├── log └── .keep ├── app ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── concerns │ │ ├── .keep │ │ └── immutable_model.rb │ ├── application_record.rb │ ├── schemas │ │ ├── parse.rb │ │ ├── fingerprint_generator.rb │ │ └── register_new_version.rb │ ├── subject.rb │ ├── compatibility.rb │ ├── schema_registry │ │ └── errors.rb │ ├── schema_version.rb │ ├── config.rb │ ├── schema.rb │ └── schema_registry.rb ├── assets │ ├── images │ │ └── .keep │ └── stylesheets │ │ └── application.css ├── controllers │ ├── concerns │ │ └── .keep │ ├── application_controller.rb │ └── pages_controller.rb ├── helpers │ └── application_helper.rb ├── views │ └── layouts │ │ └── application.html.erb └── api │ ├── helpers │ ├── cache_helper.rb │ ├── schema_version_helper.rb │ └── error_helper.rb │ ├── schema_api.rb │ ├── base_api.rb │ ├── compatibility_api.rb │ ├── config_api.rb │ └── subject_api.rb ├── lib ├── assets │ └── .keep ├── tasks │ ├── .keep │ └── cache.rake ├── grape │ └── middleware │ │ └── optional_auth.rb └── dual_heroku_rails_deploy.rb ├── public ├── favicon.ico ├── robots.txt ├── 500.html ├── 422.html ├── 404.html ├── index.html └── success.html ├── .ruby-version ├── .foreman ├── vendor └── assets │ └── stylesheets │ └── .keep ├── .ruby-gemset ├── CODEOWNERS ├── .rspec ├── Procfile ├── bin ├── rspec ├── rubocop ├── rake ├── bundle ├── rails ├── deploy ├── update ├── setup └── docker_start ├── .rubocop.yml ├── config ├── heroku.yml ├── initializers │ ├── inflections.rb │ ├── session_store.rb │ ├── mime_types.rb │ ├── fingerprint_version.rb │ ├── filter_parameter_logging.rb │ ├── cookies_serializer.rb │ ├── application_controller_renderer.rb │ ├── default_compatibility.rb │ ├── backtrace_silencers.rb │ ├── wrap_parameters.rb │ ├── reload_api.rb │ └── content_security_policy.rb ├── environment.rb ├── boot.rb ├── routes.rb ├── environments │ ├── staging.rb │ ├── test.rb │ ├── development.rb │ └── production.rb ├── puma.rb ├── database.yml ├── locales │ └── en.yml ├── secrets.yml └── application.rb ├── envrc.example ├── .dockerignore ├── config.ru ├── spec ├── zeitwerk_spec.rb ├── support │ ├── rails_configuration_custom.rb │ ├── contexts │ │ ├── secure_endpoint.rb │ │ ├── cached_endpoint.rb │ │ └── content_type_context.rb │ └── helpers │ │ └── request_helper.rb ├── factories │ ├── schema_versions.rb │ ├── configs.rb │ ├── subjects.rb │ └── schemas.rb ├── controllers │ └── pages_controller_spec.rb ├── schemas │ └── parse_spec.rb ├── spec_helper.rb ├── rails_helper.rb ├── requests │ ├── schema_api_spec.rb │ ├── compatibility_api_spec.rb │ ├── config_api_spec.rb │ └── subject_api_spec.rb ├── models │ └── schema_spec.rb └── schema_registry_spec.rb ├── Rakefile ├── db ├── migrate │ ├── 20160314152320_create_subjects.rb │ ├── 20160416174131_create_configs.rb │ ├── 20160314152739_create_schemas.rb │ ├── 20170310182013_add_fingerprint2_to_schemas.rb │ ├── 20160315151533_create_schema_versions.rb │ └── 20170310200653_populate_fingerprint2.rb ├── seeds.rb └── schema.rb ├── .gitignore ├── .github └── dependabot.yml ├── app.json ├── Gemfile ├── Dockerfile ├── .overcommit.yml ├── LICENSE.txt ├── .circleci └── config.yml ├── CHANGELOG.md ├── README.md └── Gemfile.lock /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.3 2 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.foreman: -------------------------------------------------------------------------------- 1 | port: 21000 2 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | avro-schema-registry 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @salsify/pim-core-backend 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require rails_helper 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -C config/puma.rb 2 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'bundler/setup' 3 | load Gem.bin_path('rspec-core', 'rspec') 4 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'bundler/setup' 3 | load Gem.bin_path('rubocop', 'rubocop') 4 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationHelper 4 | end 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | salsify_rubocop: conf/rubocop_rails.yml 3 | 4 | AllCops: 5 | TargetRubyVersion: 3.4 6 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /config/heroku.yml: -------------------------------------------------------------------------------- 1 | --- 2 | production: schema-registry-prod 3 | staging: schema-registry-staging 4 | compatibility: schema-registry-compatibility 5 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /envrc.example: -------------------------------------------------------------------------------- 1 | # Store unmodified PATH 2 | export ORIG_PATH="$PATH" 3 | 4 | # Prepend project bin directory to path to use wrapper scripts 5 | PATH_add bin 6 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveSupport::Inflector.inflections(:en) do |inflect| 4 | inflect.acronym 'API' 5 | end 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .env 3 | .envrc 4 | .idea 5 | .git 6 | coverage 7 | log 8 | spec 9 | tmp 10 | vendor 11 | **/node_modules 12 | **/bower_components 13 | deployment 14 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require ::File.expand_path('config/environment', __dir__) 6 | run Rails.application 7 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 4 | 5 | require 'bundler/setup' # Set up gems listed in the Gemfile. 6 | 7 | require 'bootsnap/setup' 8 | -------------------------------------------------------------------------------- /bin/deploy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative '../config/boot' 4 | require_relative '../lib/dual_heroku_rails_deploy' 5 | 6 | root_dir = File.absolute_path("#{__dir__}/..") 7 | DualHerokuRailsDeploy.deploy(root_dir, ARGV) 8 | -------------------------------------------------------------------------------- /spec/zeitwerk_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe "Zeitwerk compliance" do 4 | it "eager loads all files without errors" do 5 | expect { Rails.application.eager_load! }.not_to raise_error 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | Rails.application.config.session_store :cookie_store, key: '_avro-schema-registry_session' 6 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Add new mime types for use in respond_to blocks: 6 | # Mime::Type.register "text/richtext", :rtf 7 | -------------------------------------------------------------------------------- /config/initializers/fingerprint_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # For Rails to reload this file when it reloads code in development 4 | Rails.configuration.to_prepare do 5 | Schemas::FingerprintGenerator.valid_fingerprint_version! 6 | end 7 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure sensitive parameters which will be filtered from the log file. 6 | Rails.application.config.filter_parameters += [:password] 7 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | AvroSchemaRegistry 5 | <%= stylesheet_link_tag 'application', media: 'all' %> 6 | <%= csrf_meta_tags %> 7 | 8 | 9 | 10 | <%= yield %> 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | # Prevent CSRF attacks by raising an exception. 5 | # For APIs, you may want to use :null_session instead. 6 | protect_from_forgery with: :exception 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require File.expand_path('config/application', __dir__) 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /db/migrate/20160314152320_create_subjects.rb: -------------------------------------------------------------------------------- 1 | class CreateSubjects < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table(:subjects, id: :bigserial) do |t| 4 | t.text :name, null: false 5 | t.timestamps null: false 6 | end 7 | 8 | add_index(:subjects, :name, unique: true) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Specify a serializer for the signed and encrypted cookie jars. 6 | # Valid options are :json, :marshal, and :hybrid. 7 | Rails.application.config.action_dispatch.cookies_serializer = :json 8 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # ActiveSupport::Reloader.to_prepare do 6 | # ApplicationController.renderer.defaults.merge!( 7 | # http_host: 'example.org', 8 | # https: false 9 | # ) 10 | # end 11 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | mount SchemaAPI => '/schemas' 5 | mount SubjectAPI => '/subjects' 6 | mount ConfigAPI => '/config' 7 | mount CompatibilityAPI => '/compatibility' 8 | 9 | get '/', to: 'pages#index' 10 | get '/success', to: 'pages#success' 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20160416174131_create_configs.rb: -------------------------------------------------------------------------------- 1 | class CreateConfigs < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table(:configs, id: :bigserial) do |t| 4 | t.string :compatibility 5 | t.timestamps null: false 6 | t.bigint :subject_id 7 | end 8 | 9 | add_index(:configs, :subject_id, unique: true) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | -------------------------------------------------------------------------------- /db/migrate/20160314152739_create_schemas.rb: -------------------------------------------------------------------------------- 1 | class CreateSchemas < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table(:schemas, id: :bigserial) do |t| 4 | t.string :fingerprint, null: false 5 | t.text :json, null: false 6 | t.timestamps null: false 7 | end 8 | 9 | add_index(:schemas, :fingerprint, unique: true) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/rails_configuration_custom.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Monkey-patch Rails::Application::Configuration::Custom to allow configuration 4 | # to be stubbed. 5 | module Rails 6 | class Application::Configuration::Custom # rubocop:disable Style/ClassAndModuleChildren 7 | def respond_to_missing?(*) 8 | true 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20170310182013_add_fingerprint2_to_schemas.rb: -------------------------------------------------------------------------------- 1 | class AddFingerprint2ToSchemas < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column(:schemas, :fingerprint2, :string, null: true) 4 | add_index(:schemas, :fingerprint2, unique: true) 5 | 6 | remove_index(:schemas, :fingerprint) 7 | add_index(:schemas, :fingerprint, unique: false) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/models/schemas/parse.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Schemas 4 | 5 | # This module is used to wrap Avro schema parsing to raise a standard error 6 | # if any exception is raised. 7 | module Parse 8 | def self.call(json) 9 | Avro::Schema.parse(json) 10 | rescue StandardError 11 | raise SchemaRegistry::InvalidAvroSchemaError 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/initializers/default_compatibility.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # For Rails to reload this file when it reloads code in development 4 | Rails.configuration.to_prepare do 5 | if Compatibility::Constants::VALUES.exclude?(Rails.application.config.x.default_compatibility.upcase) 6 | raise "Default compatibility '#{Rails.application.config.x.default_compatibility}' is invalid" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /config/environments/staging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require Rails.root.join('config/environments/production') 4 | 5 | Rails.application.configure do 6 | # Settings specified here will take precedence over those in 7 | # config/environments/production.rb. 8 | 9 | config.log_level = :info 10 | 11 | config.x.default_compatibility = ENV.fetch('DEFAULT_COMPATIBILITY', 'NONE') 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20160315151533_create_schema_versions.rb: -------------------------------------------------------------------------------- 1 | class CreateSchemaVersions < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table(:schema_versions, id: :bigserial) do |t| 4 | t.integer :version, default: 1 5 | t.bigint :subject_id, null: false 6 | t.bigint :schema_id, null: false 7 | end 8 | 9 | add_index(:schema_versions, [:subject_id, :version], unique: true) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/api/helpers/cache_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Helpers 4 | module CacheHelper 5 | 6 | CACHE_CONTROL_HEADER = 'Cache-Control' 7 | CACHE_CONTROL_VALUE = "public, max-age=#{Rails.configuration.x.cache_max_age}".freeze 8 | 9 | def cache_response! 10 | header(CACHE_CONTROL_HEADER, CACHE_CONTROL_VALUE) if Rails.configuration.x.allow_response_caching 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/grape/middleware/optional_auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Grape 4 | module Middleware 5 | # This middleware allows HTTP Basic/Digest middleware to be bypassed based 6 | # on configuration. 7 | class OptionalAuth < Grape::Middleware::Auth::Base 8 | def call(env) 9 | Rails.configuration.x.disable_password ? app.call(env) : super 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Simple configuration based on 4 | # https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server 5 | 6 | workers Integer(ENV['WEB_WORKER_PROCESSES'] || 2) 7 | threads_count = Integer(ENV['MAX_WEB_THREADS'] || 5) 8 | threads threads_count, threads_count 9 | 10 | preload_app! 11 | 12 | port ENV['PORT'] || 21000 13 | environment ENV['RACK_ENV'] || 'development' 14 | -------------------------------------------------------------------------------- /db/migrate/20170310200653_populate_fingerprint2.rb: -------------------------------------------------------------------------------- 1 | class PopulateFingerprint2 < ActiveRecord::Migration[5.0] 2 | 3 | class Schema < ApplicationRecord 4 | end 5 | 6 | def up 7 | Schema.find_each(batch_size: 100) do |schema| 8 | schema.update_attribute( 9 | :fingerprint2, 10 | Schemas::FingerprintGenerator.generate_v2(schema.json)) 11 | end 12 | end 13 | 14 | def down 15 | Schema.update_all(fingerprint2: nil) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/dual_heroku_rails_deploy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'heroku_rails_deploy/deployer' 4 | 5 | module DualHerokuRailsDeploy 6 | 7 | def self.deploy(root_dir, args) 8 | config_file = File.join(root_dir, 'config', 'heroku.yml') 9 | deploy = HerokuRailsDeploy::Deployer.new(config_file, args) 10 | deploy.run 11 | 12 | HerokuRailsDeploy::Deployer.new(config_file, ['-e', 'compatibility']).run if deploy.production? 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/factories/schema_versions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: schema_versions 6 | # 7 | # id :integer not null, primary key 8 | # version :integer default(1) 9 | # subject_id :integer not null 10 | # schema_id :integer not null 11 | # 12 | 13 | FactoryBot.define do 14 | factory :schema_version, aliases: [:version] do 15 | subject 16 | schema 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/factories/configs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: configs 6 | # 7 | # id :integer not null, primary key 8 | # compatibility :string 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # subject_id :integer 12 | # 13 | 14 | FactoryBot.define do 15 | factory :config do 16 | subject 17 | compatibility { Config::DEFAULT_COMPATIBILITY } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 6 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 7 | 8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 9 | # Rails.backtrace_cleaner.remove_silencers! 10 | -------------------------------------------------------------------------------- /spec/support/contexts/secure_endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_examples_for "a secure endpoint" do 4 | context "when HTTP Basic password is disabled" do 5 | before do 6 | allow(Rails.configuration.x).to receive(:disable_password).and_return(true) 7 | end 8 | 9 | it "allows unauthorized requests" do 10 | action 11 | expect(status).to eq(200) 12 | end 13 | end 14 | 15 | it "is secured by Basic auth" do 16 | action 17 | expect(status).to eq(401) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/controllers/pages_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe PagesController do 4 | render_views 5 | 6 | describe "#index" do 7 | it "renders the index file" do 8 | get(:index) 9 | expect(response.body).to eq(IO.read("#{Rails.root}/public/index.html")) 10 | end 11 | end 12 | 13 | describe "#success" do 14 | it "renders the success file" do 15 | get(:success) 16 | expect(response.body).to eq(IO.read("#{Rails.root}/public/success.html")) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore local configuration 11 | /.envrc 12 | 13 | # Ignore all logfiles and tempfiles. 14 | /log/* 15 | !/log/.keep 16 | /tmp 17 | .idea 18 | coverage 19 | *.iml 20 | -------------------------------------------------------------------------------- /app/controllers/pages_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PagesController < ApplicationController 4 | 5 | # This page is primarily for the benefit of the schema registry hosted 6 | # at avro-schema-registry.salsify.com 7 | def index 8 | render(file: "#{Rails.root}/public/index.html", layout: false) 9 | end 10 | 11 | # This page is displayed after successfully deploying the app using the 12 | # Heroku button. 13 | def success 14 | render(file: "#{Rails.root}/public/success.html", layout: false) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/schemas/parse_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Schemas::Parse do 4 | describe ".call" do 5 | let(:json) { build(:schema).json } 6 | 7 | let(:schema) { described_class.call(json) } 8 | 9 | it "returns an Avro::Schema" do 10 | expect(schema).to be_a(Avro::Schema) 11 | end 12 | 13 | context "with an invalid schema" do 14 | let(:json) { {}.to_json } 15 | 16 | it "raises an InvalidAvroSchemaError" do 17 | expect { schema }.to raise_error(SchemaRegistry::InvalidAvroSchemaError) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/api/schema_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SchemaAPI < Grape::API 4 | include BaseAPI 5 | 6 | rescue_from ActiveRecord::RecordNotFound do 7 | schema_not_found! 8 | end 9 | 10 | rescue_from :all do 11 | server_error! 12 | end 13 | 14 | helpers ::Helpers::CacheHelper 15 | 16 | desc 'Get the schema string identified by the input id' 17 | params do 18 | requires :id, type: Integer, desc: 'Schema ID' 19 | end 20 | get '/ids/:id' do 21 | schema = ::Schema.find(params[:id]) 22 | cache_response! 23 | { schema: schema.json } 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # This file contains settings for ActionController::ParamsWrapper which 6 | # is enabled by default. 7 | 8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 9 | ActiveSupport.on_load(:action_controller) do 10 | wrap_parameters format: [:json] 11 | end 12 | 13 | # To enable root element in JSON for ActiveRecord objects. 14 | # ActiveSupport.on_load(:active_record) do 15 | # self.include_root_in_json = true 16 | # end 17 | -------------------------------------------------------------------------------- /spec/factories/subjects.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: subjects 6 | # 7 | # id :integer not null, primary key 8 | # name :text not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | 13 | FactoryBot.define do 14 | factory :subject, aliases: [:value_subject] do 15 | sequence(:name) { |n| "com.example.test.subject_#{n}_value" } 16 | 17 | factory :key_subject do 18 | sequence(:name) { |n| "com.example.test.subject_#{n}_key" } 19 | end 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | time: "10:00" 13 | versioning-strategy: lockfile-only 14 | # Setting to 0 enables security PRs only 15 | open-pull-requests-limit: 0 16 | -------------------------------------------------------------------------------- /config/initializers/reload_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Grape reloading support 4 | # https://github.com/ruby-grape/grape#reloading-api-changes-in-development 5 | # Note: adding to explicitly_unloadable_constants caused problems with Spring 6 | # so that recommendation from above is not followed. 7 | 8 | if Rails.env.development? 9 | api_files = Dir[Rails.root.join('app', 'api', '**', '*.rb')] 10 | api_reloader = ActiveSupport::FileUpdateChecker.new(api_files) do 11 | Rails.application.reload_routes! 12 | end 13 | ActiveSupport::Reloader.to_prepare do 14 | api_reloader.execute_if_updated 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/models/concerns/immutable_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # All of the models in this application are insert only. This concern 4 | # is used to ensure that they remain immutable. 5 | module ImmutableModel 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | delegate :read_only_model!, to: :class 10 | alias_method :delete, :read_only_model! 11 | end 12 | 13 | def readonly? 14 | persisted? 15 | end 16 | 17 | module ClassMethods 18 | def delete_all(*) 19 | read_only_model! 20 | end 21 | 22 | def read_only_model! 23 | raise ActiveRecord::ReadOnlyRecord 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 4 | require 'simplecov' 5 | 6 | RSpec.configure do |config| 7 | config.expect_with :rspec do |expectations| 8 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 9 | end 10 | 11 | config.mock_with :rspec do |mocks| 12 | # Prevents you from mocking or stubbing a method that does not exist on 13 | # a real object. This is generally recommended, and will default to 14 | # `true` in RSpec 4. 15 | mocks.verify_partial_doubles = true 16 | end 17 | 18 | config.order = :random 19 | 20 | config.include FactoryBot::Syntax::Methods 21 | end 22 | -------------------------------------------------------------------------------- /app/models/subject.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: subjects 6 | # 7 | # id :integer not null, primary key 8 | # name :text not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | 13 | class Subject < ApplicationRecord 14 | include ImmutableModel 15 | 16 | NAME_REGEXP = /[a-zA-Z_][\w.-]*/ 17 | 18 | has_one :config # rubocop:disable Rails/HasManyOrHasOneDependent 19 | has_many :versions, class_name: 'SchemaVersion', inverse_of: :subject # rubocop:disable Rails/HasManyOrHasOneDependent 20 | has_many :schemas, through: :versions 21 | 22 | validates :name, format: { with: NAME_REGEXP } 23 | end 24 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = File.expand_path('..', __dir__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | puts "\n== Updating database ==" 22 | system! 'bin/rails db:migrate' 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! 'bin/rails log:clear tmp:clear' 26 | 27 | puts "\n== Restarting application server ==" 28 | system! 'bin/rails restart' 29 | end 30 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | encoding: unicode 4 | # For details on connection pooling, see rails configuration guide 5 | # http://guides.rubyonrails.org/configuring.html#database-pooling 6 | pool: <%= ENV.fetch("DB_POOL", 4) %> 7 | port: <%= ENV.fetch("DB_PORT", 5432) %> 8 | host: <%= ENV.fetch("DB_HOST", 'docker.local.host') %> 9 | username: <%= ENV.fetch("DB_USER", 'postgres') %> 10 | password: <%= ENV.fetch("DB_PASSWORD", 'password') %> 11 | variables: 12 | lock_timeout: <%= ENV.fetch('DB_LOCK_TIMEOUT', 0) %> 13 | statement_timeout: <%= ENV.fetch('DB_STATEMENT_TIMEOUT', 0) %> 14 | 15 | development: 16 | <<: *default 17 | database: avro-schema-registry_development 18 | 19 | test: 20 | <<: *default 21 | database: avro-schema-registry_test 22 | 23 | production: 24 | <<: *default 25 | url: <%= ENV['DATABASE_URL'] %> 26 | 27 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Avro Schema Registry", 3 | "description": "Service for storing and retrieving versions of Avro schemas.", 4 | "repository": "https://github.com/salsify/avro-schema-registry", 5 | "keywords": [ 6 | "avro", 7 | "schema", 8 | "registry", 9 | "salsify" 10 | ], 11 | "success_url": "/success", 12 | "env": { 13 | "SECRET_KEY_BASE": { 14 | "description": "Rails secret key", 15 | "generator": "secret" 16 | }, 17 | "SCHEMA_REGISTRY_PASSWORD": { 18 | "description": "Password for HTTP Basic Authentication", 19 | "generator": "secret" 20 | }, 21 | "NEW_RELIC_APP_NAME": { 22 | "description": "Name for this application in New Relic monitoring", 23 | "value": "avro-schema-registry" 24 | } 25 | }, 26 | "addons": ["heroku-postgresql:hobby-dev", "newrelic"], 27 | "scripts": { 28 | "postdeploy": "bundle exec rake db:migrate" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/models/compatibility.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Compatibility 4 | 5 | module Constants 6 | BOTH = 'BOTH' # deprecated 7 | BACKWARD = 'BACKWARD' 8 | BACKWARD_TRANSITIVE = 'BACKWARD_TRANSITIVE' 9 | FORWARD = 'FORWARD' 10 | FORWARD_TRANSITIVE = 'FORWARD_TRANSITIVE' 11 | FULL = 'FULL' 12 | FULL_TRANSITIVE = 'FULL_TRANSITIVE' 13 | NONE = 'NONE' 14 | 15 | VALUES = Set.new([BOTH, BACKWARD, BACKWARD_TRANSITIVE, FORWARD, 16 | FORWARD_TRANSITIVE, FULL, FULL_TRANSITIVE, NONE]).freeze 17 | 18 | TRANSITIVE_VALUES = Set.new([BACKWARD_TRANSITIVE, FORWARD_TRANSITIVE, FULL_TRANSITIVE]).freeze 19 | end 20 | 21 | class InvalidCompatibilityLevelError < StandardError 22 | def initialize(invalid_level) 23 | super("Invalid compatibility level #{invalid_level.inspect}") 24 | end 25 | end 26 | 27 | def self.global 28 | Config.global.compatibility 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/models/schema_registry/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SchemaRegistry 4 | 5 | # This module contains error codes and messages defined by the Confluent 6 | # schema registry. 7 | module Errors 8 | SUBJECT_NOT_FOUND = { error_code: 40401, message: 'Subject not found' }.freeze 9 | VERSION_NOT_FOUND = { error_code: 40402, message: 'Version not found' }.freeze 10 | SCHEMA_NOT_FOUND = { error_code: 40403, message: 'Schema not found' }.freeze 11 | INCOMPATIBLE_AVRO_SCHEMA = { error_code: 40901, message: 'Incompatible Avro schema' }.freeze 12 | INVALID_AVRO_SCHEMA = { error_code: 42201, message: 'Invalid Avro schema' }.freeze 13 | INVALID_COMPATIBILITY_LEVEL = { error_code: 44203, message: 'Invalid compatibility level' }.freeze 14 | SERVER_ERROR = { error_code: 50001, message: 'Error in the backend datastore' }.freeze 15 | 16 | READ_ONLY_MODE = { message: 'Running in read-only mode' }.freeze 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/api/helpers/schema_version_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Helpers 4 | 5 | module SchemaVersionHelper 6 | include Helpers::ErrorHelper 7 | 8 | LATEST_VERSION = 'latest' 9 | 10 | private 11 | 12 | def with_schema_version(subject_name, version) 13 | schema_version = find_schema_version(subject_name, version) 14 | 15 | if schema_version 16 | yield schema_version 17 | elsif Subject.where(name: subject_name).exists? 18 | version_not_found! 19 | else 20 | subject_not_found! 21 | end 22 | end 23 | 24 | def find_schema_version(subject_name, version) 25 | relation = SchemaVersion.eager_load(:schema, subject: [:config]) 26 | if version == LATEST_VERSION 27 | relation.for_subject_name(subject_name).latest 28 | else 29 | relation.where(version: version).for_subject_name(subject_name) 30 | end.first 31 | end 32 | 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /spec/factories/schemas.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: schemas 6 | # 7 | # id :integer not null, primary key 8 | # fingerprint :string not null 9 | # json :text not null 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # fingerprint2 :string 13 | # 14 | 15 | FactoryBot.define do 16 | factory :schema do 17 | sequence(:json) do |n| 18 | { 19 | type: :record, 20 | name: "rec#{n}", 21 | fields: [ 22 | { name: "field#{n}", type: :string, default: '' } 23 | ] 24 | }.to_json 25 | end 26 | end 27 | 28 | factory :schema_without_default, class: 'Schema' do 29 | sequence(:json) do |n| 30 | { 31 | type: :record, 32 | name: "rec#{n}", 33 | fields: [ 34 | { name: "field#{n}", type: :int } 35 | ] 36 | }.to_json 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ruby '3.4.3' 4 | 5 | source 'https://rubygems.org' 6 | 7 | gem 'avro', '~> 1.10.0' 8 | gem 'avro-resolution_canonical_form', '>= 0.2.0' 9 | gem 'bootsnap', require: false 10 | gem 'grape' 11 | gem 'pg' 12 | gem 'private_attr', require: 'private_attr/everywhere' 13 | gem 'puma', '>= 5.6.7' 14 | gem 'rails', '~> 7.2.2' 15 | 16 | group :test do 17 | gem 'json_spec' 18 | gem 'rails-controller-testing' 19 | gem 'rspec_junit_formatter' 20 | gem 'rspec-rails' 21 | gem 'simplecov' 22 | end 23 | 24 | group :production do 25 | gem 'bugsnag' 26 | end 27 | 28 | group :development do 29 | gem 'annotate' 30 | gem 'avro_turf', '>= 0.8.0', require: false 31 | gem 'heroku_rails_deploy', '>= 0.4.1', require: false 32 | gem 'overcommit' 33 | end 34 | 35 | group :development, :production do 36 | gem 'newrelic_rpm' 37 | end 38 | 39 | group :development, :test do 40 | gem 'dotenv-rails' 41 | gem 'factory_bot_rails' 42 | gem 'salsify_rubocop', require: false 43 | end 44 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['RAILS_ENV'] ||= 'test' 4 | require 'simplecov' 5 | SimpleCov.start 'rails' 6 | 7 | require File.expand_path('../config/environment', __dir__) 8 | # Prevent database truncation if the environment is production 9 | abort('The Rails environment is running in production mode!') if Rails.env.production? 10 | require 'spec_helper' 11 | require 'rspec/rails' 12 | # Add additional requires below this line. Rails is not loaded until this point! 13 | 14 | Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |f| require f } 15 | 16 | # Checks for pending migration and applies them before tests are run. 17 | # If you are not using ActiveRecord, you can remove this line. 18 | ActiveRecord::Migration.maintain_test_schema! 19 | 20 | RSpec.configure do |config| 21 | config.use_transactional_fixtures = true 22 | 23 | config.infer_spec_type_from_file_location! 24 | 25 | config.filter_rails_from_backtrace! 26 | 27 | config.include RequestHelper, type: :request 28 | end 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # To build run: docker build -t avro-schema-registry . 2 | 3 | FROM ruby:3.4.3 4 | 5 | RUN mkdir /app 6 | WORKDIR /app 7 | 8 | # Copy the Gemfile as well as the Gemfile.lock and install 9 | # the RubyGems. This is a separate step so the dependencies 10 | # will be cached unless changes to one of those two files 11 | # are made. 12 | COPY Gemfile Gemfile.lock ./ 13 | RUN gem install bundler --no-document && bundle install --jobs 20 --retry 5 14 | 15 | COPY . /app 16 | 17 | # Run the app as a non-root user. The source code will be read-only, 18 | # but the process will complain if it can't write to tmp or log (even 19 | # though we're writing the logs to STDOUT). 20 | RUN mkdir /app/tmp /app/log 21 | RUN groupadd --system avro && \ 22 | useradd --no-log-init --system --create-home --gid avro avro && \ 23 | chown -R avro:avro /app/tmp /app/log 24 | USER avro 25 | 26 | ENV RACK_ENV=production 27 | ENV RAILS_ENV=production 28 | ENV RAILS_LOG_TO_STDOUT=true 29 | ENV PORT=5000 30 | 31 | EXPOSE $PORT 32 | 33 | # Start puma 34 | CMD bin/docker_start 35 | -------------------------------------------------------------------------------- /spec/support/contexts/cached_endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | shared_examples_for "a cached endpoint" do 4 | context "when caching is enabled" do 5 | before do 6 | allow(Rails.configuration.x).to receive(:allow_response_caching).and_return(true) 7 | end 8 | 9 | it "allows the response to be cached" do 10 | action 11 | expect(response.headers['Cache-Control']).to eq(Helpers::CacheHelper::CACHE_CONTROL_VALUE) 12 | end 13 | end 14 | 15 | context "when caching is not enabled" do 16 | before do 17 | allow(Rails.configuration.x).to receive(:allow_response_caching).and_return(false) 18 | end 19 | 20 | it "does not allow the response to be cached" do 21 | action 22 | expect(response.headers['Cache-Control']).to match(/max-age=0,/) 23 | end 24 | end 25 | end 26 | 27 | shared_examples_for "an error that cannot be cached" do 28 | it "does not allow the response to be cached" do 29 | action 30 | expect(response.headers['Cache-Control']).to eq('no-cache') 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 08899b1660649004c82b7a3311c58914c73cefcbb181c6504aa8bcd0ecee51fd1e257e303cc1de1a9605795ebd280a56e514a5a9b603c21cb6c75e59dfc3cdb6 15 | 16 | test: 17 | secret_key_base: b642a611168cca9a170c5fe029e6e53a1a36e863d8292d0ebaa2e38df866c0b3c19c378c74c40ec699c0b9bede2787aaffe381266904ecf73168d5f192722d07 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: &production 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | 24 | staging: 25 | <<: *production 26 | -------------------------------------------------------------------------------- /app/models/schema_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: schema_versions 6 | # 7 | # id :integer not null, primary key 8 | # version :integer default(1) 9 | # subject_id :integer not null 10 | # schema_id :integer not null 11 | # 12 | 13 | class SchemaVersion < ApplicationRecord 14 | include ImmutableModel 15 | 16 | belongs_to :subject, inverse_of: :versions 17 | belongs_to :schema, inverse_of: :versions 18 | 19 | scope :latest, 20 | -> { order(version: :desc).limit(1) } 21 | scope :for_subject_name, 22 | ->(subject_name) { joins(:subject).where('subjects.name = ?', subject_name) } 23 | scope :latest_for_subject_name, 24 | ->(subject_name) { for_subject_name(subject_name).latest } 25 | scope :for_schema, 26 | ->(schema_id) { where(schema_id: schema_id) } 27 | scope :for_schema_fingerprint, ->(fingerprint) { joins(:schema).merge(Schema.with_fingerprint(fingerprint)) } 28 | scope :for_schema_json, ->(json) { joins(:schema).merge(Schema.with_json(json)) } 29 | end 30 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | # Use this file to configure the Overcommit hooks you wish to use. This will 2 | # extend the default configuration defined in: 3 | # https://github.com/brigade/overcommit/blob/master/config/default.yml 4 | # 5 | # At the topmost level of this YAML file is a key representing type of hook 6 | # being run (e.g. pre-commit, commit-msg, etc.). Within each type you can 7 | # customize each hook, such as whether to only run it on certain files (via 8 | # `include`), whether to only display output if it fails (via `quiet`), etc. 9 | # 10 | # For a complete list of hooks, see: 11 | # https://github.com/brigade/overcommit/tree/master/lib/overcommit/hook 12 | # 13 | # For a complete list of options that you can use to customize hooks, see: 14 | # https://github.com/brigade/overcommit#configuration 15 | # 16 | # Uncomment the following lines to make the configuration take effect. 17 | 18 | PreCommit: 19 | RuboCop: 20 | enabled: true 21 | required: false 22 | on_warn: fail 23 | 24 | HardTabs: 25 | enabled: true 26 | required: false 27 | 28 | CommitMsg: 29 | TrailingPeriod: 30 | enabled: false 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Salsify, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/api/helpers/error_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Helpers 4 | 5 | # This module defines helpers for the documented errors that the schema 6 | # registry may return. 7 | module ErrorHelper 8 | 9 | def server_error! 10 | error!(SchemaRegistry::Errors::SERVER_ERROR, 500) 11 | end 12 | 13 | def subject_not_found! 14 | error!(SchemaRegistry::Errors::SUBJECT_NOT_FOUND, 404) 15 | end 16 | 17 | def schema_not_found! 18 | error!(SchemaRegistry::Errors::SCHEMA_NOT_FOUND, 404) 19 | end 20 | 21 | def version_not_found! 22 | error!(SchemaRegistry::Errors::VERSION_NOT_FOUND, 404) 23 | end 24 | 25 | def incompatible_avro_schema! 26 | error!(SchemaRegistry::Errors::INCOMPATIBLE_AVRO_SCHEMA, 409) 27 | end 28 | 29 | def invalid_avro_schema! 30 | error!(SchemaRegistry::Errors::INVALID_AVRO_SCHEMA, 422) 31 | end 32 | 33 | def invalid_compatibility_level! 34 | error!(SchemaRegistry::Errors::INVALID_COMPATIBILITY_LEVEL, 422) 35 | end 36 | 37 | def read_only_mode! 38 | error!(SchemaRegistry::Errors::READ_ONLY_MODE, 403) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/models/schemas/fingerprint_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Schemas 4 | # This module is used to standardize the fingerprint generation for an 5 | # Avro JSON schema 6 | module FingerprintGenerator 7 | 8 | VALID_FINGERPRINT_VERSIONS = ['1', '2', 'all'].to_set.freeze 9 | 10 | V1_VERSIONS = ['1', 'all'].to_set.freeze 11 | V2_VERSIONS = ['2', 'all'].to_set.freeze 12 | 13 | def self.valid_fingerprint_version! 14 | unless VALID_FINGERPRINT_VERSIONS.include?(Rails.configuration.x.fingerprint_version) 15 | raise "Invalid fingerprint version: #{Rails.configuration.x.fingerprint_version.inspect}" 16 | end 17 | end 18 | 19 | def self.generate_v1(json) 20 | Schemas::Parse.call(json).sha256_fingerprint.to_s(16) 21 | end 22 | 23 | def self.generate_v2(json) 24 | Schemas::Parse.call(json).sha256_resolution_fingerprint.to_s(16) 25 | end 26 | 27 | def self.include_v2? 28 | V2_VERSIONS.include?(Rails.configuration.x.fingerprint_version) 29 | end 30 | 31 | def self.include_v1? 32 | V1_VERSIONS.include?(Rails.configuration.x.fingerprint_version) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/tasks/cache.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if Rails.env.development? 4 | require 'avro_turf' 5 | require 'avro_turf/confluent_schema_registry' 6 | 7 | desc 'Make cacheable requests for all existing schemas in the registry' 8 | task cache_all_requests: [:environment] do 9 | raise 'registry_url must be specified' unless ENV['registry_url'] 10 | 11 | logger = Logger.new($stdout) 12 | logger.level = Logger::ERROR 13 | client = AvroTurf::ConfluentSchemaRegistry.new(ENV['registry_url'], logger: logger) 14 | 15 | client.subjects.each do |subject| 16 | 17 | puts "Fetching schemas for subject #{subject}" 18 | client.subject_versions(subject).each do |version| 19 | subject_version = client.subject_version(subject, version) 20 | schema_object = Avro::Schema.parse(subject_version['schema']) 21 | fingerprint = schema_object.sha256_resolution_fingerprint.to_s(16) 22 | 23 | puts ".. Checking fingerprint #{fingerprint} for version #{subject_version['version']}" 24 | id = client.send(:get, "/subjects/#{subject}/fingerprints/#{fingerprint}")['id'] 25 | 26 | puts ".. Fetching schema for id #{id}" 27 | client.fetch(id) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/api/base_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'grape/middleware/optional_auth' 4 | 5 | # This module provides shared configuration for the Schema Registry API 6 | module BaseAPI 7 | extend ActiveSupport::Concern 8 | 9 | SCHEMA_REGISTRY_V1_CONTENT_TYPE = 'application/vnd.schemaregistry.v1+json' 10 | SCHEMA_REGISTRY_CONTENT_TYPE = 'application/vnd.schemaregistry.json' 11 | JSON_CONTENT_TYPE = 'application/json' 12 | 13 | included do 14 | content_type :json, JSON_CONTENT_TYPE 15 | content_type :schema_registry, SCHEMA_REGISTRY_CONTENT_TYPE 16 | content_type :schema_registry_v1, SCHEMA_REGISTRY_V1_CONTENT_TYPE 17 | 18 | [:json, :schema_registry, :schema_registry_v1].each do |content_type_sym| 19 | parser content_type_sym, Grape::Parser::Json 20 | formatter content_type_sym, Grape::Formatter::Json 21 | end 22 | 23 | default_format :schema_registry_v1 24 | 25 | helpers ::Helpers::ErrorHelper 26 | 27 | use Grape::Middleware::OptionalAuth, 28 | type: :http_basic, 29 | realm: 'API Authorization', 30 | proc: ->(_username, password) do 31 | ActiveSupport::SecurityUtils.secure_compare(password, Rails.configuration.x.app_password) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to setup or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | puts "== Copying environment file ==" 21 | unless File.exist?(".envrc") 22 | FileUtils.cp "envrc.example", ".envrc" 23 | end 24 | 25 | # puts "\n== Copying sample files ==" 26 | # unless File.exist?('config/database.yml') 27 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 28 | # end 29 | 30 | puts "\n== Preparing database ==" 31 | system! 'bin/rails db:prepare' 32 | 33 | puts "\n== Removing old logs and tempfiles ==" 34 | system! 'bin/rails log:clear tmp:clear' 35 | 36 | puts "\n== Restarting application server ==" 37 | system! 'bin/rails restart' 38 | end 39 | -------------------------------------------------------------------------------- /spec/support/helpers/request_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Helper to set Basic authentication for requests. 4 | module RequestHelper 5 | extend ActiveSupport::Concern 6 | 7 | GRAPE_ROUTE_METHODS = [:get, :post, :put, :head, :delete, :patch].freeze 8 | 9 | included do 10 | GRAPE_ROUTE_METHODS.each do |method| 11 | # Alias the original method so we can explicitly call it as unauthorized_. 12 | alias_method("unauthorized_#{method}", method) 13 | 14 | # Overrides the http method to automatically set the authorization header 15 | # for HTTP Basic auth 16 | define_method(method) do |path, params: nil, headers: nil| 17 | request_headers = headers.try(:dup) || { 'CONTENT_TYPE' => 'application/json' } 18 | basic_auth = ActionController::HttpAuthentication::Basic 19 | .encode_credentials('ignored', Rails.configuration.x.app_password) 20 | request_headers['Authorization'] ||= basic_auth 21 | request_params = 22 | if params && !params.is_a?(String) 23 | params.to_json 24 | else 25 | params 26 | end 27 | send("unauthorized_#{method}", path, params: request_params, headers: request_headers) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/models/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: configs 6 | # 7 | # id :integer not null, primary key 8 | # compatibility :string 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # subject_id :integer 12 | # 13 | 14 | class Config < ApplicationRecord 15 | 16 | # This default differs from the Confluent default of BACKWARD 17 | COMPATIBILITY_NAME = 'compatibility' 18 | 19 | belongs_to :subject, optional: true 20 | 21 | validates :compatibility, 22 | inclusion: { in: Compatibility::Constants::VALUES, 23 | message: 'invalid: %' }, 24 | allow_nil: true 25 | 26 | def compatibility=(value) 27 | super(value.try(:upcase)) 28 | end 29 | 30 | def self.global 31 | find_or_create_by!(id: 0) do |config| 32 | config.compatibility = Rails.application.config.x.default_compatibility 33 | end 34 | end 35 | 36 | def update_compatibility!(compatibility) 37 | update!(compatibility: compatibility) 38 | rescue ActiveRecord::RecordInvalid 39 | if errors.key?(:compatibility) 40 | raise Compatibility::InvalidCompatibilityLevelError.new(compatibility) 41 | else 42 | raise 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /bin/docker_start: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'uri' 4 | require 'active_record' 5 | 6 | if ENV['AUTO_MIGRATE'] == '1' 7 | pg_uri = URI.parse(ENV['DATABASE_URL']) 8 | port = pg_uri.port || 5432 9 | 10 | # check that we can connect to Postgres 11 | connected = false 12 | exception = nil 13 | connection = nil 14 | 60.times do 15 | begin 16 | puts "Attempting DB connection at #{pg_uri.host}:#{port} with user #{pg_uri.user}" 17 | ActiveRecord::Base.establish_connection(ENV['DATABASE_URL']) 18 | connection = ActiveRecord::Base.connection 19 | connected = true 20 | break 21 | rescue ActiveRecord::NoDatabaseError 22 | # `NoDatabaseError` error is OK, we'll create the DB below 23 | connected = true 24 | break 25 | rescue StandardError => e 26 | exception = e 27 | sleep(2.second) 28 | ensure 29 | connection.close unless connection.nil? 30 | end 31 | end 32 | raise exception unless connected 33 | 34 | # the PG driver is slow to release its port binding: https://www.postgresql.org/message-id/30038.1494916166%40sss.pgh.pa.us 35 | sleep(2.second) 36 | 37 | # create and migrate DB 38 | abort('Unable to migrate DB') unless system('bundle exec rails db:create db:migrate') 39 | end 40 | 41 | exec('bundle exec puma -C config/puma.rb') 42 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Define an application-wide content security policy 6 | # For further information see the following documentation 7 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 8 | 9 | # Rails.application.config.content_security_policy do |policy| 10 | # policy.default_src :self, :https 11 | # policy.font_src :self, :https, :data 12 | # policy.img_src :self, :https, :data 13 | # policy.object_src :none 14 | # policy.script_src :self, :https 15 | # policy.style_src :self, :https 16 | 17 | # # Specify URI for violation reports 18 | # # policy.report_uri "/csp-violation-report-endpoint" 19 | # end 20 | 21 | # If you are using UJS then enable automatic nonce generation 22 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 23 | 24 | # Set the nonce only to specific directives 25 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 26 | 27 | # Report CSP violations to a specified URI 28 | # For further information see the following documentation: 29 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 30 | # Rails.application.config.content_security_policy_report_only = true 31 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | docker: 5 | - image: ruby:3.4.3 6 | - image: cimg/postgres:14.7 7 | environment: 8 | POSTGRES_USER: "ubuntu" 9 | POSTGRES_DB: "avro-schema-registry_test" 10 | POSTGRES_HOST_AUTH_METHOD: "trust" 11 | environment: 12 | RACK_ENV: "test" 13 | RAILS_ENV: "test" 14 | CIRCLE_TEST_REPORTS: "test-results" 15 | DB_HOST: "localhost" 16 | DB_USER: "ubuntu" 17 | working_directory: ~/avro-schema-registry 18 | steps: 19 | - checkout 20 | - restore_cache: 21 | keys: 22 | - v1-gems-ruby-3.4.3-{{ checksum "Gemfile.lock" }} 23 | - v1-gems-ruby-3.4.3- 24 | - run: 25 | name: Install Gems 26 | command: | 27 | if ! bundle check --path=vendor/bundle; then 28 | bundle install --path=vendor/bundle --jobs=4 --retry=3 29 | bundle clean 30 | fi 31 | - save_cache: 32 | key: v1-gems-ruby-3.4.3-{{ checksum "Gemfile.lock" }} 33 | paths: 34 | - "vendor/bundle" 35 | - run: 36 | name: Run Rubocop 37 | command: bundle exec rubocop --config .rubocop.yml 38 | - run: 39 | name: Run Tests 40 | command: | 41 | bundle exec rspec --format RspecJunitFormatter --out $CIRCLE_TEST_REPORTS/rspec/junit.xml --format progress spec 42 | - store_test_results: 43 | path: "test-results" 44 | - store_artifacts: 45 | path: "log" 46 | workflows: 47 | build: 48 | jobs: 49 | - build 50 | -------------------------------------------------------------------------------- /app/api/compatibility_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CompatibilityAPI < Grape::API 4 | include BaseAPI 5 | 6 | helpers ::Helpers::SchemaVersionHelper 7 | 8 | rescue_from SchemaRegistry::InvalidAvroSchemaError do 9 | invalid_avro_schema! 10 | end 11 | 12 | rescue_from Grape::Exceptions::ValidationErrors do |e| 13 | if e.errors.keys == [['with_compatibility']] 14 | invalid_compatibility_level! 15 | else 16 | error!({ message: e.message }, 422) 17 | end 18 | end 19 | 20 | rescue_from :all do 21 | server_error! 22 | end 23 | 24 | desc 'Test input schema against a particular version of a subject\’s schema '\ 25 | 'for compatibility' 26 | params do 27 | requires :subject, type: String, desc: 'Subject name' 28 | requires :version_id, types: [Integer, String], 29 | desc: 'Version of the schema registered under the subject' 30 | requires :schema, type: String, desc: 'New Avro schema to compare against' 31 | optional :with_compatibility, type: String, desc: 'The compatibility level to test', 32 | values: Compatibility::Constants::VALUES 33 | end 34 | post '/subjects/:subject/versions/:version_id', requirements: { subject: Subject::NAME_REGEXP } do 35 | with_schema_version(params[:subject], params[:version_id]) do |schema_version| 36 | status 200 37 | { 38 | is_compatible: SchemaRegistry.compatible?(params[:schema], 39 | version: schema_version, 40 | compatibility: params[:with_compatibility]) 41 | } 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/requests/schema_api_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | describe SchemaAPI do 6 | describe "GET /schemas/ids/:id" do 7 | let(:schema) { create(:schema) } 8 | 9 | it_behaves_like "a secure endpoint" do 10 | let(:action) { unauthorized_get("/schemas/ids/#{schema.id}") } 11 | end 12 | 13 | context "content type" do 14 | include_examples "content type", :get do 15 | let(:path) { "/schemas/ids/#{schema.id}" } 16 | let(:expected) do 17 | { schema: schema.json }.to_json 18 | end 19 | end 20 | end 21 | 22 | context "when the schema is found" do 23 | let(:expected) do 24 | { schema: schema.json }.to_json 25 | end 26 | 27 | it "returns the schema" do 28 | get("/schemas/ids/#{schema.id}") 29 | expect(response).to be_ok 30 | expect(response.body).to be_json_eql(expected) 31 | end 32 | 33 | it_behaves_like "a cached endpoint" do 34 | let(:action) { get("/schemas/ids/#{schema.id}") } 35 | end 36 | end 37 | 38 | context "when the schema is not found" do 39 | let(:schema_id) { create(:schema).id + 1 } 40 | let(:expected) do 41 | { 42 | error_code: 40403, 43 | message: 'Schema not found' 44 | }.to_json 45 | end 46 | let(:action) { get("/schemas/ids/#{schema_id}") } 47 | 48 | it "returns a not found response" do 49 | action 50 | expect(response).to be_not_found 51 | expect(response.body).to be_json_eql(expected) 52 | end 53 | 54 | it_behaves_like "an error that cannot be cached" 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/support/contexts/content_type_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This shared example expects the following variables to be defined 4 | # @param :expected [JSON] JSON response 5 | # @param :path [String] Request path 6 | # @param :params [Hash, NilClass] Optional params for request 7 | shared_examples_for "content type" do |verb| 8 | let(:_params) { defined?(params) ? params : nil } 9 | 10 | it "returns a schema registry v1 response" do 11 | send(verb, path, params: _params) 12 | expect(response.headers['Content-Type']).to eq(BaseAPI::SCHEMA_REGISTRY_V1_CONTENT_TYPE) 13 | end 14 | 15 | it "accepts schema registry v1 json requests" do 16 | send(verb, path, params: _params, 17 | headers: { 18 | 'ACCEPT' => BaseAPI::SCHEMA_REGISTRY_V1_CONTENT_TYPE, 19 | 'CONTENT_TYPE' => BaseAPI::SCHEMA_REGISTRY_V1_CONTENT_TYPE 20 | }) 21 | expect(response).to be_ok 22 | expect(response.body).to be_json_eql(expected) 23 | end 24 | 25 | it "accepts schema registry json requests" do 26 | send(verb, path, params: _params, 27 | headers: { 28 | 'ACCEPT' => BaseAPI::SCHEMA_REGISTRY_CONTENT_TYPE, 29 | 'CONTENT_TYPE' => BaseAPI::SCHEMA_REGISTRY_CONTENT_TYPE 30 | }) 31 | expect(response).to be_ok 32 | expect(response.body).to be_json_eql(expected) 33 | end 34 | 35 | it "accepts json requests" do 36 | send(verb, path, params: _params, 37 | headers: { 38 | 'ACCEPT' => 'application/json', 39 | 'CONTENT_TYPE' => 'application/json' 40 | }) 41 | expect(response).to be_ok 42 | expect(response.body).to be_json_eql(expected) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # The test environment is used exclusively to run your application's 7 | # test suite. You never need to work with it otherwise. Remember that 8 | # your test database is "scratch space" for the test suite and is wiped 9 | # and recreated between test runs. Don't rely on the data there! 10 | config.cache_classes = false 11 | config.action_view.cache_template_loading = true 12 | 13 | # Do not eager load code on boot. This avoids loading your whole application 14 | # just for the purpose of running a single test. If you are using a tool that 15 | # preloads Rails for running tests, you may have to set it to true. 16 | config.eager_load = false 17 | 18 | # Configure public file server for tests with Cache-Control for performance. 19 | config.public_file_server.enabled = true 20 | config.public_file_server.headers = { 21 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 22 | } 23 | 24 | # Show full error reports and disable caching. 25 | config.consider_all_requests_local = true 26 | config.action_controller.perform_caching = false 27 | config.cache_store = :null_store 28 | 29 | # Raise exceptions instead of rendering exception templates. 30 | config.action_dispatch.show_exceptions = :none 31 | 32 | # Disable request forgery protection in test environment. 33 | config.action_controller.allow_forgery_protection = false 34 | 35 | # Print deprecation notices to the stderr. 36 | config.active_support.deprecation = :stderr 37 | 38 | # Raises error for missing translations. 39 | # config.action_view.raise_on_missing_translations = true 40 | end 41 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/api/config_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ConfigAPI < Grape::API 4 | include BaseAPI 5 | 6 | rescue_from Compatibility::InvalidCompatibilityLevelError do 7 | invalid_compatibility_level! 8 | end 9 | 10 | rescue_from :all do 11 | server_error! 12 | end 13 | 14 | helpers do 15 | def find_subject!(name) 16 | Subject.eager_load(:config).find_by!(name: name) 17 | rescue ActiveRecord::RecordNotFound 18 | subject_not_found! 19 | end 20 | end 21 | 22 | desc 'Get top-level config' 23 | get '/' do 24 | { compatibility: Config.global.compatibility } 25 | end 26 | 27 | desc 'Update compatibility requirements globally' 28 | params { requires :compatibility, type: String } 29 | put '/' do 30 | read_only_mode! if Rails.configuration.x.read_only_mode 31 | 32 | config = Config.global 33 | config.update_compatibility!(params[:compatibility]) 34 | { compatibility: config.compatibility } 35 | end 36 | 37 | desc 'Get compatibility level for a subject' 38 | params do 39 | requires :subject, type: String, desc: 'Subject name' 40 | end 41 | get '/:subject', requirements: { subject: Subject::NAME_REGEXP } do 42 | subject = find_subject!(params[:subject]) 43 | { compatibility: subject.config.try(:compatibility) } 44 | end 45 | 46 | desc 'Update compatibility requirements for a subject' 47 | params do 48 | requires :subject, type: String, desc: 'Subject name' 49 | requires :compatibility, type: String 50 | end 51 | put '/:subject', requirements: { subject: Subject::NAME_REGEXP } do 52 | read_only_mode! if Rails.configuration.x.read_only_mode 53 | 54 | subject = find_subject!(params[:subject]) 55 | subject.create_config! unless subject.config 56 | subject.config.update_compatibility!(params[:compatibility]) 57 | { compatibility: subject.config.compatibility } 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Do not dump the schema if the environment has DO_NOT_DUMP_SCHEMA set to any value. 7 | config.active_record.dump_schema_after_migration = ENV.fetch('DUMP_SCHEMA_AFTER_MIGRATION', 'true') == 'true' 8 | 9 | # In the development environment your application's code is reloaded on 10 | # every request. This slows down response time but is perfect for development 11 | # since you don't have to restart the web server when you make code changes. 12 | config.cache_classes = false 13 | 14 | # Do not eager load code on boot. 15 | config.eager_load = false 16 | 17 | # Show full error reports. 18 | config.consider_all_requests_local = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Print deprecation notices to the Rails logger. 37 | config.active_support.deprecation = :log 38 | 39 | # Raise an error on page load if there are pending migrations. 40 | config.active_record.migration_error = :page_load 41 | 42 | # Highlight code that triggered database queries in logs. 43 | config.active_record.verbose_query_logs = true 44 | 45 | # Raises error for missing translations. 46 | # config.action_view.raise_on_missing_translations = true 47 | end 48 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails' 6 | require 'active_model/railtie' 7 | require 'active_record/railtie' 8 | require 'action_controller/railtie' 9 | require 'action_view/railtie' 10 | 11 | require 'active_support/core_ext/integer/time' 12 | 13 | # Require the gems listed in Gemfile, including any gems 14 | # you've limited to :test, :development, or :production. 15 | Bundler.require(*Rails.groups) 16 | 17 | module AvroSchemaRegistry 18 | class Application < Rails::Application 19 | config.load_defaults 7.2 20 | 21 | # Settings in config/environments/* take precedence over those specified here. 22 | # Application configuration should go into files in config/initializers 23 | # -- all .rb files in that directory are automatically loaded. 24 | 25 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 26 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 27 | # config.time_zone = 'Central Time (US & Canada)' 28 | 29 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 30 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 31 | # config.i18n.default_locale = :de 32 | 33 | config.x.disable_password = ENV['DISABLE_PASSWORD'] == 'true' 34 | config.x.app_password = ENV['SCHEMA_REGISTRY_PASSWORD'] || 'avro' 35 | 36 | config.x.allow_response_caching = ENV['ALLOW_RESPONSE_CACHING'] == 'true' 37 | config.x.cache_max_age = (ENV['CACHE_MAX_AGE'] || 30.days).to_i 38 | 39 | config.x.fingerprint_version = (ENV['FINGERPRINT_VERSION'] || '2').downcase 40 | config.x.disable_schema_registration = ENV['DISABLE_SCHEMA_REGISTRATION'] == 'true' 41 | 42 | config.x.read_only_mode = ENV['READ_ONLY_MODE'] == 'true' 43 | 44 | config.x.default_compatibility = ENV.fetch('DEFAULT_COMPATIBILITY', 'NONE') 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/models/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # == Schema Information 4 | # 5 | # Table name: schemas 6 | # 7 | # id :integer not null, primary key 8 | # fingerprint :string not null 9 | # json :text not null 10 | # created_at :datetime not null 11 | # updated_at :datetime not null 12 | # fingerprint2 :string 13 | # 14 | 15 | class Schema < ApplicationRecord 16 | include ImmutableModel 17 | 18 | before_save :generate_fingerprints 19 | 20 | has_many :versions, class_name: 'SchemaVersion', inverse_of: :schema # rubocop:disable Rails/HasManyOrHasOneDependent 21 | has_many :subjects, through: :versions 22 | 23 | scope :with_fingerprints, ->(fingerprint, fingerprint2 = nil) do 24 | fingerprint2 ||= fingerprint 25 | case Rails.configuration.x.fingerprint_version 26 | when '1' 27 | where('schemas.fingerprint = ?', fingerprint) 28 | when '2' 29 | where('schemas.fingerprint2 = ?', fingerprint2) 30 | else 31 | where('schemas.fingerprint = ? OR schemas.fingerprint2 = ?', fingerprint, fingerprint2) 32 | end 33 | end 34 | 35 | scope :with_fingerprint, ->(fingerprint) do 36 | with_fingerprints(fingerprint) 37 | end 38 | 39 | scope :with_json, ->(json) do 40 | with_fingerprints(Schemas::FingerprintGenerator.include_v1? ? Schemas::FingerprintGenerator.generate_v1(json) : nil, 41 | Schemas::FingerprintGenerator.include_v2? ? Schemas::FingerprintGenerator.generate_v2(json) : nil) 42 | end 43 | 44 | def self.existing_schema(json) 45 | if Schemas::FingerprintGenerator.include_v2? 46 | schema = find_by(fingerprint2: Schemas::FingerprintGenerator.generate_v2(json)) 47 | end 48 | if Schemas::FingerprintGenerator.include_v1? && schema.nil? 49 | schema = find_by(fingerprint: Schemas::FingerprintGenerator.generate_v1(json)) 50 | end 51 | 52 | schema 53 | end 54 | 55 | private 56 | 57 | def generate_fingerprints 58 | self.fingerprint = Schemas::FingerprintGenerator.generate_v1(json) 59 | 60 | self.fingerprint2 = Schemas::FingerprintGenerator.generate_v2(json) if Schemas::FingerprintGenerator.include_v2? 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2017_03_10_200653) do 14 | 15 | # These are extensions that must be enabled in order to support this database 16 | enable_extension "plpgsql" 17 | 18 | create_table "configs", force: :cascade do |t| 19 | t.string "compatibility" 20 | t.datetime "created_at", null: false 21 | t.datetime "updated_at", null: false 22 | t.bigint "subject_id" 23 | t.index ["subject_id"], name: "index_configs_on_subject_id", unique: true 24 | end 25 | 26 | create_table "schema_versions", force: :cascade do |t| 27 | t.integer "version", default: 1 28 | t.bigint "subject_id", null: false 29 | t.bigint "schema_id", null: false 30 | t.index ["subject_id", "version"], name: "index_schema_versions_on_subject_id_and_version", unique: true 31 | end 32 | 33 | create_table "schemas", force: :cascade do |t| 34 | t.string "fingerprint", null: false 35 | t.text "json", null: false 36 | t.datetime "created_at", null: false 37 | t.datetime "updated_at", null: false 38 | t.string "fingerprint2" 39 | t.index ["fingerprint"], name: "index_schemas_on_fingerprint" 40 | t.index ["fingerprint2"], name: "index_schemas_on_fingerprint2", unique: true 41 | end 42 | 43 | create_table "subjects", force: :cascade do |t| 44 | t.text "name", null: false 45 | t.datetime "created_at", null: false 46 | t.datetime "updated_at", null: false 47 | t.index ["name"], name: "index_subjects_on_name", unique: true 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | avro-schema-registry 5 | 34 | 35 | 36 |

avro-schema-registry

37 |

38 | 39 | A service for storing and retrieving versions of Avro schemas 40 | 41 |

42 | 43 |

Description

44 |

45 | This service implements the API of the Confluent Schema Registry. 46 |
47 | Imitation is the sincerest form of flattery, right? 48 |

49 | 50 |

51 | This service is hosted by Salsify 52 | for anyone to use. 53 |

54 | 55 |

Host your own copy of this service on Heroku:

56 |

57 | 58 | Deploy 59 | 60 |

61 | 62 |

Details on the implementation can be found here.

63 | 64 |

Background on how we use this service is described on our blog.

65 | 66 |

Security

67 |

This service is public. Uploaded schemas are visible to anyone.

68 | 69 |

The password for the service is 'avro'. The username for requests is not 70 | verified but you should choose something to identify yourself.

71 | 72 |

Data stored by this service may be cleared at any time.

73 | 74 | 75 | -------------------------------------------------------------------------------- /app/models/schema_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SchemaRegistry 4 | InvalidAvroSchemaError = Class.new(StandardError) 5 | IncompatibleAvroSchemaError = Class.new(StandardError) 6 | 7 | extend self 8 | 9 | def compatible?(new_json, version:, compatibility: nil) 10 | compatibility ||= version.subject.config.try(:compatibility) || Compatibility.global 11 | 12 | if Compatibility::Constants::TRANSITIVE_VALUES.include?(compatibility) 13 | check_all_versions(compatibility, new_json, version.subject) 14 | else 15 | check_single_version(compatibility, version.schema.json, new_json) 16 | end 17 | end 18 | 19 | def compatible!(new_json, version:, compatibility: nil) 20 | raise IncompatibleAvroSchemaError unless compatible?(new_json, version: version, compatibility: compatibility) 21 | end 22 | 23 | private 24 | 25 | # If a version/fork of avro that defines Avro::SchemaCompability is 26 | # present, use the full compatibility check, otherwise fall back to 27 | # match_schemas. 28 | def check(readers_schema, writers_schema) 29 | if defined?(Avro::SchemaCompatibility) 30 | Avro::SchemaCompatibility.can_read?(writers_schema, readers_schema) 31 | else 32 | Avro::IO::DatumReader.match_schemas(writers_schema, readers_schema) 33 | end 34 | end 35 | 36 | def check_single_version(compatibility, old_json, new_json) 37 | old_schema = Schemas::Parse.call(old_json) 38 | new_schema = Schemas::Parse.call(new_json) 39 | 40 | case compatibility 41 | when Compatibility::Constants::NONE 42 | true 43 | when Compatibility::Constants::BACKWARD 44 | check(new_schema, old_schema) 45 | when Compatibility::Constants::FORWARD 46 | check(old_schema, new_schema) 47 | when Compatibility::Constants::FULL, Compatibility::Constants::BOTH 48 | check(old_schema, new_schema) && check(new_schema, old_schema) 49 | end 50 | end 51 | 52 | def check_all_versions(compatibility, new_json, subject) 53 | new_schema = Schemas::Parse.call(new_json) 54 | json_schemas = subject.versions.order(version: :desc).joins(:schema).pluck('version', 'schemas.json').map(&:last) 55 | 56 | case compatibility 57 | when Compatibility::Constants::BACKWARD_TRANSITIVE 58 | json_schemas.all? { |json| check(new_schema, Schemas::Parse.call(json)) } 59 | when Compatibility::Constants::FORWARD_TRANSITIVE 60 | json_schemas.all? { |json| check(Schemas::Parse.call(json), new_schema) } 61 | when Compatibility::Constants::FULL_TRANSITIVE 62 | json_schemas.all? do |json| 63 | old_schema = Schemas::Parse.call(json) 64 | check(old_schema, new_schema) && check(new_schema, old_schema) 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # avro-schema-registry 2 | 3 | ## v0.13.4 4 | - Allow hyphens in subject names. 5 | 6 | ## v0.13.3 7 | - Upgrade to avro-patches 0.3.4 8 | 9 | ## v0.13.2 10 | - Missing database no longer causes `docker_start` script to fail in auto-migrate mode. 11 | 12 | ## v0.13.1 13 | - Missing database no longer causes `docker_start` script to fail in auto-migrate mode. 14 | 15 | ## v0.13.0 16 | - Add auto-migrate and waiting for Postgres to the Docker container. 17 | 18 | ## v0.12.1 19 | - Upgrade to Puma 3.11.3. 20 | 21 | ## v0.12.0 22 | - Upgrade to Ruby 2.4.2. 23 | - Upgrade to Rails 5.1. 24 | - Allow default compatibility level to be set via environment variable and 25 | change the default for non-production environments. 26 | - Include Dockerfile. 27 | 28 | ## v0.11.0 29 | - Change the default fingerprint version to '2'. Set `FINGERPRINT_VERSION=1` 30 | before upgrading if you have not migrated to fingerprint version 2. 31 | 32 | ## v0.10.0 33 | - Use `avro-patches` instead of `avro-salsify-fork`. 34 | 35 | ## v0.9.1 36 | - Support dual deploys. 37 | 38 | ## v0.9.0 39 | - Add read-only mode for the application. 40 | 41 | ## v0.8.1 42 | - Reverse the definition of BACKWARD and FORWARD compatibility levels. 43 | Previous releases had these backwards. 44 | Note: The compatibility level is NOT changed for existing configs in the 45 | database. Current compatibility levels should be reviewed to ensure that the 46 | expectation is consistent with the description here: 47 | http://docs.confluent.io/3.2.0/avro.html#schema-evolution. 48 | 49 | ## v0.8.0 50 | - Allow the compatibility level to use while registering a schema to be specified, 51 | and a compatibility level to set for the subject after registration. 52 | 53 | ## v0.7.0 54 | - Allow the compatibility level to be specified in the Compatibility API. 55 | 56 | ## v0.6.2 57 | - Update `cache_all_requests` task to use the resolution fingerprint. 58 | 59 | ## v0.6.1 60 | - Only define rake task to cache requests in the development environment. 61 | 62 | ## v0.6.0 63 | - Introduce fingerprint2 based on `avro-resolution_canonical_form`. 64 | This is a compatibility breaking change and requires a sequence of upgrade steps. 65 | - Fix fingerprint endpoint when using an integer fingerprint. 66 | 67 | ## v0.5.0 68 | - Fix Config API for subjects. 69 | - Add endpoint to get schema id by fingerprint. 70 | - Upgrade to Rails 5.0. 71 | - Add rake task to issue cacheable requests for all schemas. 72 | 73 | ## v0.4.0 74 | - Use `avro-salsify-fork` v1.9.0.3. 75 | - Implement full schema compatibility check and transitive options from 76 | Confluent Schema Registry API v3.1.0. 77 | 78 | ## v0.3.0 79 | - Use `heroku_rails_deploy` gem. 80 | 81 | ## v0.2.0 82 | - Update to Rails 4.2.7. 83 | - Use `avro-salsify-fork`. 84 | - Add `bin/deploy` script. 85 | 86 | ## v0.1.0 87 | - Initial release 88 | -------------------------------------------------------------------------------- /public/success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | avro-schema-registry Success! 5 | 34 | 52 | 53 | 54 |

Congratulations!

55 |

56 | 57 | You've successfully deployed the avro-schema-registry! 58 | 59 |

60 | 61 |

Next Steps

62 |
    63 |
  1. Check the Heroku config for your app to find the value set for the 64 | SCHEMA_REGISTRY_PASSWORD. You'll need this value to make requests.
  2. 65 | 66 |
  3. Use a client to make some requests: 67 |
      68 |
    • avro_turf
    • 69 |
    • schema_registry
    • 70 |
    • curl: 71 |

      72 | 73 |

      curl -X POST --data-binary @request.json \
      74 |
        -H "Content-Type: application/vnd.schemaregistry.v1+json" \
      75 |
        https://username:SCHEMA_REGISTRY_PASSWORD@\
      76 |
          avro-schema-registry.example.com/subjects/NAME/versions
      77 |
      78 |

      79 |
    • 80 |
    81 |
  4. 82 | 83 |
  5. Learn about the API in the Confluent Schema Registry 84 | documentation. 85 |
  6. 86 | 87 |
  7. See the implementation in the avro-schema-registry repo.
  8. 88 | 89 |
  9. Learn more about why we built it on the Salsify blog.
  10. 90 |
91 | 92 | 93 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Attempt to read encrypted secrets from `config/secrets.yml.enc`. 20 | # Requires an encryption key in `ENV["RAILS_MASTER_KEY"]` or 21 | # `config/secrets.yml.key`. 22 | # config.read_encrypted_secrets = true 23 | 24 | # Disable serving static files from the `/public` folder by default since 25 | # Apache or NGINX already handles this. 26 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 27 | 28 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 29 | # config.action_controller.asset_host = 'http://assets.example.com' 30 | 31 | # Specifies the header that your server uses for sending files. 32 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 33 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 34 | 35 | # Mount Action Cable outside main process or domain 36 | # config.action_cable.mount_path = nil 37 | # config.action_cable.url = 'wss://example.com/cable' 38 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 39 | 40 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 41 | config.force_ssl = ActiveRecord::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', true)) 42 | 43 | # Use the lowest log level to ensure availability of diagnostic information 44 | # when problems arise. 45 | config.log_level = ENV.fetch('LOG_LEVEL', :info).to_sym 46 | 47 | # Prepend all log lines with the following tags. 48 | # config.log_tags = [:request_id] 49 | 50 | # Use a different cache store in production. 51 | # config.cache_store = :mem_cache_store 52 | 53 | # Use a real queuing backend for Active Job (and separate queues per environment) 54 | # config.active_job.queue_adapter = :resque 55 | # config.active_job.queue_name_prefix = "avro_schema_registry_#{Rails.env}" 56 | 57 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 58 | # the I18n.default_locale when a translation cannot be found). 59 | config.i18n.fallbacks = true 60 | 61 | # Send deprecation notices to registered listeners. 62 | config.active_support.deprecation = :notify 63 | 64 | # Use default logging formatter so that PID and timestamp are not suppressed. 65 | config.log_formatter = ::Logger::Formatter.new 66 | 67 | # Use a different logger for distributed setups. 68 | # require 'syslog/logger' 69 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 70 | 71 | if ENV['RAILS_LOG_TO_STDOUT'].present? 72 | logger = ActiveSupport::Logger.new($stdout) 73 | logger.formatter = config.log_formatter 74 | config.logger = ActiveSupport::TaggedLogging.new(logger) 75 | end 76 | 77 | # Do not dump schema after migrations. 78 | config.active_record.dump_schema_after_migration = false 79 | 80 | config.x.app_password = ENV.fetch('SCHEMA_REGISTRY_PASSWORD') unless config.x.disable_password 81 | 82 | # This default differs from the Confluent default of BACKWARD 83 | config.x.default_compatibility = ENV.fetch('DEFAULT_COMPATIBILITY', 'FULL_TRANSITIVE') 84 | end 85 | -------------------------------------------------------------------------------- /app/models/schemas/register_new_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Schemas 4 | # This class is called to register a new version of a schema. 5 | # If the schema already exists, then the existing model object is returned. 6 | # Subjects and SchemaVersions are created as necessary. 7 | # Race conditions are protected against by relying on unique indexes on the 8 | # created models. 9 | # If a unique index error is raised, then the operation is retried once. 10 | class RegisterNewVersion 11 | attr_reader :subject_name, :json 12 | attr_accessor :schema 13 | 14 | private_attr_accessor :retried 15 | private_attr_reader :options 16 | 17 | def self.call(...) 18 | new(...).call 19 | end 20 | 21 | def initialize(subject_name, json, **options) 22 | @subject_name = subject_name 23 | @json = json 24 | @options = options 25 | end 26 | 27 | # Retry once to make it easier to handle race conditions on the client, 28 | # i.e. the client should not need to retry. 29 | def call 30 | register_new_version 31 | schema 32 | rescue ActiveRecord::RecordNotUnique 33 | if retried 34 | raise 35 | else 36 | self.retried = true 37 | retry 38 | end 39 | end 40 | 41 | private 42 | 43 | def register_new_version 44 | self.schema = Schema.existing_schema(json) 45 | 46 | if schema 47 | create_new_version 48 | else 49 | create_new_schema 50 | end 51 | end 52 | 53 | def create_new_version 54 | create_version_with_optional_new_subject unless version_exists_for_subject_schema?(schema.id) 55 | end 56 | 57 | def create_new_schema 58 | create_version_with_optional_new_subject do 59 | self.schema = Schema.create!(json: json) 60 | end 61 | end 62 | 63 | def create_version_with_optional_new_subject 64 | latest_version = latest_version_for_subject 65 | 66 | if latest_version.nil? 67 | # Create new subject and version 68 | Subject.transaction do 69 | yield if block_given? 70 | subject = new_subject!(schema.id) 71 | after_compatibility!(subject) 72 | end 73 | else 74 | # Create new schema version for subject 75 | SchemaVersion.transaction do 76 | SchemaRegistry.compatible!(json, 77 | version: latest_version, 78 | compatibility: options[:with_compatibility]) 79 | yield if block_given? 80 | schema_version = new_schema_version_for_subject!(schema.id, latest_version) 81 | after_compatibility!(schema_version.subject) 82 | end 83 | end 84 | end 85 | 86 | def new_schema_version_for_subject!(schema_id, previous_version) 87 | SchemaVersion.create!(schema_id: schema_id, 88 | subject_id: previous_version.subject_id, 89 | version: previous_version.version + 1) 90 | 91 | end 92 | 93 | def new_subject!(schema_id) 94 | subject = Subject.create!(name: subject_name) 95 | subject.versions.create!(schema_id: schema_id) 96 | subject 97 | end 98 | 99 | def latest_version_for_subject 100 | SchemaVersion.eager_load(:schema, subject: [:config]) 101 | .latest_for_subject_name(subject_name).first 102 | end 103 | 104 | def version_exists_for_subject_schema?(schema_id) 105 | SchemaVersion.for_schema(schema_id) 106 | .for_subject_name(subject_name).first.present? 107 | 108 | end 109 | 110 | def after_compatibility!(subject) 111 | compatibility = options[:after_compatibility] 112 | if compatibility 113 | subject.create_config! unless subject.config 114 | subject.config.update_compatibility!(compatibility) 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /spec/models/schema_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Schema do 4 | before do 5 | allow(Rails.configuration.x).to receive(:fingerprint_version).and_return(fingerprint_version) 6 | end 7 | 8 | describe '#save' do 9 | let(:schema) { build(:schema) } 10 | 11 | before do 12 | schema.save! 13 | end 14 | 15 | context "when fingerprint_version is '1'" do 16 | let(:fingerprint_version) { '1' } 17 | 18 | it "sets fingerprint" do 19 | expect(schema.fingerprint).to be_present 20 | end 21 | 22 | it "does not set fingerprint2" do 23 | expect(schema.fingerprint2).to be_nil 24 | end 25 | end 26 | 27 | shared_examples_for "it sets both fingerprints" do 28 | it "sets fingerprint" do 29 | expect(schema.fingerprint).to be_present 30 | end 31 | 32 | it "sets fingerprint2" do 33 | expect(schema.fingerprint2).to be_present 34 | end 35 | end 36 | 37 | context "when fingerprint_version is '2'" do 38 | let(:fingerprint_version) { '2' } 39 | 40 | it_behaves_like "it sets both fingerprints" 41 | end 42 | 43 | context "when fingerprint_version is 'all'" do 44 | let(:fingerprint_version) { 'all' } 45 | 46 | it_behaves_like "it sets both fingerprints" 47 | end 48 | end 49 | 50 | describe ".existing_schema" do 51 | let(:schema1) { create(:schema) } 52 | let(:schema2) { create(:schema) } 53 | let(:json) { build(:schema).json } 54 | let(:fingerprint_v1) { Schemas::FingerprintGenerator.generate_v1(json) } 55 | let(:fingerprint_v2) { Schemas::FingerprintGenerator.generate_v2(json) } 56 | 57 | # `Schema` is an immutable model, so we need raw SQL 58 | def update_schema_fingerprints(schema:, fingerprint:, fingerprint2:) 59 | params = [ 60 | 'UPDATE schemas SET fingerprint = ?, fingerprint2 = ? WHERE id = ?', 61 | fingerprint, 62 | fingerprint2, 63 | schema.id 64 | ] 65 | params = ActiveRecord::Base.sanitize_sql(params) 66 | ActiveRecord::Base.connection.execute(params) 67 | end 68 | 69 | before do 70 | update_schema_fingerprints( 71 | schema: schema1, 72 | fingerprint: fingerprint_v1, 73 | fingerprint2: Schemas::FingerprintGenerator.generate_v2(schema1.json) 74 | ) 75 | 76 | update_schema_fingerprints( 77 | schema: schema2, 78 | fingerprint: Schemas::FingerprintGenerator.generate_v1(schema2.json), 79 | fingerprint2: fingerprint_v2 80 | ) 81 | end 82 | 83 | context "when fingerprint_version is '1'" do 84 | let(:fingerprint_version) { '1' } 85 | 86 | it "finds the existing schema by fingerprint v1" do 87 | expect(Schema.existing_schema(json)).to eq(schema1) 88 | end 89 | 90 | context "when there is no schema matching fingerprint v1" do 91 | before do 92 | ActiveRecord::Base.connection.execute("DELETE FROM schemas where id = #{schema1.id}") 93 | end 94 | 95 | it "returns nil" do 96 | expect(Schema.existing_schema(json)).to be_nil 97 | end 98 | end 99 | end 100 | 101 | context "when fingerprint_version is '2'" do 102 | let(:fingerprint_version) { '2' } 103 | 104 | it "finds the existing schema by fingerprint v2" do 105 | expect(Schema.existing_schema(json)).to eq(schema2) 106 | end 107 | 108 | context "when there is no schema matching fingerprint v2" do 109 | before do 110 | ActiveRecord::Base.connection.execute("DELETE FROM schemas where id = #{schema2.id}") 111 | end 112 | 113 | it "returns nil" do 114 | expect(Schema.existing_schema(json)).to be_nil 115 | end 116 | end 117 | end 118 | 119 | context "when fingerprint_version is 'all'" do 120 | let(:fingerprint_version) { 'all' } 121 | 122 | it "finds the existing schema by fingerprint v2" do 123 | expect(Schema.existing_schema(json)).to eq(schema2) 124 | end 125 | 126 | context "when there is no schema matching fingerprint v2" do 127 | before do 128 | ActiveRecord::Base.connection.execute("DELETE FROM schemas where id = #{schema2.id}") 129 | end 130 | 131 | it "finds the existing schema by fingerprint v1" do 132 | expect(Schema.existing_schema(json)).to eq(schema1) 133 | end 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /app/api/subject_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SubjectAPI < Grape::API 4 | include BaseAPI 5 | 6 | INTEGER_FINGERPRINT_REGEXP = /^[0-9]+$/ 7 | 8 | rescue_from ActiveRecord::RecordNotFound do 9 | subject_not_found! 10 | end 11 | 12 | rescue_from SchemaRegistry::InvalidAvroSchemaError do 13 | invalid_avro_schema! 14 | end 15 | 16 | rescue_from SchemaRegistry::IncompatibleAvroSchemaError do 17 | incompatible_avro_schema! 18 | end 19 | 20 | rescue_from :all do 21 | server_error! 22 | end 23 | 24 | helpers ::Helpers::SchemaVersionHelper 25 | helpers ::Helpers::CacheHelper 26 | 27 | desc 'Get a list of registered subjects' 28 | get '/' do 29 | Subject.order(:name).pluck(:name) 30 | end 31 | 32 | params { requires :name, type: String, desc: 'Subject name' } 33 | segment ':name', requirements: { name: Subject::NAME_REGEXP } do 34 | desc 'Get a list of versions registered under the specified subject.' 35 | get :versions do 36 | SchemaVersion.for_subject_name(params[:name]) 37 | .order(:version) 38 | .pluck(:version) 39 | .tap do |result| 40 | raise ActiveRecord::RecordNotFound if result.empty? 41 | end 42 | end 43 | 44 | params do 45 | requires :version_id, types: [Integer, String], 46 | desc: 'version of the schema registered under the subject' 47 | end 48 | namespace '/versions/:version_id' do 49 | desc 'Get a specific version of the schema registered under this subject' 50 | get do 51 | with_schema_version(params[:name], params[:version_id]) do |schema_version| 52 | { 53 | id: schema_version.schema_id, 54 | name: schema_version.subject.name, 55 | version: schema_version.version, 56 | schema: schema_version.schema.json 57 | } 58 | end 59 | end 60 | 61 | desc 'Get the Avro schema for the specified version of this subject. Only the unescaped schema is returned.' 62 | get '/schema' do 63 | with_schema_version(params[:name], params[:version_id]) do |schema_version| 64 | JSON.parse(schema_version.schema.json) 65 | end 66 | end 67 | end 68 | 69 | desc 'Get the id of a specific version of the schema registered under a subject' 70 | params do 71 | requires :fingerprint, types: [String, Integer], desc: 'SHA256 fingerprint' 72 | end 73 | get '/fingerprints/:fingerprint' do 74 | fingerprint = if INTEGER_FINGERPRINT_REGEXP.match?(params[:fingerprint]) 75 | params[:fingerprint].to_i.to_s(16) 76 | else 77 | params[:fingerprint] 78 | end 79 | 80 | schema_version = SchemaVersion.select(:schema_id) 81 | .for_subject_name(params[:name]) 82 | .for_schema_fingerprint(fingerprint).first 83 | 84 | if schema_version 85 | cache_response! 86 | { id: schema_version.schema_id } 87 | else 88 | schema_not_found! 89 | end 90 | end 91 | 92 | desc 'Register a new schema under the specified subject' 93 | params do 94 | requires :schema, type: String, desc: 'The Avro schema string' 95 | optional :with_compatibility, type: String, 96 | desc: 'The compatibility level to use while registering the schema', 97 | values: Compatibility::Constants::VALUES 98 | optional :after_compatibility, type: String, 99 | desc: 'The compatibility level to set after registering the schema', 100 | values: Compatibility::Constants::VALUES 101 | end 102 | post '/versions' do 103 | read_only_mode! if Rails.configuration.x.read_only_mode 104 | 105 | error!({ message: 'Schema registration is disabled' }, 503) if Rails.configuration.x.disable_schema_registration 106 | 107 | new_schema_options = declared(params).slice(:with_compatibility, :after_compatibility).symbolize_keys 108 | schema = Schemas::RegisterNewVersion.call(params[:name], params[:schema], **new_schema_options) 109 | status 200 110 | { id: schema.id } 111 | end 112 | 113 | desc 'Check if a schema has been registered under the specified subject' 114 | params do 115 | requires :schema, type: String, desc: 'The Avro schema string' 116 | end 117 | post '/' do 118 | schema_version = SchemaVersion.eager_load(:schema, :subject) 119 | .for_subject_name(params[:name]) 120 | .for_schema_json(params[:schema]).first 121 | if schema_version 122 | status 200 123 | { 124 | subject: schema_version.subject.name, 125 | id: schema_version.schema_id, 126 | version: schema_version.version, 127 | schema: schema_version.schema.json 128 | } 129 | elsif Subject.where(name: params[:name]).exists? 130 | schema_not_found! 131 | else 132 | subject_not_found! 133 | end 134 | end 135 | 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/requests/compatibility_api_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe CompatibilityAPI do 4 | 5 | describe "POST /compatibility/subjects/:subject/versions/:version" do 6 | let(:version) { create(:schema_version) } 7 | let(:subject_name) { version.subject.name } 8 | let(:schema) do 9 | JSON.parse(version.schema.json).tap do |avro| 10 | avro['fields'] << { name: :new, type: :string, default: '' } 11 | end.to_json 12 | end 13 | let(:compatibility) { nil } 14 | 15 | it "tests compatibility of the schema with the version of the subject's schema" do 16 | allow(SchemaRegistry).to receive(:compatible?).with(schema, version: version, compatibility: nil) 17 | .and_return(true) 18 | post("/compatibility/subjects/#{subject_name}/versions/#{version.version}", params: { schema: schema }) 19 | expect(response).to be_ok 20 | expect(response.body).to be_json_eql({ is_compatible: true }.to_json) 21 | end 22 | 23 | context "when compatibility is set for the subject" do 24 | let(:compatibility) { 'FORWARD' } 25 | 26 | before { version.subject.create_config!(compatibility: compatibility) } 27 | 28 | it "tests compatibility of the schema with the version of the subject's schema" do 29 | allow(SchemaRegistry).to receive(:compatible?).with(schema, version: version, compatibility: nil) 30 | .and_return(true) 31 | post("/compatibility/subjects/#{subject_name}/versions/#{version.version}", params: { schema: schema }) 32 | expect(response).to be_ok 33 | expect(response.body).to be_json_eql({ is_compatible: true }.to_json) 34 | end 35 | end 36 | 37 | context "when compatibility is specified in the reqest" do 38 | let(:compatibility) { 'BACKWARD' } 39 | 40 | it "tests compatibility of the schema using the specified compatibility level" do 41 | allow(SchemaRegistry).to receive(:compatible?).with(schema, version: version, compatibility: compatibility) 42 | .and_return(true) 43 | post("/compatibility/subjects/#{subject_name}/versions/#{version.version}", 44 | params: { schema: schema, with_compatibility: compatibility }) 45 | expect(response).to be_ok 46 | expect(response.body).to be_json_eql({ is_compatible: true }.to_json) 47 | end 48 | end 49 | 50 | context "when the version is specified as latest" do 51 | let(:second_version) { create(:schema_version, subject: version.subject, version: 2) } 52 | 53 | it "tests compatibility of the schema with the latest version of the subject's schema" do 54 | allow(SchemaRegistry).to receive(:compatible?).with(schema, version: second_version, compatibility: nil) 55 | .and_return(true) 56 | post("/compatibility/subjects/#{subject_name}/versions/latest", params: { schema: schema }) 57 | expect(response).to be_ok 58 | expect(response.body).to be_json_eql({ is_compatible: true }.to_json) 59 | end 60 | end 61 | 62 | it_behaves_like "a secure endpoint" do 63 | let(:action) do 64 | unauthorized_post("/compatibility/subjects/#{subject_name}/versions/#{version.version}", 65 | params: { schema: schema }) 66 | end 67 | end 68 | 69 | context "when the schema is invalid" do 70 | let(:schema) do 71 | # invalid due to missing record name 72 | { type: :record, fields: [{ name: :i, type: :int }] }.to_json 73 | end 74 | 75 | it "returns an invalid schema error" do 76 | post("/compatibility/subjects/#{subject_name}/versions/latest", params: { schema: schema }) 77 | expect(status).to eq(422) 78 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::INVALID_AVRO_SCHEMA.to_json) 79 | end 80 | end 81 | 82 | context "when the subject is not found" do 83 | it "returns a subject not found error" do 84 | post('/compatibility/subjects/example.not_found/versions/latest', params: { schema: schema }) 85 | expect(response).to be_not_found 86 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::SUBJECT_NOT_FOUND.to_json) 87 | end 88 | end 89 | 90 | context "when the version is not found" do 91 | it "returns a version not found error" do 92 | post("/compatibility/subjects/#{subject_name}/versions/2", params: { schema: schema }) 93 | expect(response).to be_not_found 94 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::VERSION_NOT_FOUND.to_json) 95 | end 96 | end 97 | 98 | context "when the compatibility level is invalid" do 99 | it "returns an invalid compatibility level error" do 100 | post("/compatibility/subjects/#{subject_name}/versions/latest", 101 | params: { schema: schema, with_compatibility: 'SAME' }) 102 | expect(status).to eq(422) 103 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::INVALID_COMPATIBILITY_LEVEL.to_json) 104 | end 105 | end 106 | 107 | context "when the schema is not specified" do 108 | it "returns an invalid compatibility level error" do 109 | post("/compatibility/subjects/#{subject_name}/versions/latest", 110 | params: { with_compatibility: 'BOTH' }) 111 | expect(status).to eq(422) 112 | expect(response.body).to be_json_eql({ message: 'schema is missing' }.to_json) 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/requests/config_api_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe ConfigAPI do 4 | let(:expected) do 5 | { compatibility: compatibility }.to_json 6 | end 7 | 8 | describe "GET /config" do 9 | let(:compatibility) { Compatibility.global } 10 | 11 | it "returns the global compatibility level" do 12 | get('/config') 13 | expect(response).to be_ok 14 | expect(response.body).to be_json_eql(expected) 15 | end 16 | 17 | it_behaves_like "a secure endpoint" do 18 | let(:action) { unauthorized_get('/config') } 19 | end 20 | end 21 | 22 | describe "PUT /config" do 23 | let(:compatibility) { 'FORWARD' } 24 | 25 | it "changes the global compatibility level" do 26 | put('/config', params: { compatibility: compatibility }) 27 | expect(response).to be_ok 28 | expect(response.body).to be_json_eql({ compatibility: compatibility }.to_json) 29 | expect(Config.global.compatibility).to eq(compatibility) 30 | end 31 | 32 | context "when the app is in read-only mode" do 33 | before do 34 | allow(Rails.configuration.x).to receive(:read_only_mode).and_return(true) 35 | end 36 | 37 | it "returns an error" do 38 | put('/config', params: { compatibility: compatibility }) 39 | expect(response.status).to eq(403) 40 | expect(response.body).to be_json_eql({ message: 'Running in read-only mode' }.to_json) 41 | end 42 | end 43 | 44 | it_behaves_like "a secure endpoint" do 45 | let(:action) { unauthorized_put('/config', params: { compatibility: compatibility }) } 46 | end 47 | 48 | context "when the compatibility value is not uppercase" do 49 | it "changes the global compatibility level" do 50 | put('/config', params: { compatibility: compatibility.downcase }) 51 | expect(response).to be_ok 52 | expect(response.body).to be_json_eql({ compatibility: compatibility }.to_json) 53 | expect(Config.global.compatibility).to eq(compatibility) 54 | end 55 | end 56 | 57 | context "when the compatibility level is invalid" do 58 | let(:compatibility) { 'BACK' } 59 | 60 | it "returns an unprocessable entity error" do 61 | put('/config', params: { compatibility: compatibility }) 62 | expect(status).to eq(422) 63 | expect(response.body) 64 | .to be_json_eql(SchemaRegistry::Errors::INVALID_COMPATIBILITY_LEVEL.to_json) 65 | end 66 | end 67 | end 68 | 69 | describe "GET /config/:subject" do 70 | let(:schema_subject) { create(:subject) } 71 | let(:compatibility) { Compatibility.global } 72 | 73 | it_behaves_like "a secure endpoint" do 74 | let(:action) { unauthorized_get("/config/#{schema_subject.name}") } 75 | end 76 | 77 | context "when compatibility is set for the subject" do 78 | before { schema_subject.create_config.update_compatibility!(compatibility) } 79 | 80 | it "returns the compatibility level for the subject" do 81 | get("/config/#{schema_subject.name}") 82 | expect(response).to be_ok 83 | expect(response.body).to be_json_eql(expected) 84 | end 85 | end 86 | 87 | context "when compatibility has not been set for the subject" do 88 | let(:compatibility) { nil } 89 | 90 | it "returns null" do 91 | get("/config/#{schema_subject.name}") 92 | expect(response).to be_ok 93 | expect(response.body).to be_json_eql(expected) 94 | end 95 | end 96 | 97 | context "when the subject does not exist" do 98 | let(:name) { 'example.does_not_exist' } 99 | 100 | it "returns a not found error" do 101 | get("/config/#{name}") 102 | expect(response).to be_not_found 103 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::SUBJECT_NOT_FOUND.to_json) 104 | end 105 | end 106 | end 107 | 108 | describe "PUT /config/:subject" do 109 | let(:schema_subject) { create(:subject) } 110 | let(:compatibility) { 'BACKWARD' } 111 | 112 | it "updates the compatibility level on the subject" do 113 | put("/config/#{schema_subject.name}", params: { compatibility: compatibility }) 114 | expect(response).to be_ok 115 | expect(response.body).to be_json_eql(expected) 116 | expect(schema_subject.config.compatibility).to eq(compatibility) 117 | end 118 | 119 | context "when the app is in read-only mode" do 120 | before do 121 | allow(Rails.configuration.x).to receive(:read_only_mode).and_return(true) 122 | end 123 | 124 | it "returns an error" do 125 | put("/config/#{schema_subject.name}", params: { compatibility: compatibility }) 126 | expect(response.status).to eq(403) 127 | expect(response.body).to be_json_eql({ message: 'Running in read-only mode' }.to_json) 128 | end 129 | end 130 | 131 | context "when the subject already has a compatibility level set" do 132 | let(:original_compatibility) { 'FORWARD' } 133 | 134 | before { schema_subject.create_config!(compatibility: original_compatibility) } 135 | 136 | it "updates the compatibility level on the subject" do 137 | put("/config/#{schema_subject.name}", params: { compatibility: compatibility }) 138 | expect(response).to be_ok 139 | expect(response.body).to be_json_eql(expected) 140 | expect(schema_subject.config.reload.compatibility).to eq(compatibility) 141 | end 142 | end 143 | 144 | context "when the compatibility level is not uppercase" do 145 | it "updates the compatibility level on the subject" do 146 | put("/config/#{schema_subject.name}", params: { compatibility: compatibility.downcase }) 147 | expect(response).to be_ok 148 | expect(response.body).to be_json_eql(expected) 149 | expect(schema_subject.config.compatibility).to eq(compatibility) 150 | end 151 | end 152 | 153 | it_behaves_like "a secure endpoint" do 154 | let(:action) do 155 | unauthorized_put("/config/#{schema_subject.name}", params: { compatibility: compatibility }) 156 | end 157 | end 158 | 159 | context "when the subject does not exist" do 160 | let(:name) { 'example.does_not_exist' } 161 | 162 | it "returns a not found error" do 163 | put("/config/#{name}", params: { compatibility: compatibility }) 164 | expect(response).to be_not_found 165 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::SUBJECT_NOT_FOUND.to_json) 166 | end 167 | end 168 | 169 | context "when the compatibility level is invalid" do 170 | let(:compatibility) { 'FOO' } 171 | 172 | it "returns an unprocessable entity error" do 173 | put("/config/#{schema_subject.name}", params: { compatibility: compatibility }) 174 | expect(status).to eq(422) 175 | expect(response.body) 176 | .to be_json_eql(SchemaRegistry::Errors::INVALID_COMPATIBILITY_LEVEL.to_json) 177 | end 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /spec/schema_registry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe SchemaRegistry do 4 | let(:json_hash) do 5 | { 6 | type: :record, 7 | name: :rec, 8 | fields: [ 9 | { name: :field1, type: :string, default: '' }, 10 | { name: :field2, type: :string } 11 | ] 12 | } 13 | end 14 | let(:old_json) { json_hash.to_json } 15 | let(:schema) { create(:schema, json: old_json) } 16 | let(:version) { create(:schema_version, schema: schema) } 17 | let(:new_json) { build(:schema).json } 18 | let(:backward_json) do 19 | # BACKWARD compatible - can read old schema 20 | # It ignores the removed field in the old schema 21 | hash = JSON.parse(old_json) 22 | hash['fields'].pop 23 | hash.to_json 24 | end 25 | let(:forward_json) do 26 | # FORWARD compatible - can be read by old schema 27 | # The old schema ignores the new field. 28 | hash = JSON.parse(old_json) 29 | hash['fields'] << { name: :extra, type: :string } 30 | hash.to_json 31 | end 32 | let(:full_json) do 33 | # FULL compatible 34 | hash = JSON.parse(old_json) 35 | # removed required field with default 36 | hash['fields'].shift 37 | # add required field with default 38 | hash['fields'] << { name: :extra, type: :string, default: '' } 39 | hash.to_json 40 | end 41 | let(:old_schema) { Avro::Schema.parse(old_json) } 42 | let(:new_schema) { Avro::Schema.parse(new_json) } 43 | 44 | before do 45 | create(:config, subject_id: version.subject_id, compatibility: compatibility) if compatibility 46 | end 47 | 48 | describe ".compatible?" do 49 | let(:compatibility) { 'FULL_TRANSITIVE' } 50 | 51 | subject(:check) { described_class.compatible?(new_json, version: version) } 52 | 53 | before do 54 | allow(Avro::SchemaCompatibility).to receive(:can_read?).and_call_original 55 | end 56 | 57 | it "allows compatibility to be specified" do 58 | described_class.compatible?(new_json, version: version, compatibility: 'BACKWARD') 59 | expect(Avro::SchemaCompatibility).to have_received(:can_read?).with(new_schema, old_schema) 60 | end 61 | 62 | context "when compatibility is nil" do 63 | let(:compatibility) { nil } 64 | 65 | it "uses the global Compatibility level" do 66 | allow(Compatibility).to receive(:global).and_call_original 67 | check 68 | expect(Compatibility).to have_received(:global) 69 | end 70 | end 71 | 72 | context "when compatibility is NONE" do 73 | let(:compatibility) { 'NONE' } 74 | 75 | it "does not perform any check" do 76 | expect(check).to eq(true) 77 | expect(Avro::SchemaCompatibility).not_to have_received(:can_read?) 78 | end 79 | end 80 | 81 | context "when compatibility is BACKWARD" do 82 | let(:compatibility) { 'BACKWARD' } 83 | 84 | it "performs a check with the old schema as the readers schema" do 85 | check 86 | expect(Avro::SchemaCompatibility).to have_received(:can_read?).with(new_schema, old_schema) 87 | end 88 | 89 | it "returns false for a forward compatible schema" do 90 | expect(described_class.compatible?(forward_json, version: version)).to eq(false) 91 | end 92 | 93 | it "returns true for a backward compatible schema" do 94 | expect(described_class.compatible?(backward_json, version: version)).to eq(true) 95 | end 96 | end 97 | 98 | context "when compatibility is FORWARD" do 99 | let(:compatibility) { 'FORWARD' } 100 | 101 | it "performs a check with the new schema as the readers schema" do 102 | check 103 | expect(Avro::SchemaCompatibility).to have_received(:can_read?).with(old_schema, new_schema) 104 | end 105 | 106 | it "returns true for a forward compatible schema" do 107 | expect(described_class.compatible?(forward_json, version: version)).to eq(true) 108 | end 109 | 110 | it "returns false for a backward compatible schema" do 111 | expect(described_class.compatible?(backward_json, version: version)).to eq(false) 112 | end 113 | end 114 | 115 | context "when compatibility is BOTH (deprecated)" do 116 | let(:compatibility) { 'BOTH' } 117 | 118 | it "performs a check with each schema as the readers schema" do 119 | check 120 | expect(Avro::SchemaCompatibility).to have_received(:can_read?).with(new_schema, old_schema).once 121 | expect(Avro::SchemaCompatibility).to have_received(:can_read?).with(old_schema, new_schema).once 122 | end 123 | 124 | it "returns false" do 125 | expect(check).to eq(false) 126 | end 127 | end 128 | 129 | context "when compatibility is FULL" do 130 | let(:compatibility) { 'FULL' } 131 | 132 | it "performs a check with each schema as the readers schema" do 133 | check 134 | expect(Avro::SchemaCompatibility).to have_received(:can_read?).with(new_schema, old_schema) 135 | expect(Avro::SchemaCompatibility).to have_received(:can_read?).with(old_schema, new_schema) 136 | end 137 | 138 | it "returns false for a forward compatible schema" do 139 | expect(described_class.compatible?(forward_json, version: version)).to eq(false) 140 | end 141 | 142 | it "returns false for a backward compatible schema" do 143 | expect(described_class.compatible?(backward_json, version: version)).to eq(false) 144 | end 145 | 146 | it "returns true for a fully compatible schema" do 147 | expect(described_class.compatible?(full_json, version: version)).to eq(true) 148 | end 149 | end 150 | 151 | context "transitive checks" do 152 | let(:registry_subject) { version.subject } 153 | let!(:second_version) { create(:schema_version, subject: registry_subject, version: 2) } 154 | let(:second_schema) { Avro::Schema.parse(second_version.schema.json) } 155 | let(:can_read_args) { [] } 156 | 157 | before do 158 | # rspec checks for ordered calls with have_received were not working so 159 | # expectations are checked directly based on captured args 160 | allow(Avro::SchemaCompatibility).to receive(:can_read?) do |*args| 161 | can_read_args << args 162 | true 163 | end 164 | end 165 | 166 | context "when compatibility is BACKWARD_TRANSITIVE" do 167 | let(:compatibility) { 'BACKWARD_TRANSITIVE' } 168 | 169 | it "performs a check with all schemas as the readers schema" do 170 | check 171 | expect(can_read_args.first).to eq([new_schema, second_schema]) 172 | expect(can_read_args.second).to eq([new_schema, old_schema]) 173 | end 174 | end 175 | 176 | context "when compatibility is FORWARD_TRANSITIVE" do 177 | let(:compatibility) { 'FORWARD_TRANSITIVE' } 178 | 179 | it "performs a check with all schemas as the writers schema" do 180 | check 181 | expect(can_read_args.first).to eq([second_schema, new_schema]) 182 | expect(can_read_args.second).to eq([old_schema, new_schema]) 183 | end 184 | end 185 | 186 | context "when compatibility is FULL_TRANSITIVE" do 187 | let(:compatibility) { 'FULL_TRANSITIVE' } 188 | 189 | it "performs a check with all schemas as the writers schema" do 190 | check 191 | expect(can_read_args.first).to eq([new_schema, second_schema]) 192 | expect(can_read_args.second).to eq([second_schema, new_schema]) 193 | expect(can_read_args.third).to eq([new_schema, old_schema]) 194 | expect(can_read_args.fourth).to eq([old_schema, new_schema]) 195 | end 196 | end 197 | end 198 | 199 | context "server error" do 200 | let(:compatibility) { 'BOTH' } 201 | 202 | let(:old_json) do 203 | { 204 | type: 'record', 205 | name: 'event', 206 | fields: [ 207 | { 208 | name: 'attribute', 209 | type: { 210 | type: 'record', 211 | name: 'reference', 212 | fields: [ 213 | { 214 | name: 'id', 215 | type: 'string' 216 | } 217 | ] 218 | } 219 | } 220 | ] 221 | }.to_json 222 | end 223 | 224 | let(:new_json) do 225 | { 226 | type: 'record', 227 | name: 'event', 228 | fields: [ 229 | { 230 | name: 'attribute', 231 | type: [ 232 | 'null', 233 | { 234 | type: 'record', 235 | name: 'reference', 236 | fields: [ 237 | { 238 | name: 'id', 239 | type: 'string' 240 | } 241 | ] 242 | } 243 | ], 244 | default: nil 245 | } 246 | ] 247 | }.to_json 248 | end 249 | 250 | it "returns false" do 251 | expect(check).to eq(false) 252 | end 253 | 254 | end 255 | end 256 | 257 | describe ".compatible!" do 258 | subject(:check) { described_class.compatible!(new_json, version: version) } 259 | 260 | before do 261 | allow(described_class).to receive(:compatible?) 262 | .with(new_json, version: version, compatibility: compatibility).and_return(compatible) 263 | end 264 | 265 | context "when the compatibility level is specified" do 266 | let(:compatibility) { 'FORWARD' } 267 | let(:compatible) { true } 268 | 269 | it "checks compatibility using the specified level" do 270 | expect do 271 | described_class.compatible!(new_json, version: version, compatibility: compatibility) 272 | end.not_to raise_error 273 | end 274 | end 275 | 276 | context "when schemas are compatible" do 277 | let(:compatibility) { nil } 278 | let(:compatible) { true } 279 | 280 | it "does not raise an error" do 281 | expect { check }.not_to raise_error 282 | end 283 | end 284 | 285 | context "when schemas are incompatible" do 286 | let(:compatibility) { nil } 287 | let(:compatible) { false } 288 | 289 | it "raises IncompatibleAvroSchemaError" do 290 | expect { check }.to raise_error(SchemaRegistry::IncompatibleAvroSchemaError) 291 | end 292 | end 293 | end 294 | end 295 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # avro-schema-registry 2 | 3 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 4 | 5 | [![Build Status](https://circleci.com/gh/salsify/avro-schema-registry.svg?style=svg)][circleci] 6 | 7 | [circleci]: https://circleci.com/gh/salsify/avro-schema-registry 8 | 9 | A service for storing and retrieving versions of Avro schemas. 10 | 11 | Schema versions stored by the service are assigned an id. These ids can be 12 | embedded in messages published to Kafka avoiding the need to send the full 13 | schema with each message. 14 | 15 | ## Upgrading to v0.11.0 16 | 17 | v0.11.0 changes the default fingerprint version to 2. Set `FINGERPRINT_VERSION=1` 18 | before upgrading if you have not migrated to fingerprint version 2. 19 | 20 | ## Upgrading to v0.6.0 21 | 22 | There is a compatibility break when upgrading to v0.6.0 due to the way that 23 | fingerprints are generated. Prior to v0.6.0 fingerprints were generated based 24 | on the Parsing Canonical Form for Avro schemas. This does not take into account 25 | attributes such as `default` that are used during schema resolution and for 26 | compatibility checking. The new fingerprint is based on [avro-resolution_canonical_form](https://github.com/salsify/avro-resolution_canonical_form). 27 | 28 | To upgrade: 29 | 30 | 1. Set `FINGERPRINT_VERSION=1` and `DISABLE_SCHEMA_REGISTRATION=true` in the 31 | environment for the application, and restart the application. 32 | 2. Deploy v0.6.0 and run migrations to create and populate the new `fingerprint2` 33 | column. 34 | 3. If NOT using the fingerprint endpoint, move to the final step. 35 | 4. Set `FINGERPRINT_VERSION=all`, unset `DISABLE_SCHEMA_REGISTRATION`, and restart the application. 36 | 5. Update all clients to use the v2 fingerprint. 37 | 6. Set `FINGERPRINT_VERSION=2` and unset `DISABLE_SCHEMA_REGISTRATION` (if still set) and 38 | restart the application. 39 | 40 | At some point in the future the original `fingerprint` column will be removed. 41 | 42 | ## Overview 43 | 44 | This application provides the same API as the Confluent 45 | [Schema Registry](http://docs.confluent.io/3.1.0/schema-registry/docs/api.html). 46 | 47 | The service is implemented as a Rails 7.2 application and stores Avro schemas in 48 | Postgres. The API is implemented using [Grape](https://github.com/ruby-grape/grape). 49 | 50 | ### Why? 51 | 52 | The Confluent Schema Registry has been reimplemented because the original 53 | implementation uses Kafka to store schemas. We view the messages that pass 54 | through Kafka as more ephemeral and want the flexibility to change how we host Kafka. 55 | In the future we may also apply per-subject permissions to the Avro schemas that 56 | are stored by the registry. 57 | 58 | ## Extensions 59 | 60 | In addition to the Confluent Schema Registry API, this application provides some 61 | extensions. 62 | 63 | ### Schema ID by Fingerprint 64 | 65 | There is an endpoint that can be used to determine by 66 | fingerprint if a schema is already registered for a subject. 67 | 68 | This endpoint provides a success response that can be cached indefinitely since 69 | the id for a schema will not change once it is registered for a subject. 70 | 71 | `GET /subjects/(string: subject)/fingerprints/(:fingerprint)` 72 | 73 | Get the id of the schema registered for the subject by fingerprint. The 74 | fingerprint may either be the hex string or the integer value produced by the 75 | [SHA256 fingerprint](http://avro.apache.org/docs/1.8.1/spec.html#Schema+Fingerprints). 76 | 77 | **Parameters:** 78 | - **subject** (_string_) - Name of the subject that the schema is registered under 79 | - **fingerprint** (_string_ or _integer_) - SHA256 fingerprint for the schema 80 | 81 | **Response JSON Object:** 82 | - **id** (_int_) - Globally unique identifier of the schema 83 | 84 | **Status Codes:** 85 | - 404 Not Found - Error Code 40403 - Schema not found 86 | - 500 Internal Server Error - Error code 50001 - Error in the backend datastore 87 | 88 | **Example Request:** 89 | ``` 90 | GET /subjects/test/fingerprints/90479eea876f5d6c8482b5b9e3e865ff1c0931c1bfe0adb44c41d628fd20989c HTTP/1.1 91 | Host: schemaregistry.example.com 92 | Accept: application/vnd.schemaregistry.v1+json, application/vnd.schemaregistry+json, application/json 93 | ``` 94 | 95 | **Example response:** 96 | ``` 97 | HTTP/1.1 200 OK 98 | Content-Type: application/vnd.schemaregistry.v1+json 99 | 100 | {"id":1} 101 | ``` 102 | 103 | ### Test Compatibility with a Specified Level 104 | 105 | The Compatibility API is extended to support a `with_compatibility` parameter 106 | that controls the level used for the compatibility check against the specified 107 | schema version. 108 | 109 | When `with_compatibility` is specified, it overrides any configuration for the 110 | subject and the global configuration. 111 | 112 | **Example Request:** 113 | ``` 114 | POST /subjects/test/versions/latest HTTP/1.1 115 | Host: schemaregistry.example.com 116 | Accept: application/vnd.schemaregistry.v1+json, application/vnd.schemaregistry+json, application/json 117 | 118 | { 119 | "schema": "{ ... }", 120 | "with_compatibility": "BACKWARD" 121 | } 122 | ``` 123 | 124 | ### Register A New Schema With Specified Compatibility Levels 125 | 126 | The Subject API is extended to support a `with_compatibility` parameter that 127 | controls the level used for the compatibility check when registering a new 128 | schema for a subject. 129 | 130 | An `after_compatibility` parameter is also supported to set a new compatibility 131 | level for the subject after a new schema version is registered. This option is 132 | ignored if no new version is created. 133 | 134 | **Example Request:** 135 | ``` 136 | POST /subjects/test/versions HTTP/1.1 137 | Host: schemaregistry.example.com 138 | Accept: application/vnd.schemaregistry.v1+json, application/vnd.schemaregistry+json, application/json 139 | 140 | { 141 | "schema": "{ ... }", 142 | "with_compatibility": "NONE", 143 | "after_compatibility": "BACKwARD" 144 | } 145 | ``` 146 | 147 | ## Setup 148 | 149 | The application is written using Ruby 3.4.3. Start the service using the following 150 | steps: 151 | 152 | ```bash 153 | git clone git@github.com:salsify/avro-schema-registry.git 154 | cd avro-schema-registry 155 | bundle install 156 | bin/rake db:setup 157 | bin/rails s 158 | ``` 159 | 160 | By default the service runs on port 21000. 161 | 162 | ## Deployment 163 | 164 | Salsify hosts a public instance of this application at 165 | [avro-schema-registry.salsify.com](https://avro-schema-registry.salsify.com) that 166 | anyone can experiment with, just please don't rely on it for production! 167 | 168 | There is also a button above to easily deploy your own copy of the application to Heroku. 169 | 170 | ### Docker 171 | 172 | A Dockerfile is provided to run the application within a container. To 173 | build the image, navigate to the root of the repo and run `docker build`: 174 | 175 | ```bash 176 | docker build . -t avro-schema-registry 177 | ``` 178 | 179 | The container is built for the `production` environment, so you'll 180 | need to pass in a few environment flags to run it: 181 | 182 | ```bash 183 | docker run -p 5000:5000 -d \ 184 | -e DATABASE_URL=postgresql://user:pass@host/dbname \ 185 | -e FORCE_SSL=false \ 186 | -e SECRET_KEY_BASE=supersecret \ 187 | -e SCHEMA_REGISTRY_PASSWORD=avro \ 188 | avro-schema-registry 189 | ``` 190 | 191 | If you also want to run PostgreSQL in a container, you can link the two containers: 192 | 193 | ```bash 194 | docker run --name avro-postgres -d \ 195 | -e POSTGRES_PASSWORD=avro \ 196 | -e POSTGRES_USER=avro \ 197 | postgres:9.6 198 | 199 | docker run --name avro-schema-registry --link avro-postgres:postgres -p 5000:5000 -d \ 200 | -e DATABASE_URL=postgresql://avro:avro@postgres/avro \ 201 | -e FORCE_SSL=false \ 202 | -e SECRET_KEY_BASE=supersecret \ 203 | -e SCHEMA_REGISTRY_PASSWORD=avro \ 204 | avro-schema-registry 205 | ``` 206 | 207 | To setup the database the first time you run the app, you can call 208 | `rails db:setup` from within the container: 209 | 210 | ```bash 211 | docker exec avro-schema-registry bundle exec rails db:setup 212 | ``` 213 | 214 | Alternatively, your can pass `-e AUTO_MIGRATE=1` to your `docker run` command to have the 215 | container automatically create and migrate the database schema. 216 | 217 | ## Security 218 | 219 | The service is secured using HTTP Basic authentication and should be used with 220 | SSL. The default password for the service is 'avro' but it can be set via 221 | the environment as `SCHEMA_REGISTRY_PASSWORD`. 222 | 223 | Authentication can be disabled by setting `DISABLE_PASSWORD` to 'true' in the 224 | environment. 225 | 226 | ## Caching 227 | 228 | When the environment variable `ALLOW_RESPONSE_CACHING` is set to `true` then the 229 | service sets headers to allow responses from the following endpoints to be cached: 230 | 231 | - `GET /schemas/(int: id)` 232 | - `GET /subjects/(string: subject)/fingerprints/(:fingerprint)` 233 | 234 | By default, responses for these endpoints are allowed to be cached for 30 days. 235 | This max age can be configured by setting `CACHE_MAX_AGE` to a number of seconds. 236 | 237 | To populate a cache of the responses from these endpoints, the application 238 | contains a rake task that can be run in a development environment to iterate 239 | through all registered schemas and issue the cacheable requests: 240 | 241 | ```bash 242 | rake cache_all_requests registry_url=https://anything:avro@registry.example.com 243 | ``` 244 | 245 | ## Usage 246 | 247 | For more details on the REST API see the Confluent 248 | [documentation](http://docs.confluent.io/3.1.0/schema-registry/docs/api.html). 249 | 250 | A [client](https://github.com/dasch/avro_turf/blob/master/lib/avro_turf/confluent_schema_registry.rb) 251 | (see [AvroTurf](https://github.com/dasch/avro_turf)) can be used to 252 | communicate with the service: 253 | 254 | ```ruby 255 | require 'avro_turf' 256 | require 'avro_turf/confluent_schema_registry' 257 | 258 | url = 'https://anything:avro@registry.example.com' 259 | client = AvroTurf::ConfluentSchemaRegistry.new(url) 260 | 261 | # registering a new schema returns an id 262 | id = client.register('test_subject', avro_json_schema) 263 | # => 99 264 | 265 | # attempting to register the same schema for a subject returns the existing id 266 | id = client.register('test_subject', avro_json_schema) 267 | # => 99 268 | 269 | # the JSON for an Avro schema can be fetched by id 270 | client.fetch(id) 271 | # => avro_json_schema 272 | ``` 273 | 274 | ## Tests 275 | 276 | Tests for the application can be run using: 277 | 278 | ``` 279 | bundle exec rspec 280 | ``` 281 | 282 | ## License 283 | 284 | This code is available as open source under the terms of the 285 | [MIT License](http://opensource.org/licenses/MIT). 286 | 287 | ## Contributing 288 | 289 | Bug reports and pull requests are welcome on GitHub at 290 | https://github.com/salsify/avro-schema-registry. 291 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (7.2.2.2) 5 | actionpack (= 7.2.2.2) 6 | activesupport (= 7.2.2.2) 7 | nio4r (~> 2.0) 8 | websocket-driver (>= 0.6.1) 9 | zeitwerk (~> 2.6) 10 | actionmailbox (7.2.2.2) 11 | actionpack (= 7.2.2.2) 12 | activejob (= 7.2.2.2) 13 | activerecord (= 7.2.2.2) 14 | activestorage (= 7.2.2.2) 15 | activesupport (= 7.2.2.2) 16 | mail (>= 2.8.0) 17 | actionmailer (7.2.2.2) 18 | actionpack (= 7.2.2.2) 19 | actionview (= 7.2.2.2) 20 | activejob (= 7.2.2.2) 21 | activesupport (= 7.2.2.2) 22 | mail (>= 2.8.0) 23 | rails-dom-testing (~> 2.2) 24 | actionpack (7.2.2.2) 25 | actionview (= 7.2.2.2) 26 | activesupport (= 7.2.2.2) 27 | nokogiri (>= 1.8.5) 28 | racc 29 | rack (>= 2.2.4, < 3.2) 30 | rack-session (>= 1.0.1) 31 | rack-test (>= 0.6.3) 32 | rails-dom-testing (~> 2.2) 33 | rails-html-sanitizer (~> 1.6) 34 | useragent (~> 0.16) 35 | actiontext (7.2.2.2) 36 | actionpack (= 7.2.2.2) 37 | activerecord (= 7.2.2.2) 38 | activestorage (= 7.2.2.2) 39 | activesupport (= 7.2.2.2) 40 | globalid (>= 0.6.0) 41 | nokogiri (>= 1.8.5) 42 | actionview (7.2.2.2) 43 | activesupport (= 7.2.2.2) 44 | builder (~> 3.1) 45 | erubi (~> 1.11) 46 | rails-dom-testing (~> 2.2) 47 | rails-html-sanitizer (~> 1.6) 48 | activejob (7.2.2.2) 49 | activesupport (= 7.2.2.2) 50 | globalid (>= 0.3.6) 51 | activemodel (7.2.2.2) 52 | activesupport (= 7.2.2.2) 53 | activerecord (7.2.2.2) 54 | activemodel (= 7.2.2.2) 55 | activesupport (= 7.2.2.2) 56 | timeout (>= 0.4.0) 57 | activestorage (7.2.2.2) 58 | actionpack (= 7.2.2.2) 59 | activejob (= 7.2.2.2) 60 | activerecord (= 7.2.2.2) 61 | activesupport (= 7.2.2.2) 62 | marcel (~> 1.0) 63 | activesupport (7.2.2.2) 64 | base64 65 | benchmark (>= 0.3) 66 | bigdecimal 67 | concurrent-ruby (~> 1.0, >= 1.3.1) 68 | connection_pool (>= 2.2.5) 69 | drb 70 | i18n (>= 1.6, < 2) 71 | logger (>= 1.4.2) 72 | minitest (>= 5.1) 73 | securerandom (>= 0.3) 74 | tzinfo (~> 2.0, >= 2.0.5) 75 | annotate (3.2.0) 76 | activerecord (>= 3.2, < 8.0) 77 | rake (>= 10.4, < 14.0) 78 | ast (2.4.3) 79 | avro (1.10.2) 80 | multi_json (~> 1) 81 | avro-resolution_canonical_form (0.4.0) 82 | avro (~> 1.10.0) 83 | avro_turf (1.14.0) 84 | avro (>= 1.8.0, < 1.12) 85 | excon (~> 0.104) 86 | base64 (0.3.0) 87 | benchmark (0.4.1) 88 | bigdecimal (3.2.2) 89 | bootsnap (1.18.6) 90 | msgpack (~> 1.2) 91 | bugsnag (6.28.0) 92 | concurrent-ruby (~> 1.0) 93 | builder (3.3.0) 94 | childprocess (5.1.0) 95 | logger (~> 1.5) 96 | concurrent-ruby (1.3.5) 97 | connection_pool (2.5.3) 98 | crass (1.0.6) 99 | date (3.4.1) 100 | diff-lcs (1.6.2) 101 | docile (1.4.1) 102 | dotenv (3.1.8) 103 | dotenv-rails (3.1.8) 104 | dotenv (= 3.1.8) 105 | railties (>= 6.1) 106 | drb (2.2.3) 107 | dry-core (1.1.0) 108 | concurrent-ruby (~> 1.0) 109 | logger 110 | zeitwerk (~> 2.6) 111 | dry-inflector (1.2.0) 112 | dry-logic (1.6.0) 113 | bigdecimal 114 | concurrent-ruby (~> 1.0) 115 | dry-core (~> 1.1) 116 | zeitwerk (~> 2.6) 117 | dry-types (1.8.3) 118 | bigdecimal (~> 3.0) 119 | concurrent-ruby (~> 1.0) 120 | dry-core (~> 1.0) 121 | dry-inflector (~> 1.0) 122 | dry-logic (~> 1.4) 123 | zeitwerk (~> 2.6) 124 | erb (5.0.2) 125 | erubi (1.13.1) 126 | excon (0.112.0) 127 | factory_bot (6.5.4) 128 | activesupport (>= 6.1.0) 129 | factory_bot_rails (6.5.0) 130 | factory_bot (~> 6.5) 131 | railties (>= 6.1.0) 132 | globalid (1.2.1) 133 | activesupport (>= 6.1) 134 | grape (2.2.0) 135 | activesupport (>= 6) 136 | dry-types (>= 1.1) 137 | mustermann-grape (~> 1.1.0) 138 | rack (>= 2) 139 | zeitwerk 140 | heroku_rails_deploy (0.4.5) 141 | private_attr 142 | rails 143 | i18n (1.14.7) 144 | concurrent-ruby (~> 1.0) 145 | iniparse (1.5.0) 146 | io-console (0.8.1) 147 | irb (1.15.2) 148 | pp (>= 0.6.0) 149 | rdoc (>= 4.0.0) 150 | reline (>= 0.4.2) 151 | json (2.13.2) 152 | json_spec (1.1.5) 153 | multi_json (~> 1.0) 154 | rspec (>= 2.0, < 4.0) 155 | language_server-protocol (3.17.0.5) 156 | lint_roller (1.1.0) 157 | logger (1.7.0) 158 | loofah (2.24.1) 159 | crass (~> 1.0.2) 160 | nokogiri (>= 1.12.0) 161 | mail (2.8.1) 162 | mini_mime (>= 0.1.1) 163 | net-imap 164 | net-pop 165 | net-smtp 166 | marcel (1.0.4) 167 | mini_mime (1.1.5) 168 | mini_portile2 (2.8.9) 169 | minitest (5.25.5) 170 | msgpack (1.8.0) 171 | multi_json (1.17.0) 172 | mustermann (3.0.4) 173 | ruby2_keywords (~> 0.0.1) 174 | mustermann-grape (1.1.0) 175 | mustermann (>= 1.0.0) 176 | net-imap (0.5.9) 177 | date 178 | net-protocol 179 | net-pop (0.1.2) 180 | net-protocol 181 | net-protocol (0.2.2) 182 | timeout 183 | net-smtp (0.5.1) 184 | net-protocol 185 | newrelic_rpm (9.20.0) 186 | nio4r (2.7.4) 187 | nokogiri (1.18.9) 188 | mini_portile2 (~> 2.8.2) 189 | racc (~> 1.4) 190 | overcommit (0.68.0) 191 | childprocess (>= 0.6.3, < 6) 192 | iniparse (~> 1.4) 193 | rexml (>= 3.3.9) 194 | parallel (1.27.0) 195 | parser (3.3.9.0) 196 | ast (~> 2.4.1) 197 | racc 198 | pg (1.6.1) 199 | pp (0.6.2) 200 | prettyprint 201 | prettyprint (0.2.0) 202 | prism (1.4.0) 203 | private_attr (2.0.0) 204 | psych (5.2.6) 205 | date 206 | stringio 207 | puma (6.6.1) 208 | nio4r (~> 2.0) 209 | racc (1.8.1) 210 | rack (3.1.16) 211 | rack-session (2.1.1) 212 | base64 (>= 0.1.0) 213 | rack (>= 3.0.0) 214 | rack-test (2.2.0) 215 | rack (>= 1.3) 216 | rackup (2.2.1) 217 | rack (>= 3) 218 | rails (7.2.2.2) 219 | actioncable (= 7.2.2.2) 220 | actionmailbox (= 7.2.2.2) 221 | actionmailer (= 7.2.2.2) 222 | actionpack (= 7.2.2.2) 223 | actiontext (= 7.2.2.2) 224 | actionview (= 7.2.2.2) 225 | activejob (= 7.2.2.2) 226 | activemodel (= 7.2.2.2) 227 | activerecord (= 7.2.2.2) 228 | activestorage (= 7.2.2.2) 229 | activesupport (= 7.2.2.2) 230 | bundler (>= 1.15.0) 231 | railties (= 7.2.2.2) 232 | rails-controller-testing (1.0.5) 233 | actionpack (>= 5.0.1.rc1) 234 | actionview (>= 5.0.1.rc1) 235 | activesupport (>= 5.0.1.rc1) 236 | rails-dom-testing (2.3.0) 237 | activesupport (>= 5.0.0) 238 | minitest 239 | nokogiri (>= 1.6) 240 | rails-html-sanitizer (1.6.2) 241 | loofah (~> 2.21) 242 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 243 | railties (7.2.2.2) 244 | actionpack (= 7.2.2.2) 245 | activesupport (= 7.2.2.2) 246 | irb (~> 1.13) 247 | rackup (>= 1.0.0) 248 | rake (>= 12.2) 249 | thor (~> 1.0, >= 1.2.2) 250 | zeitwerk (~> 2.6) 251 | rainbow (3.1.1) 252 | rake (13.3.0) 253 | rdoc (6.14.2) 254 | erb 255 | psych (>= 4.0.0) 256 | regexp_parser (2.11.0) 257 | reline (0.6.2) 258 | io-console (~> 0.5) 259 | rexml (3.4.1) 260 | rspec (3.13.1) 261 | rspec-core (~> 3.13.0) 262 | rspec-expectations (~> 3.13.0) 263 | rspec-mocks (~> 3.13.0) 264 | rspec-core (3.13.5) 265 | rspec-support (~> 3.13.0) 266 | rspec-expectations (3.13.5) 267 | diff-lcs (>= 1.2.0, < 2.0) 268 | rspec-support (~> 3.13.0) 269 | rspec-mocks (3.13.5) 270 | diff-lcs (>= 1.2.0, < 2.0) 271 | rspec-support (~> 3.13.0) 272 | rspec-rails (8.0.1) 273 | actionpack (>= 7.2) 274 | activesupport (>= 7.2) 275 | railties (>= 7.2) 276 | rspec-core (~> 3.13) 277 | rspec-expectations (~> 3.13) 278 | rspec-mocks (~> 3.13) 279 | rspec-support (~> 3.13) 280 | rspec-support (3.13.4) 281 | rspec_junit_formatter (0.6.0) 282 | rspec-core (>= 2, < 4, != 2.12.0) 283 | rubocop (1.74.0) 284 | json (~> 2.3) 285 | language_server-protocol (~> 3.17.0.2) 286 | lint_roller (~> 1.1.0) 287 | parallel (~> 1.10) 288 | parser (>= 3.3.0.2) 289 | rainbow (>= 2.2.2, < 4.0) 290 | regexp_parser (>= 2.9.3, < 3.0) 291 | rubocop-ast (>= 1.38.0, < 2.0) 292 | ruby-progressbar (~> 1.7) 293 | unicode-display_width (>= 2.4.0, < 4.0) 294 | rubocop-ast (1.46.0) 295 | parser (>= 3.3.7.2) 296 | prism (~> 1.4) 297 | rubocop-performance (1.15.2) 298 | rubocop (>= 1.7.0, < 2.0) 299 | rubocop-ast (>= 0.4.0) 300 | rubocop-rails (2.17.4) 301 | activesupport (>= 4.2.0) 302 | rack (>= 1.1) 303 | rubocop (>= 1.33.0, < 2.0) 304 | rubocop-rake (0.6.0) 305 | rubocop (~> 1.0) 306 | rubocop-rspec (2.16.0) 307 | rubocop (~> 1.33) 308 | ruby-progressbar (1.13.0) 309 | ruby2_keywords (0.0.5) 310 | salsify_rubocop (1.74.0) 311 | rubocop (~> 1.74.0) 312 | rubocop-performance (~> 1.15.2) 313 | rubocop-rails (~> 2.17.4) 314 | rubocop-rake (~> 0.6.0) 315 | rubocop-rspec (~> 2.16.0) 316 | securerandom (0.4.1) 317 | simplecov (0.22.0) 318 | docile (~> 1.1) 319 | simplecov-html (~> 0.11) 320 | simplecov_json_formatter (~> 0.1) 321 | simplecov-html (0.13.2) 322 | simplecov_json_formatter (0.1.4) 323 | stringio (3.1.7) 324 | thor (1.4.0) 325 | timeout (0.4.3) 326 | tzinfo (2.0.6) 327 | concurrent-ruby (~> 1.0) 328 | unicode-display_width (3.1.4) 329 | unicode-emoji (~> 4.0, >= 4.0.4) 330 | unicode-emoji (4.0.4) 331 | useragent (0.16.11) 332 | websocket-driver (0.8.0) 333 | base64 334 | websocket-extensions (>= 0.1.0) 335 | websocket-extensions (0.1.5) 336 | zeitwerk (2.7.3) 337 | 338 | PLATFORMS 339 | ruby 340 | 341 | DEPENDENCIES 342 | annotate 343 | avro (~> 1.10.0) 344 | avro-resolution_canonical_form (>= 0.2.0) 345 | avro_turf (>= 0.8.0) 346 | bootsnap 347 | bugsnag 348 | dotenv-rails 349 | factory_bot_rails 350 | grape 351 | heroku_rails_deploy (>= 0.4.1) 352 | json_spec 353 | newrelic_rpm 354 | overcommit 355 | pg 356 | private_attr 357 | puma (>= 5.6.7) 358 | rails (~> 7.2.2) 359 | rails-controller-testing 360 | rspec-rails 361 | rspec_junit_formatter 362 | salsify_rubocop 363 | simplecov 364 | 365 | RUBY VERSION 366 | ruby 3.4.3p32 367 | 368 | BUNDLED WITH 369 | 2.7.1 370 | -------------------------------------------------------------------------------- /spec/requests/subject_api_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe SubjectAPI do 4 | let(:invalid_json) do 5 | # invalid due to missing record name 6 | { type: :record, fields: [{ name: :i, type: :int }] }.to_json 7 | end 8 | 9 | context "content type" do 10 | # content type is configured at the API level so only one endpoint is 11 | # tested here. 12 | include_examples "content type", :get do 13 | let(:path) { '/subjects' } 14 | let(:expected) { [].to_json } 15 | end 16 | 17 | context "with JSON body" do 18 | include_examples "content type", :post do 19 | let!(:version) { create(:schema_version) } 20 | let(:subject_name) { version.subject.name } 21 | let(:expected) do 22 | { 23 | subject: version.subject.name, 24 | id: version.schema_id, 25 | version: version.version, 26 | schema: version.schema.json 27 | }.to_json 28 | end 29 | 30 | let(:path) { "/subjects/#{subject_name}" } 31 | let(:params) { { schema: version.schema.json } } 32 | end 33 | end 34 | end 35 | 36 | describe "GET /subjects" do 37 | let!(:subjects) { create_list(:subject, 3) } 38 | let(:expected) do 39 | subjects.map(&:name).sort.to_json 40 | end 41 | 42 | it "returns a list of subject names" do 43 | get('/subjects') 44 | expect(response).to be_ok 45 | expect(response.body).to be_json_eql(expected) 46 | end 47 | 48 | it_behaves_like "a secure endpoint" do 49 | let(:action) { unauthorized_get('/subjects') } 50 | end 51 | end 52 | 53 | describe "GET /subjects/:name/versions" do 54 | 55 | context "supported subject names" do 56 | # This is only being tested for one representative route under 57 | # /subjects/:name 58 | shared_examples_for "a supported subject name" do |desc, name| 59 | let(:subject) { create(:subject, name: name) } 60 | let!(:schema_version) { create(:version, subject: subject) } 61 | 62 | it "supports #{desc}" do 63 | get("/subjects/#{subject.name}/versions") 64 | expect(response).to be_ok 65 | expect(response.body).to eq([schema_version.version].to_json) 66 | end 67 | end 68 | 69 | shared_examples_for "an unsupported subject name" do |desc, name| 70 | it "does not support #{desc}" do 71 | expect do 72 | get("/subjects/#{name}/versions") 73 | end.to raise_error(ActionController::RoutingError) 74 | end 75 | end 76 | 77 | it_behaves_like "a supported subject name", 78 | 'a name containing a period', 79 | 'com.example.foo' 80 | 81 | it_behaves_like "a supported subject name", 82 | 'a name beginning with an underscore', 83 | '_underscore' 84 | 85 | it_behaves_like "a supported subject name", 86 | 'a name containing a digit', 87 | 'number5' 88 | 89 | it_behaves_like "a supported subject name", 90 | 'a name containing mixed case', 91 | 'UPPER_lower_0123456789' 92 | 93 | it_behaves_like "a supported subject name", 94 | 'a name containing a hyphen', 95 | 'topic-value' 96 | 97 | it_behaves_like "an unsupported subject name", 98 | 'a name beginning with a digit', 99 | '5alive' 100 | 101 | it_behaves_like "an unsupported subject name", 102 | 'a name beginning with a hyphen', 103 | '-foobar' 104 | 105 | it_behaves_like "an unsupported subject name", 106 | 'a name beginning with a period', 107 | '.com' 108 | end 109 | 110 | context "when the subject exists" do 111 | let(:schema_subject) { create(:subject) } 112 | let!(:schema_versions) { Array.new(1) { create(:version, subject: schema_subject) } } 113 | let(:expected) do 114 | schema_versions.map(&:version).sort.to_json 115 | end 116 | 117 | it "returns a list of the versions" do 118 | get("/subjects/#{schema_subject.name}/versions") 119 | expect(response).to be_ok 120 | expect(response.body).to eq(expected) 121 | end 122 | end 123 | 124 | it_behaves_like "a secure endpoint" do 125 | let(:version) { create(:version) } 126 | let(:action) { unauthorized_get("/subjects/#{version.subject.name}/versions") } 127 | end 128 | 129 | context "when the subject does not exist" do 130 | let(:name) { 'fnord' } 131 | 132 | it "returns a not found error" do 133 | get("/subjects/#{name}/versions") 134 | expect(response).to be_not_found 135 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::SUBJECT_NOT_FOUND.to_json) 136 | end 137 | end 138 | end 139 | 140 | describe "GET /subjects/:name/versions/:version_id" do 141 | it_behaves_like "a secure endpoint" do 142 | let(:version) { create(:schema_version) } 143 | let(:action) do 144 | unauthorized_get("/subjects/#{version.subject.name}/versions/#{version.version}") 145 | end 146 | end 147 | 148 | context "when the subject and version exists" do 149 | let!(:other_schema_version) { create(:schema_version) } 150 | let(:version) { create(:schema_version) } 151 | let(:subject_name) { version.subject.name } 152 | let(:schema) { version.schema } 153 | 154 | let(:expected) do 155 | { 156 | id: version.schema_id, 157 | name: subject_name, 158 | version: version.version, 159 | schema: schema.json 160 | }.to_json 161 | end 162 | 163 | it "returns the schema" do 164 | get("/subjects/#{subject_name}/versions/#{version.version}") 165 | expect(response).to be_ok 166 | expect(JSON.parse(response.body)['id']).to eq(version.schema_id) 167 | expect(response.body).to be_json_eql(expected) 168 | end 169 | 170 | context "when the version is specified as 'latest'" do 171 | it "returns the schema" do 172 | get("/subjects/#{subject_name}/versions/latest") 173 | expect(response).to be_ok 174 | expect(response.body).to be_json_eql(expected) 175 | end 176 | end 177 | 178 | context "when the version is an invalid string" do 179 | it "returns a not found error" do 180 | get("/subjects/#{subject_name}/versions/invalid") 181 | expect(response).to be_not_found 182 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::VERSION_NOT_FOUND.to_json) 183 | end 184 | end 185 | end 186 | 187 | context "when the subject does not exist" do 188 | it "returns a not found error" do 189 | get('/subjects/fnord/versions/latest') 190 | expect(response).to be_not_found 191 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::SUBJECT_NOT_FOUND.to_json) 192 | end 193 | end 194 | 195 | context "when the version does not exist" do 196 | let!(:version) { create(:schema_version) } 197 | 198 | it "returns a not found error" do 199 | get("/subjects/#{version.subject.name}/versions/2") 200 | expect(response).to be_not_found 201 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::VERSION_NOT_FOUND.to_json) 202 | end 203 | end 204 | end 205 | 206 | describe "GET /subjects/:name/versions/:version_id/schema" do 207 | it_behaves_like "a secure endpoint" do 208 | let(:version) { create(:schema_version) } 209 | let(:action) do 210 | unauthorized_get("/subjects/#{version.subject.name}/versions/#{version.version}/schema") 211 | end 212 | end 213 | 214 | context "when the subject and version exists" do 215 | let!(:other_schema_version) { create(:schema_version) } 216 | let(:version) { create(:schema_version) } 217 | let(:subject_name) { version.subject.name } 218 | let(:schema) { version.schema } 219 | let(:expected) { schema.json } 220 | 221 | it "returns the schema" do 222 | get("/subjects/#{subject_name}/versions/#{version.version}/schema") 223 | expect(response).to be_ok 224 | expect(response.body).to be_json_eql(expected) 225 | end 226 | 227 | context "when the version is specified as 'latest'" do 228 | it "returns the schema" do 229 | get("/subjects/#{subject_name}/versions/latest/schema") 230 | expect(response).to be_ok 231 | expect(response.body).to be_json_eql(expected) 232 | end 233 | end 234 | 235 | context "when the version is an invalid string" do 236 | it "returns a not found error" do 237 | get("/subjects/#{subject_name}/versions/invalid/schema") 238 | expect(response).to be_not_found 239 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::VERSION_NOT_FOUND.to_json) 240 | end 241 | end 242 | end 243 | 244 | context "when the subject does not exist" do 245 | it "returns a not found error" do 246 | get('/subjects/fnord/versions/latest/schema') 247 | expect(response).to be_not_found 248 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::SUBJECT_NOT_FOUND.to_json) 249 | end 250 | end 251 | 252 | context "when the version does not exist" do 253 | let!(:version) { create(:schema_version) } 254 | 255 | it "returns a not found error" do 256 | get("/subjects/#{version.subject.name}/versions/2/schema") 257 | expect(response).to be_not_found 258 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::VERSION_NOT_FOUND.to_json) 259 | end 260 | end 261 | end 262 | 263 | describe "GET /subjects/:name/fingerprints/:fingerprint" do 264 | let(:version) { create(:schema_version) } 265 | let(:schema) { version.schema } 266 | let(:other_schema) { build(:schema) } 267 | let(:other_version) { create(:schema_version) } 268 | let(:fingerprint) do 269 | Schemas::FingerprintGenerator.generate_v2(schema.json) 270 | end 271 | let(:other_fingerprint) do 272 | Schemas::FingerprintGenerator.generate_v2(other_schema.json) 273 | end 274 | let(:existing_fingerprint) do 275 | Schemas::FingerprintGenerator.generate_v2(other_version.schema.json) 276 | end 277 | 278 | it_behaves_like "a secure endpoint" do 279 | let(:action) { unauthorized_get("/subjects/#{version.subject.name}/fingerprints/#{fingerprint}") } 280 | end 281 | 282 | context "content type" do 283 | include_examples "content type", :get do 284 | let(:path) { "/subjects/#{version.subject.name}/fingerprints/#{fingerprint}" } 285 | let(:expected) do 286 | { id: schema.id }.to_json 287 | end 288 | end 289 | end 290 | 291 | shared_examples_for "identifying a schema by fingerprint" do 292 | context "when the schema is found" do 293 | let(:expected) do 294 | { id: schema.id }.to_json 295 | end 296 | 297 | it "returns the schema" do 298 | get("/subjects/#{version.subject.name}/fingerprints/#{fingerprint}") 299 | expect(response).to be_ok 300 | expect(response.body).to be_json_eql(expected).including(:id) 301 | end 302 | 303 | it_behaves_like "a cached endpoint" do 304 | let(:action) { get("/subjects/#{version.subject.name}/fingerprints/#{fingerprint}") } 305 | end 306 | end 307 | 308 | context "error cases" do 309 | context "when the schema is not found" do 310 | let(:expected) do 311 | { 312 | error_code: 40403, 313 | message: 'Schema not found' 314 | }.to_json 315 | end 316 | let(:action) { get("/subjects/#{version.subject.name}/fingerprints/#{other_fingerprint}") } 317 | 318 | it "returns a not found response" do 319 | action 320 | expect(response).to be_not_found 321 | expect(response.body).to be_json_eql(expected) 322 | end 323 | 324 | it_behaves_like "an error that cannot be cached" 325 | end 326 | 327 | context "when the schema exists for a different subject" do 328 | let(:expected) do 329 | { 330 | error_code: 40403, 331 | message: 'Schema not found' 332 | }.to_json 333 | end 334 | let(:action) { get("/subjects/#{version.subject.name}/fingerprints/#{existing_fingerprint}") } 335 | 336 | it "returns a not found response" do 337 | action 338 | expect(response).to be_not_found 339 | expect(response.body).to be_json_eql(expected) 340 | end 341 | 342 | it_behaves_like "an error that cannot be cached" 343 | end 344 | end 345 | end 346 | 347 | context "when the fingerprint version is '1'" do 348 | let(:fingerprint) { schema.fingerprint } 349 | let(:other_fingerprint) do 350 | Schemas::FingerprintGenerator.generate_v1(other_schema.json) 351 | end 352 | let(:existing_fingerprint) { other_version.schema.fingerprint } 353 | 354 | before do 355 | allow(Rails.configuration.x).to receive(:fingerprint_version).and_return('1') 356 | end 357 | 358 | context "using a string fingerprint" do 359 | it_behaves_like "identifying a schema by fingerprint" 360 | 361 | context "using a v2 fingerprint" do 362 | let(:fingerprint) { Schemas::FingerprintGenerator.generate_v2(schema.json) } 363 | 364 | it "does not return the schema" do 365 | get("/subjects/#{version.subject.name}/fingerprints/#{fingerprint}") 366 | expect(response).to be_not_found 367 | end 368 | end 369 | end 370 | 371 | context "using an integer fingerprint" do 372 | let(:fingerprint) { super().to_i(16) } 373 | let(:other_fingerprint) { super().to_i(16) } 374 | let(:existing_fingerprint) { super().to_i(16) } 375 | 376 | it_behaves_like "identifying a schema by fingerprint" 377 | end 378 | end 379 | 380 | context "when the fingerprint version is '2'" do 381 | before do 382 | allow(Rails.configuration.x).to receive(:fingerprint_version).and_return('2') 383 | end 384 | 385 | context "using a v1 fingerprint" do 386 | it "does not return the schema" do 387 | get("/subjects/#{version.subject.name}/fingerprints/#{schema.fingerprint}") 388 | expect(response).to be_not_found 389 | end 390 | end 391 | 392 | context "using a v2 fingerprint" do 393 | let(:fingerprint) { schema.fingerprint2 } 394 | let(:other_fingerprint) do 395 | Schemas::FingerprintGenerator.generate_v2(other_schema.json) 396 | end 397 | let(:existing_fingerprint) { other_version.schema.fingerprint2 } 398 | 399 | it_behaves_like "identifying a schema by fingerprint" 400 | end 401 | end 402 | 403 | context "when the fingerprint version is 'all'" do 404 | before do 405 | allow(Rails.configuration.x).to receive(:fingerprint_version).and_return('all') 406 | end 407 | 408 | it_behaves_like "identifying a schema by fingerprint" 409 | 410 | context "using a v2 fingerprint" do 411 | let(:fingerprint) { schema.fingerprint2 } 412 | let(:other_fingerprint) do 413 | Schemas::FingerprintGenerator.generate_v2(other_schema.json) 414 | end 415 | let(:existing_fingerprint) { other_version.schema.fingerprint2 } 416 | 417 | it_behaves_like "identifying a schema by fingerprint" 418 | end 419 | end 420 | end 421 | 422 | describe "POST /subjects/:name/versions" do 423 | it_behaves_like "a secure endpoint" do 424 | let(:version) { create(:version) } 425 | let(:action) do 426 | unauthorized_post("/subjects/#{version.subject.name}/versions", 427 | params: { schema: version.schema.json }) 428 | end 429 | end 430 | 431 | context "with an invalid avro schema" do 432 | let(:subject_name) { 'invalid' } 433 | 434 | it "returns an unprocessable entity error" do 435 | post("/subjects/#{subject_name}/versions", params: { schema: invalid_json }) 436 | expect(response.status).to eq(422) 437 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::INVALID_AVRO_SCHEMA.to_json) 438 | end 439 | end 440 | 441 | context "when DISABLE_SCHEMA_REGISTRATION is set to 'true'" do 442 | let(:json) { build(:schema).json } 443 | let(:subject_name) { 'new_subject' } 444 | 445 | before do 446 | allow(Rails.configuration.x).to receive(:disable_schema_registration).and_return(true) 447 | end 448 | 449 | it "returns an error" do 450 | post("/subjects/#{subject_name}/versions", params: { schema: json }) 451 | expect(response.status).to eq(503) 452 | expect(response.body).to be_json_eql({ message: 'Schema registration is disabled' }.to_json) 453 | end 454 | end 455 | 456 | context "when the app is in read-only mode" do 457 | let(:json) { build(:schema).json } 458 | let(:subject_name) { 'new_subject' } 459 | 460 | before do 461 | allow(Rails.configuration.x).to receive(:read_only_mode).and_return(true) 462 | end 463 | 464 | it "returns an error" do 465 | post("/subjects/#{subject_name}/versions", params: { schema: json }) 466 | expect(response.status).to eq(403) 467 | expect(response.body).to be_json_eql({ message: 'Running in read-only mode' }.to_json) 468 | end 469 | end 470 | 471 | context "when the schema and subject do not exist" do 472 | let(:json) { build(:schema).json } 473 | let(:subject_name) { 'new_subject' } 474 | 475 | it "returns the id of the new schema", :aggregate_failures do 476 | expect do 477 | post("/subjects/#{subject_name}/versions", params: { schema: json }) 478 | end.to change(Schema, :count).by(1) 479 | expect(response).to be_ok 480 | schema_id = SchemaVersion.latest_for_subject_name(subject_name).first.schema_id 481 | expect(response.body).to be_json_eql({ id: schema_id }.to_json).including(:id) 482 | end 483 | 484 | it "creates a new subject and version" do 485 | expect do 486 | expect do 487 | post("/subjects/#{subject_name}/versions", params: { schema: json }) 488 | end.to change(Subject, :count).by(1) 489 | end.to change(SchemaVersion, :count).by(1) 490 | expect(Subject.find_by(name: subject_name)).to be_present 491 | expect(SchemaVersion.latest_for_subject_name(subject_name).first).to be_present 492 | end 493 | 494 | context "when the compatibility level to use after registration is specified" do 495 | let(:after_compatibility) { 'FULL' } 496 | 497 | it "creates the config for the subject", :aggregate_failures do 498 | expect do 499 | post("/subjects/#{subject_name}/versions", 500 | params: { schema: json, after_compatibility: after_compatibility }) 501 | end.to change(Config, :count).by(1) 502 | expect(Subject.find_by(name: subject_name).config.compatibility).to eq(after_compatibility) 503 | end 504 | end 505 | end 506 | 507 | context "when the schema is already registered under the subject" do 508 | let!(:version) { create(:schema_version) } 509 | let(:schema_subject) { version.subject } 510 | 511 | it "returns the id of the schema" do 512 | post("/subjects/#{schema_subject.name}/versions", params: { schema: version.schema.json }) 513 | expect(response).to be_ok 514 | expect(response.body).to be_json_eql({ id: version.schema_id }.to_json).including(:id) 515 | end 516 | 517 | it "does not create a new version" do 518 | expect do 519 | post("/subjects/#{schema_subject.name}/versions", params: { schema: version.schema.json }) 520 | end.not_to change(SchemaVersion, :count) 521 | end 522 | 523 | it "ignores setting a new compatibility level" do 524 | expect do 525 | post("/subjects/#{schema_subject.name}/versions", 526 | params: { schema: version.schema.json, after_compatibility: 'BACKWARD' }) 527 | end.not_to change(Config, :count) 528 | end 529 | end 530 | 531 | context "when a previous version of the schema is registered under the subject" do 532 | before do 533 | allow(Rails.application.config.x).to receive(:default_compatibility) 534 | .and_return(Compatibility::Constants::FULL_TRANSITIVE) 535 | end 536 | 537 | let!(:version) { create(:schema_version) } 538 | let(:schema_subject) { version.subject } 539 | let(:json) do 540 | JSON.parse(version.schema.json).tap do |h| 541 | h['fields'] << { type: :string, name: :additional_field, default: '' } 542 | end.to_json 543 | end 544 | 545 | it "returns the id of a new schema" do 546 | expect do 547 | expect do 548 | post("/subjects/#{schema_subject.name}/versions", params: { schema: json }) 549 | end.to change(Schema, :count).by(1) 550 | end.to change(SchemaVersion, :count).by(1) 551 | 552 | expect(response).to be_ok 553 | expect(JSON.parse(response.body)['id']).not_to eq(version.schema.id) 554 | end 555 | 556 | context "when the new version of the schema adds a default" do 557 | let(:schema) { create(:schema_without_default) } 558 | let!(:version) { create(:schema_version, schema_id: schema.id) } 559 | let(:json) do 560 | JSON.parse(schema.json).tap do |h| 561 | h['fields'].first.merge!(default: 0) 562 | end.to_json 563 | end 564 | 565 | before do 566 | # only fingerprint version '2' recognizes a default as a change 567 | allow(Rails.configuration.x).to receive(:fingerprint_version).and_return('2') 568 | end 569 | 570 | it "returns the id of a new schema" do 571 | expect do 572 | expect do 573 | post("/subjects/#{schema_subject.name}/versions", params: { schema: json }) 574 | end.to change(Schema, :count).by(1) 575 | end.to change(SchemaVersion, :count).by(1) 576 | 577 | expect(response).to be_ok 578 | expect(JSON.parse(response.body)['id']).not_to eq(schema.id) 579 | end 580 | end 581 | 582 | context "when the new version of the schema is incompatible" do 583 | let(:json) { build(:schema).json } 584 | 585 | it "returns an incompatible schema error" do 586 | post("/subjects/#{schema_subject.name}/versions", params: { schema: json }) 587 | expect(status).to eq(409) 588 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::INCOMPATIBLE_AVRO_SCHEMA.to_json) 589 | end 590 | 591 | context "when the compatibility level to use during registration is specified" do 592 | it "returns the id of a new schema" do 593 | expect do 594 | expect do 595 | expect do 596 | post("/subjects/#{schema_subject.name}/versions", 597 | params: { schema: json, with_compatibility: 'NONE' }) 598 | end.to change(Schema, :count).by(1) 599 | end.to change(SchemaVersion, :count).by(1) 600 | end.not_to change(Config, :count) 601 | end 602 | 603 | context "when a compatibility level to use after registration is specified" do 604 | let(:after_compatibility) { 'FORWARD' } 605 | 606 | it "returns the id of a new schema and updates the config for the subject", :aggregate_failures do 607 | expect do 608 | expect do 609 | expect do 610 | post("/subjects/#{schema_subject.name}/versions", 611 | params: { schema: json, with_compatibility: 'NONE', after_compatibility: after_compatibility }) 612 | end.to change(Schema, :count).by(1) 613 | end.to change(SchemaVersion, :count).by(1) 614 | end.to change(Config, :count).by(1) 615 | 616 | expect(Config.find_by(subject_id: schema_subject.id).compatibility).to eq(after_compatibility) 617 | end 618 | 619 | context "when config already exists for the subject" do 620 | let!(:config) { create(:config, subject_id: schema_subject.id, compatibility: 'FULL') } 621 | 622 | it "updates the config for the subject" do 623 | expect do 624 | post("/subjects/#{schema_subject.name}/versions", 625 | params: { schema: json, with_compatibility: 'NONE', after_compatibility: after_compatibility }) 626 | end.not_to change(Config, :count) 627 | expect(config.reload.compatibility).to eq(after_compatibility) 628 | end 629 | end 630 | end 631 | end 632 | end 633 | end 634 | 635 | context "when the schema is already registered under a different subject" do 636 | let!(:version) { create(:schema_version) } 637 | let(:original_subject) { version.subject } 638 | 639 | context "when the subject does not exist" do 640 | let(:subject_name) { 'new_subject' } 641 | 642 | it "returns the id of the schema" do 643 | post("/subjects/#{subject_name}/versions", params: { schema: version.schema.json }) 644 | expect(response).to be_ok 645 | expect(response.body).to be_json_eql({ id: version.schema_id }.to_json).including(:id) 646 | end 647 | 648 | it "creates a new subject and version" do 649 | expect do 650 | expect do 651 | post("/subjects/#{subject_name}/versions", params: { schema: version.schema.json }) 652 | end.to change(Subject, :count).by(1) 653 | end.to change(SchemaVersion, :count).by(1) 654 | expect(Subject.find_by(name: subject_name)).to be_present 655 | expect(SchemaVersion.latest_for_subject_name(subject_name).first).to be_present 656 | end 657 | end 658 | 659 | context "when the subject exists" do 660 | let(:json) { version.schema.json } 661 | let(:new_subject) { create(:subject) } 662 | let(:subject_name) { new_subject.name } 663 | let(:new_json) do 664 | JSON.parse(json).tap do |avro| 665 | avro['fields'] << { name: :new, type: :string, default: '' } 666 | end.to_json 667 | end 668 | let(:new_schema) { create(:schema, json: new_json) } 669 | let!(:new_version) { create(:schema_version, subject: new_subject, schema: new_schema) } 670 | 671 | it "returns the id of the schema" do 672 | post("/subjects/#{subject_name}/versions", params: { schema: json }) 673 | expect(response).to be_ok 674 | expect(response.body).to be_json_eql({ id: version.schema_id }.to_json).including(:id) 675 | end 676 | 677 | it "creates a new schema version" do 678 | expect do 679 | post("/subjects/#{subject_name}/versions", params: { schema: json }) 680 | end.to change(SchemaVersion, :count).by(1) 681 | new_schema_version = SchemaVersion.latest_for_subject_name(subject_name).first 682 | expect(new_schema_version.version).to eq(2) 683 | expect(new_schema_version.schema_id).to eq(version.schema_id) 684 | end 685 | end 686 | 687 | context "when the subject and schema do not exist" do 688 | let(:json) { build(:schema).json } 689 | let(:subject_name) { 'new_subject' } 690 | 691 | it "returns the id of a new schema" do 692 | post("/subjects/#{subject_name}/versions", params: { schema: json }) 693 | expect(response).to be_ok 694 | version = SchemaVersion.latest_for_subject_name(subject_name).first 695 | expect(response.body).to be_json_eql({ id: version.schema_id }.to_json).including(:id) 696 | end 697 | 698 | it "creates a new subject, schema, and schema version" do 699 | expect do 700 | expect do 701 | expect do 702 | post("/subjects/#{subject_name}/versions", params: { schema: json }) 703 | end.to change(Schema, :count).by(1) 704 | end.to change(Subject, :count).by(1) 705 | end.to change(SchemaVersion, :count).by(1) 706 | 707 | version = SchemaVersion.latest_for_subject_name(subject_name).first 708 | expect(version.version).to eq(1) 709 | expect(version.subject.name).to eq(subject_name) 710 | expect(version.schema.json).to eq(json) 711 | end 712 | end 713 | end 714 | 715 | context "retry" do 716 | before do 717 | # after migrations on version fingerprint2 has a unique constraint 718 | allow(Rails.configuration.x).to receive(:fingerprint_version).and_return('2') 719 | end 720 | 721 | context "registering a new schema for a subject" do 722 | let!(:previous_version) { create(:schema_version) } 723 | let(:subject_name) { previous_version.subject.name } 724 | let(:json) do 725 | JSON.parse(previous_version.schema.json).tap do |avro| 726 | avro['fields'] << { name: :extra, type: :string, default: '' } 727 | end.to_json 728 | end 729 | let(:fingerprint) { Schemas::FingerprintGenerator.generate_v2(json) } 730 | 731 | before do 732 | first_time = true 733 | allow(Schema).to receive(:find_by).with(fingerprint2: fingerprint) do 734 | if first_time 735 | @schema = Schema.create!(json: json) 736 | first_time = false 737 | nil 738 | else 739 | @schema 740 | end 741 | end 742 | end 743 | 744 | it "retries once" do 745 | expect do 746 | post("/subjects/#{subject_name}/versions", params: { schema: json }) 747 | end.to change(Schema, :count).by(1) 748 | 749 | expect(response).to be_ok 750 | expect(response.body).to be_json_eql({ id: @schema.id }.to_json).including(:id) 751 | end 752 | end 753 | end 754 | end 755 | 756 | describe "POST /subjects/:name" do 757 | it_behaves_like "a secure endpoint" do 758 | let(:version) { create(:version) } 759 | let(:action) do 760 | unauthorized_post("/subjects/#{version.subject.name}", 761 | params: { schema: version.schema.json }) 762 | end 763 | end 764 | 765 | shared_examples_for "checking for schema existence" do 766 | before do 767 | allow(Rails.configuration.x).to receive(:fingerprint_version).and_return(fingerprint_version) 768 | end 769 | 770 | context "when the schema exists for the subject" do 771 | let!(:version) { create(:schema_version) } 772 | let(:subject_name) { version.subject.name } 773 | let(:expected) do 774 | { 775 | subject: version.subject.name, 776 | id: version.schema_id, 777 | version: version.version, 778 | schema: version.schema.json 779 | }.to_json 780 | end 781 | 782 | it "returns information about the schema" do 783 | post("/subjects/#{subject_name}", params: { schema: version.schema.json }) 784 | expect(response).to be_ok 785 | expect(response.body).to be_json_eql(expected) 786 | end 787 | end 788 | 789 | context "when the subject does not exist" do 790 | let(:subject_name) { 'fnord' } 791 | let(:json) { build(:schema).json } 792 | 793 | it "returns a subject not found error" do 794 | post("/subjects/#{subject_name}", params: { schema: json }) 795 | expect(response).to be_not_found 796 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::SUBJECT_NOT_FOUND.to_json) 797 | end 798 | end 799 | 800 | context "when the schema does not exist for the subject" do 801 | let!(:version) { create(:schema_version) } 802 | let(:subject_name) { version.subject.name } 803 | let(:json) { build(:schema).json } 804 | 805 | it "return a schema not found error" do 806 | post("/subjects/#{subject_name}", params: { schema: json }) 807 | expect(response).to be_not_found 808 | expect(response.body).to be_json_eql(SchemaRegistry::Errors::SCHEMA_NOT_FOUND.to_json) 809 | end 810 | end 811 | end 812 | 813 | context "when fingerprint_version is '1'" do 814 | let(:fingerprint_version) { '1' } 815 | 816 | it_behaves_like "checking for schema existence" 817 | end 818 | 819 | context "when fingerprint_version is '2'" do 820 | let(:fingerprint_version) { '2' } 821 | 822 | it_behaves_like "checking for schema existence" 823 | end 824 | 825 | context "when fingerprint_version is 'all'" do 826 | let(:fingerprint_version) { 'all' } 827 | 828 | it_behaves_like "checking for schema existence" 829 | end 830 | 831 | context "when the schema is invalid" do 832 | # Confluent schema registry does not specify anything in this case, for 833 | # this endpoint, but a 422 makes the most sense to me. Better than a 404. 834 | it "returns an unprocessable entity error" do 835 | post('/subjects/foo', params: { schema: invalid_json }) 836 | expect(response.status).to eq(422) 837 | expect(response.body).to eq(SchemaRegistry::Errors::INVALID_AVRO_SCHEMA.to_json) 838 | end 839 | end 840 | end 841 | end 842 | --------------------------------------------------------------------------------