├── ruby ├── Gemfile ├── spec │ ├── support │ │ └── schemas │ │ │ └── Domain │ │ │ └── Event │ │ │ ├── 2.json │ │ │ └── 1.json │ ├── schema_registry_spec.rb │ ├── schema_registry │ │ └── validator_spec.rb │ └── spec_helper.rb ├── lib │ ├── schema_registry │ │ ├── loader.rb │ │ └── validator.rb │ └── schema_registry.rb ├── schema_registry.gemspec └── Gemfile.lock ├── schemas └── billing │ └── refund │ └── 1.json └── README.md /ruby/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /ruby/spec/support/schemas/Domain/Event/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "description": "invalid json", 4 | "properties": { 5 | "name": { 6 | "type": "string" 7 | }, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ruby/lib/schema_registry/loader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SchemaRegistry 4 | class Loader 5 | def initialize(schemas_root_path:) 6 | @schemas_root_path = schemas_root_path 7 | end 8 | 9 | def schema_path(name, version:) 10 | path = name.tr('.', '/') 11 | File.join(schemas_root_path, "#{path}/#{version}.json") 12 | end 13 | 14 | private 15 | 16 | attr_reader :schemas_root_path 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /ruby/spec/schema_registry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'schema_registry' 4 | require 'securerandom' 5 | 6 | RSpec.describe SchemaRegistry do 7 | describe '#validate_event' do 8 | subject { described_class.validate_event(data, 'billing.refund', version: 1) } 9 | 10 | let(:data) do 11 | { 12 | event_id: SecureRandom.uuid, 13 | event_version: 1, 14 | event_name: 'Domain.Event', 15 | event_time: Time.now.to_s, 16 | producer: 'rspec', 17 | data_version: 1, 18 | data: { 19 | order_id: 1, 20 | account_uuid: SecureRandom.uuid, 21 | timestamp: Time.now.to_s 22 | }, 23 | } 24 | end 25 | 26 | it { is_expected.to eq(SchemaRegistry::Result.new([])) } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /ruby/schema_registry.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = 'schema_registry' 5 | spec.version = 1 6 | spec.authors = ['Anton Davydov'] 7 | spec.email = ['antondavydov.o@gmail.com'] 8 | spec.summary = 'Event schema registry example' 9 | spec.homepage = 'https://github.com/davydovanton/event_schema_registry' 10 | spec.license = 'MIT' 11 | 12 | spec.files = Dir['../schemas/**/*', 'lib/**/*'] 13 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 14 | spec.require_paths = ['../schemas', 'lib/'] 15 | 16 | spec.required_ruby_version = '>= 2.4.0' 17 | 18 | spec.add_dependency 'json-schema' 19 | 20 | spec.add_development_dependency 'bundler' 21 | spec.add_development_dependency 'progress_bar' 22 | spec.add_development_dependency 'rake' 23 | spec.add_development_dependency 'rspec' 24 | end 25 | -------------------------------------------------------------------------------- /ruby/lib/schema_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'json-schema' 5 | 6 | require_relative 'schema_registry/loader' 7 | require_relative 'schema_registry/validator' 8 | 9 | # Validates event schema 10 | # 11 | # SchemaRegistry.validate_event(event_hash, 'order.refund') 12 | # 13 | module SchemaRegistry 14 | # Method for validate event data by specific schema 15 | # 16 | # @param data [Hash] raw event data 17 | # @param type [String] a name of the schema 18 | # @param version [Integer] version of event schema 19 | # 20 | # @return [] Result object with list of validation errors or an empty list if schema is valid 21 | def self.validate_event(data, type, version: 1) 22 | validator.validate( 23 | data, 24 | type, 25 | version: version 26 | ) 27 | end 28 | 29 | def self.validator 30 | @validator ||= Validator.new(loader: loader) 31 | end 32 | 33 | def self.loader 34 | @loader ||= Loader.new(schemas_root_path: File.expand_path('../../schemas', __dir__)) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /ruby/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | schema_registry (1) 5 | json-schema 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | addressable (2.7.0) 11 | public_suffix (>= 2.0.2, < 5.0) 12 | diff-lcs (1.3) 13 | highline (2.0.3) 14 | json-schema (2.8.1) 15 | addressable (>= 2.4) 16 | options (2.3.2) 17 | progress_bar (1.3.1) 18 | highline (>= 1.6, < 3) 19 | options (~> 2.3.0) 20 | public_suffix (4.0.5) 21 | rake (13.0.1) 22 | rspec (3.9.0) 23 | rspec-core (~> 3.9.0) 24 | rspec-expectations (~> 3.9.0) 25 | rspec-mocks (~> 3.9.0) 26 | rspec-core (3.9.0) 27 | rspec-support (~> 3.9.0) 28 | rspec-expectations (3.9.0) 29 | diff-lcs (>= 1.2.0, < 2.0) 30 | rspec-support (~> 3.9.0) 31 | rspec-mocks (3.9.0) 32 | diff-lcs (>= 1.2.0, < 2.0) 33 | rspec-support (~> 3.9.0) 34 | rspec-support (3.9.0) 35 | 36 | PLATFORMS 37 | ruby 38 | 39 | DEPENDENCIES 40 | bundler 41 | progress_bar 42 | rake 43 | rspec 44 | schema_registry! 45 | 46 | BUNDLED WITH 47 | 2.1.4 48 | -------------------------------------------------------------------------------- /ruby/spec/support/schemas/Domain/Event/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | 4 | "title": "Billing.Refund.v1", 5 | "description": "json schema for billing refund event (version 1)", 6 | 7 | "definitions": { 8 | "event_data": { 9 | "type": "object", 10 | "properties": { 11 | "order_id": { 12 | "type": "integer" 13 | }, 14 | "account_uuid": { 15 | "type": "string" 16 | } 17 | }, 18 | "required": [ 19 | "order_id", 20 | "account_uuid" 21 | ] 22 | } 23 | }, 24 | 25 | "type": "object", 26 | 27 | "properties": { 28 | "event_id": { "type": "string" }, 29 | "event_version": { "enum": [1] }, 30 | "event_name": { "type": "string" }, 31 | "event_time": { "type": "string" }, 32 | "producer": { "type": "string" }, 33 | 34 | "data": { "$ref": "#/definitions/event_data" } 35 | }, 36 | 37 | "required": [ 38 | "event_id", 39 | "event_version", 40 | "event_name", 41 | "event_time", 42 | "producer", 43 | "data" 44 | ] 45 | } 46 | 47 | -------------------------------------------------------------------------------- /ruby/lib/schema_registry/validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require 'dry/monads' 5 | rescue LoadError 6 | end 7 | 8 | module SchemaRegistry 9 | class Result 10 | attr_reader :result 11 | 12 | def initialize(validation_result) 13 | @result = validation_result 14 | end 15 | 16 | def success? 17 | result.empty? 18 | end 19 | 20 | def failure? 21 | result.any? 22 | end 23 | 24 | def failure 25 | result 26 | end 27 | 28 | def ==(other) 29 | self.result == other.result 30 | end 31 | end 32 | 33 | class Validator 34 | def initialize(loader:) 35 | @loader = loader 36 | end 37 | 38 | attr_reader :loader 39 | 40 | def validate(data, type, version: 1) 41 | schema_path = loader.schema_path(type, version: version) 42 | result = JSON::Validator.fully_validate(schema_path, data) 43 | 44 | # TODO: use monads instead result object if gem was defined 45 | if defined?(Dry::Monads::Result::Success) 46 | result.empty? ? Dry::Monads::Result::Success.new(result) : Dry::Monads::Result::Failure.new(result) 47 | else 48 | Result.new(result) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /schemas/billing/refund/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | 4 | "title": "Billing.Refund.v1", 5 | "description": "json schema for billing refund event (version 1)", 6 | 7 | "definitions": { 8 | "event_data": { 9 | "type": "object", 10 | "properties": { 11 | "order_id": { 12 | "type": "integer" 13 | }, 14 | "reason": { 15 | "type": "string" 16 | }, 17 | "account_uuid": { 18 | "type": "string" 19 | }, 20 | "timestamp": { 21 | "type": "string" 22 | } 23 | }, 24 | "required": [ 25 | "order_id", 26 | "account_uuid", 27 | "timestamp" 28 | ] 29 | } 30 | }, 31 | 32 | "type": "object", 33 | 34 | "properties": { 35 | "event_id": { "type": "string" }, 36 | "event_version": { "enum": [1] }, 37 | "event_name": { "type": "string" }, 38 | "event_time": { "type": "string" }, 39 | "producer": { "type": "string" }, 40 | 41 | "data": { "$ref": "#/definitions/event_data" } 42 | }, 43 | 44 | "required": [ 45 | "event_id", 46 | "event_version", 47 | "event_name", 48 | "event_time", 49 | "producer", 50 | "data" 51 | ] 52 | } 53 | 54 | -------------------------------------------------------------------------------- /ruby/spec/schema_registry/validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'schema_registry' 4 | 5 | RSpec.describe SchemaRegistry::Validator do 6 | describe '#validate' do 7 | subject { validator.validate(data, event_name, version: version) } 8 | 9 | let(:validator) { described_class.new(loader: loader) } 10 | let(:loader) { SchemaRegistry::Loader.new(schemas_root_path: schemas_root_path) } 11 | let(:schemas_root_path) { File.expand_path('../support/schemas', __dir__) } 12 | 13 | let(:event_name) { 'domain.event' } 14 | let(:category) { 'general' } 15 | let(:version) { 1 } 16 | 17 | context 'when event schema is valid' do 18 | context 'when type schema is valid' do 19 | let(:data) do 20 | { 21 | event_id: SecureRandom.uuid, 22 | event_version: 1, 23 | event_name: 'Domain.Event', 24 | event_time: Time.now.to_s, 25 | producer: 'rspec', 26 | data_version: 1, 27 | data: { 28 | order_id: 1, 29 | account_uuid: SecureRandom.uuid, 30 | }, 31 | } 32 | end 33 | 34 | it { expect(subject).to be_success } 35 | end 36 | 37 | context 'when event schema is invalid' do 38 | let(:data) { { data: { name: nil } } } 39 | 40 | it { expect(subject).to be_failure } 41 | 42 | it 'returns errors' do 43 | expect(subject.failure.count).to eq(7) 44 | expect(subject.failure.last).to include("The property '#/' did not contain a required property of 'producer' in schema") 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Event schema registry 2 | 3 | This repository is an example of how to make event schema registry for JSON schema events using only github. The general idea - how to share schemas across different services plus how to validate data for specific events. 4 | 5 | ## Setup 6 | ### Ruby 7 | Add this line into your Gemfile: 8 | 9 | ``` 10 | gem "schema_registry", git: "https://github.com/davydovanton/event_schema_registry.git" 11 | ``` 12 | 13 | ## How to add a new event schema 14 | 15 | For example, you want to create `billing.refund` event. For make it happen you need: 16 | 17 | 1. Create a new file `domain/event_name/version.json` in `schemas/` folder. For `billing.refund` it will be `schemas/billing/refund/1.json` (because all new events should be first version; 18 | 2. Create a new json schema file like this: 19 | 20 | ``` 21 | { 22 | "$schema": "http://json-schema.org/draft-04/schema#", 23 | 24 | "title": "Billing.Refund.v1", 25 | "description": "json schema for billing refund event (version 1)", 26 | 27 | "definitions": { 28 | "event_data": { 29 | "type": "object", 30 | "properties": { 31 | // event specific information here 32 | }, 33 | "required": [ 34 | ] 35 | } 36 | }, 37 | 38 | "type": "object", 39 | 40 | "properties": { 41 | "event_id": { "type": "string" }, 42 | "event_version": { "enum": [1] }, 43 | "event_name": { "type": "string" }, 44 | "event_time": { "type": "string" }, 45 | "producer": { "type": "string" }, 46 | 47 | "data": { "$ref": "#/definitions/event_data" } 48 | }, 49 | 50 | "required": [ 51 | "event_id", 52 | "event_version", 53 | "event_name", 54 | "event_time", 55 | "producer", 56 | "data" 57 | ] 58 | } 59 | ``` 60 | 61 | ## How to validate an event data by specific schema 62 | 63 | ### Ruby 64 | 65 | For validating event data you need to use `SchemaRegistry#validate_event` method with following options: 66 | 67 | * `data` - event data 68 | * `name` - name of event which you will use for getting schema 69 | * `version` - version of event data schema (default `1`) 70 | 71 | Example: 72 | 73 | ```ruby 74 | message = { 75 | # ... 76 | } 77 | 78 | # will try to search `schemas/Billing/CompliteCycle/1.json` file 79 | result = SchemaRegistry.validate_event(data, 'Billing.CompliteCycle', version: 1) 80 | # will try to search `schemas/billing/complite_cycle/1.json` file 81 | result = SchemaRegistry.validate_event(data, 'billing.complite_cycle', version: 1) 82 | 83 | # After you can work with result object 84 | result.success? 85 | result.failure? 86 | result.failure 87 | ``` 88 | 89 | ## How to use this library with producer 90 | ### Option one: with event object 91 | ```ruby 92 | result = SchemaRegistry.validate_event(event, 'billing.refund', version: 1) 93 | 94 | if result.success? 95 | kafka.produce('topic', event.to_json) 96 | end 97 | ``` 98 | 99 | ### Option two: with pure hash 100 | ```ruby 101 | result = SchemaRegistry.validate_event(event, 'billing.refund', version: 1) 102 | 103 | if result.success? 104 | kafka.produce('topic', event.to_json) 105 | end 106 | ``` 107 | -------------------------------------------------------------------------------- /ruby/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'schema_registry' 4 | 5 | # This file was generated by the `rspec --init` command. Conventionally, all 6 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 7 | # The generated `.rspec` file contains `--require spec_helper` which will cause 8 | # this file to always be loaded, without a need to explicitly require it in any 9 | # files. 10 | # 11 | # Given that it is always loaded, you are encouraged to keep this file as 12 | # light-weight as possible. Requiring heavyweight dependencies from this file 13 | # will add to the boot time of your test suite on EVERY test run, even for an 14 | # individual file that may not need all of that loaded. Instead, consider making 15 | # a separate helper file that requires the additional dependencies and performs 16 | # the additional setup, and require it from the spec files that actually need 17 | # it. 18 | # 19 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 20 | RSpec.configure do |config| 21 | # rspec-expectations config goes here. You can use an alternate 22 | # assertion/expectation library such as wrong or the stdlib/minitest 23 | # assertions if you prefer. 24 | config.expect_with :rspec do |expectations| 25 | # This option will default to `true` in RSpec 4. It makes the `description` 26 | # and `failure_message` of custom matchers include text for helper methods 27 | # defined using `chain`, e.g.: 28 | # be_bigger_than(2).and_smaller_than(4).description 29 | # # => "be bigger than 2 and smaller than 4" 30 | # ...rather than: 31 | # # => "be bigger than 2" 32 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 33 | end 34 | 35 | # rspec-mocks config goes here. You can use an alternate test double 36 | # library (such as bogus or mocha) by changing the `mock_with` option here. 37 | config.mock_with :rspec do |mocks| 38 | # Prevents you from mocking or stubbing a method that does not exist on 39 | # a real object. This is generally recommended, and will default to 40 | # `true` in RSpec 4. 41 | mocks.verify_partial_doubles = true 42 | end 43 | 44 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 45 | # have no way to turn it off -- the option exists only for backwards 46 | # compatibility in RSpec 3). It causes shared context metadata to be 47 | # inherited by the metadata hash of host groups and examples, rather than 48 | # triggering implicit auto-inclusion in groups with matching metadata. 49 | config.shared_context_metadata_behavior = :apply_to_host_groups 50 | 51 | # This allows you to limit a spec run to individual examples or groups 52 | # you care about by tagging them with `:focus` metadata. When nothing 53 | # is tagged with `:focus`, all examples get run. RSpec also provides 54 | # aliases for `it`, `describe`, and `context` that include `:focus` 55 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 56 | config.filter_run_when_matching :focus 57 | 58 | # Limits the available syntax to the non-monkey patched syntax that is 59 | # recommended. For more details, see: 60 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 61 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 62 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 63 | config.disable_monkey_patching! 64 | 65 | # This setting enables warnings. It's recommended, but in some cases may 66 | # be too noisy due to issues in dependencies. 67 | config.warnings = true 68 | 69 | # Many RSpec users commonly either run the entire suite or an individual 70 | # file, and it's useful to allow more verbose output when running an 71 | # individual spec file. 72 | if config.files_to_run.one? 73 | # Use the documentation formatter for detailed output, 74 | # unless a formatter has already been configured 75 | # (e.g. via a command-line flag). 76 | config.default_formatter = 'doc' 77 | end 78 | 79 | # Print the 10 slowest examples and example groups at the 80 | # end of the spec run, to help surface which specs are running 81 | # particularly slow. 82 | config.profile_examples = 10 83 | 84 | # Run specs in random order to surface order dependencies. If you find an 85 | # order dependency and want to debug it, you can fix the order by providing 86 | # the seed, which is printed after each run. 87 | # --seed 1234 88 | config.order = :random 89 | 90 | # Seed global randomization in this process using the `--seed` CLI option. 91 | # Setting this allows you to use `--seed` to deterministically reproduce 92 | # test failures related to randomization by passing the same `--seed` value 93 | # as the one that triggered the failure. 94 | Kernel.srand config.seed 95 | end 96 | --------------------------------------------------------------------------------