diff --git a/lib/jsonschema_serializer.rb b/lib/jsonschema_serializer.rb index e930e25..9879ed2 100644 --- a/lib/jsonschema_serializer.rb +++ b/lib/jsonschema_serializer.rb @@ -2,6 +2,7 @@ require 'jsonschema_serializer/version' require 'jsonschema_serializer/builder' +require 'jsonschema_serializer/typed_builder' require 'jsonschema_serializer/active_record' require 'jsonschema_serializer/filter_utilities' diff --git a/lib/jsonschema_serializer/error.rb b/lib/jsonschema_serializer/error.rb index 977bd03..4531837 100644 --- a/lib/jsonschema_serializer/error.rb +++ b/lib/jsonschema_serializer/error.rb @@ -10,4 +10,14 @@ def initialize(msg = DEFAULT_MESSAGE) super(msg) end end + + # +DuplicatedObjectPropertyError+ is an +ArgumentError+ + class DuplicatedObjectPropertyError < ArgumentError + # Default message for +DuplicatedObjectPropertyError+ instance + DEFAULT_MESSAGE = 'Duplicated declaration for object properties' + + def initialize(msg = DEFAULT_MESSAGE) + super(msg) + end + end end diff --git a/lib/jsonschema_serializer/typed_builder.rb b/lib/jsonschema_serializer/typed_builder.rb new file mode 100644 index 0000000..8ec0161 --- /dev/null +++ b/lib/jsonschema_serializer/typed_builder.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require_relative 'types' + +module JsonschemaSerializer + # The +JsonschemaSerializer::Builder+ class provides + # an effective DSL to generate a valid json schema. + class TypedBuilder < JsonschemaSerializer::Types::Object + class << self + # The +build+ class method create a new instance and applies the block + def build + new.tap do |builder| + yield(builder) if block_given? + end + end + end + + # The +new+ method creates assigns a new object + # to a +schema+ instance variable + def schema + self + end + + # Assigns the +title+ to the root schema object + # + # Params: + # +title+:: +String+ or +Symbol+ title field of the schema object + + def title(title) + schema[:title] = title + end + + # Assigns the +description+ to the root schema object + # + # params: + # +description+:: +String+ description field of the schema object + + def description(description) + schema[:description] = description + end + + # The +required+ method allows to provide a list of required properties + # + # params: + # +required+ [Array[String, Symbol]] + + def required(*required) + schema[:required] = required + end + + # The +properties+ method allows to access object properties + # + # e.g.: + # JsonschemaSerializer::Builder.build do |b| + # b.properties.tap do |p| + # p.merge! {} + # end + # end + + def properties + schema[:properties] ||= {} + end + + # A base representation of the +boolean+ type. + def _boolean(**opts) + JsonschemaSerializer::Types::Boolean.new(**opts) + end + + # A property representation of the +boolean+ type. + # + # Params: + # +name+:: +String+ or +Symbol+ + # + # Optional Params: + # +default+:: +Boolean+ default value + # +description+:: +String+ property description + # +title+:: +String+ property title + + def boolean(name, **opts) + JsonschemaSerializer::Types::Boolean.named(name, **opts) + end + + # A base representation of the +integer+ type. + def _integer(**opts) + JsonschemaSerializer::Types::Integer.new(**opts) + end + + # A property representation of the +integer+ type. + # + # Params: + # +name+:: +String+ or +Symbol+ + # + # Optional Params: + # +default+:: +Integer+ default value + # +description+:: +String+ property description + # +title+:: +String+ property title + # +enum+:: +Array+ property allowed values + # +minimum+:: +Integer+ property minimum value + # +maximum+:: +Integer+ property maximum value + # +multipleOf+:: +Integer+ property conditional constraint + + def integer(name, **opts) + JsonschemaSerializer::Types::Integer.named(name, **opts) + end + + # A base representation of the +number+ type. + def _number(**opts) + JsonschemaSerializer::Types::Number.new(**opts) + end + + # A property representation of the +number+ type. + # + # Params: + # +name+:: +String+ or +Symbol+ + # + # Optional Params: + # +default+:: +Numeric+ default value + # +description+:: +String+ property description + # +title+:: +String+ property title + # +enum+:: +Array+ property allowed values + # +minimum+:: +Numeric+ property minimum value + # +maximum+:: +Numeric+ property maximum value + # +multipleOf+:: +Numeric+ property conditional constraint + + def number(name, **opts) + JsonschemaSerializer::Types::Number.named(name, **opts) + end + + # A base representation of the +string+ type. + def _string(**opts) + JsonschemaSerializer::Types::String.new(**opts) + end + + # A property representation of the +string+ type. + # + # Params: + # +name+:: +String+ or +Symbol+ + # + # Optional Params: + # +default+:: +String+ default value + # +description+:: +String+ property description + # +title+:: +String+ property title + # +format+:: +String+ property format for validation + # +minLength+:: +Int+ property minimum length + + def string(name, **opts) + JsonschemaSerializer::Types::String.named(name, **opts) + end + + # A base representation of the +object+ type. + # + # JsonschemaSerializer::Builder.build do |b| + # subscriber = b._object title: :subscriber, required: [:age] do |prop| + # prop.merge! b.string :first_name, title: 'First Name' + # prop.merge! b.string :last_name, title: 'Last Name' + # prop.merge! b.integer :age, title: 'Age' + # end + # end + + def _object(**opts) + JsonschemaSerializer::Types::Object.new(**opts).tap do |h| + yield(h[:properties]) if block_given? + end + end + + # A property representation of the +array+ type. + # + # Params: + # +name+:: +String+ or +Symbol+ + # +items+:: an object representation or an array of objects + # + # Optional Params: + # +default+:: +Array+ default value + # +description+:: +String+ property description + # +title+:: +String+ property title + # +minItems+:: +Int+ property minimum length + # +maxItems+:: +Int+ property maximum length + # + # JsonschemaSerializer::Builder.build do |b| + # b.array :integers, items: {type: :integer}, minItems:5 + # b.array :strings, items: b._string, default: [] + # + # subscriber = b._object title: :subscriber, required: [:age] do |prop| + # prop.merge! b.string :first_name, title: 'First Name' + # prop.merge! b.string :last_name, title: 'Last Name' + # prop.merge! b.integer :age, title: 'Age' + # end + # b.array :subscribers, items: subscriber + # end + + def array(name, items:, **opts) + JsonschemaSerializer::Types::Array.named(name, items: items, **opts) + end + end +end diff --git a/lib/jsonschema_serializer/types.rb b/lib/jsonschema_serializer/types.rb new file mode 100644 index 0000000..10ffbcd --- /dev/null +++ b/lib/jsonschema_serializer/types.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require_relative 'error' + +module JsonschemaSerializer + # +Hash+ class with some extended methods + class FutureHash < Hash + # Backport ruby 2.5 yield_self method + def yield_self + yield(self) + end + + # The +to_json+ method exports the schema as a json string + # By default it would exported with a pretty print + # + # Params: + # +pretty+:: +Boolean+ + + def to_json(pretty: true) + pretty ? JSON.pretty_generate(self.dup) : super + end + end + + # This module contains types declarations + module Types + # Base type from which all other types inherit + class Base < JsonschemaSerializer::FutureHash + class << self + # Default FutureHash structure. This should be implemented in + # every subclass + def default_hash + FutureHash.new + end + + # Default Hash structure merged with class attributes and instance options + def processed_hash(**opts) + default_hash + .yield_self { |h| class_attributes.reduce(h) { |h1, a| h1.merge(a) } } + .merge(opts) + end + + # Initialize a new object with the default hash attributes + def new(**opts) + super.yield_self do |h| + if !@name.nil? + # Merge with name and reset it after the creation + h.merge(@name => processed_hash(**opts)).tap { @name = nil } + else + h.merge(processed_hash(**opts)) + end + end + end + + # Initialize a new object with name as key + def named(name, **opts) + @name = name + new(**opts) + end + + # Allowed class attributes declaration + def allowed_class_attributes + [@title, @description, @default] + end + + # Class level attributes declaration + def class_attributes + allowed_class_attributes.compact + end + + # Assigns the +default+ to the object + # + # Params: + # +default+:: default value field of the object + + def default(default_value) + @default = { default: default_value } + end + + # Assigns the +description+ to the object + # + # Params: + # +description+:: +String+ or +Symbol+ description field of the object + + def description(description) + @description = { description: description } + end + + # Assigns the +title+ to the object + # + # Params: + # +title+:: +String+ or +Symbol+ title field of the object + + def title(title) + @title = { title: title } + end + end + end + + # Array type for jsonschema serializer + class Array < JsonschemaSerializer::Types::Base + class << self + # Default Hash structure + def default_hash + FutureHash[{ type: :array }] + end + + # Default new array + # + # Params: + # +items+:: an object representation or an array of objects + # + + def new(items:, **opts) + super(items: items, **opts) + end + end + end + + # Boolean type for jsonschema serializer + class Boolean < JsonschemaSerializer::Types::Base + class << self + # Default Hash structure + def default_hash + FutureHash[{ type: :boolean }] + end + end + end + + # Integer type for jsonschema serializer + class Integer < JsonschemaSerializer::Types::Base + class << self + # Default Hash structure + def default_hash + FutureHash[{ type: :integer }] + end + end + end + + # Number type for jsonschema serializer + class Number < JsonschemaSerializer::Types::Base + class << self + # Default Hash structure + def default_hash + FutureHash[{ type: :number }] + end + end + end + + # Object type for jsonschema serializer + class Object < JsonschemaSerializer::Types::Base + class << self + # Default Hash structure + def default_hash + FutureHash[{ type: :object, properties: {} }] + end + + # Allowed class attributes declaration + def allowed_class_attributes + super.concat([@required, @properties]) + end + + # The +required+ method allows to provide a list of required properties + # + # params: + # +required+ [Array[String, Symbol]] + + def required(*required) + @required = { required: required } + end + + # The +properties+ method allows to access object properties + # + # e.g.: + # class CustomObject < JsonschemaSerializer::Types::Object + # properties( + # JsonschemaSerializer::Types::String.named(:foo), + # JsonschemaSerializer::Types::Boolean.named(:bar) + # ) + # end + + def properties(*properties) + raise JsonschemaSerializer::DuplicatedObjectPropertyError if @properties + props = properties.reduce({}) { |h, prop| h.merge(prop) } + @properties = { properties: props } + end + end + end + + # String type for jsonschema serializer + class String < JsonschemaSerializer::Types::Base + class << self + # Default Hash structure + def default_hash + FutureHash[{ type: :string }] + end + end + end + end +end diff --git a/spec/jsonschema_serializer/error_spec.rb b/spec/jsonschema_serializer/error_spec.rb index e4ad236..415df06 100644 --- a/spec/jsonschema_serializer/error_spec.rb +++ b/spec/jsonschema_serializer/error_spec.rb @@ -1,4 +1,3 @@ - # frozen_string_literal: true RSpec.describe 'JsonschemaSerializer errors' do @@ -9,4 +8,12 @@ end.to raise_error(ArgumentError) end end + + context 'DuplicatedObjectPropertyError' do + it 'should be an ArgumentError' do + expect do + raise JsonschemaSerializer::DuplicatedObjectPropertyError, 'a custom message' + end.to raise_error(ArgumentError) + end + end end diff --git a/spec/jsonschema_serializer/typed_builder_spec.rb b/spec/jsonschema_serializer/typed_builder_spec.rb new file mode 100644 index 0000000..3a41fb4 --- /dev/null +++ b/spec/jsonschema_serializer/typed_builder_spec.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +RSpec.describe JsonschemaSerializer::TypedBuilder do + let(:builder) { JsonschemaSerializer::TypedBuilder } + + it 'should generate an empty object' do + expect(builder.build.schema).to eq(type: :object, properties: {}) + end + + it 'should generate add some attributes to the root object' do + actual = builder.build do |b| + b.title 'a title' + b.description 'a description' + b.required :a, :b, :c + end + + expect(actual.schema).to eq( + title: 'a title', + description: 'a description', + required: [:a, :b, :c], + type: :object, + properties: {} + ) + end + + context 'properties on root object' do + it 'should add simple array attributes' do + actual = builder.build do |b| + b.properties.tap do |p| + p.merge! b.array :ary, items: b._string(default: 'foo') + end + end + + expect(actual.schema).to eq( + type: :object, + properties: { + ary: { + type: :array, + items: { + type: :string, + default: 'foo' + } + } + } + ) + end + + it 'should add complex array attributes' do + actual = builder.build do |b| + subscriber = b._object title: :subscriber, required: [:age] do |prop| + prop.merge! b.string :first_name, title: 'First Name' + prop.merge! b.string :last_name, title: 'Last Name' + prop.merge! b.integer :age, title: 'Age' + end + + b.properties.tap do |p| + p.merge! b.array :subscribers, minItems: 1, items: subscriber + end + end + + expect(actual.schema).to eq( + type: :object, + properties: { + subscribers: { + type: :array, + minItems: 1, + items: { + type: :object, + title: :subscriber, + required: [:age], + properties: { + first_name: { + type: :string, + title: 'First Name' + }, + last_name: { + type: :string, + title: 'Last Name' + }, + age: { + type: :integer, + title: 'Age' + } + } + } + } + } + ) + end + + it 'should add boolean attributes' do + actual = builder.build do |b| + b.properties.tap do |p| + p.merge! b.boolean :a, default: true + end + end + + expect(actual.schema).to eq( + type: :object, + properties: { + a: { + type: :boolean, + default: true + } + } + ) + end + + it 'should add integer attributes' do + actual = builder.build do |b| + b.properties.tap do |p| + p.merge! b.integer :b, maximum: 10 + end + end + + expect(actual.schema).to eq( + type: :object, + properties: { + b: { + type: :integer, + maximum: 10 + } + } + ) + end + + it 'should add number attributes' do + actual = builder.build do |b| + b.properties.tap do |p| + p.merge! b.number :c, minimum: 3 + end + end + + expect(actual.schema).to eq( + type: :object, + properties: { + c: { + type: :number, + minimum: 3 + } + } + ) + end + + it 'should add string attributes' do + actual = builder.build do |b| + b.properties.tap do |p| + p.merge! b.string :d, description: 'abc' + end + end + + expect(actual.schema).to eq( + type: :object, + properties: { + d: { + type: :string, + description: 'abc' + } + } + ) + end + end + + describe 'generate valid json schemas' do + let(:schema) do + builder.build do |b| + b.title 'a title' + b.description 'a description' + b.required :a, :b, :c + b.properties.tap do |p| + p.merge! b.string :a, minLength: 2 + p.merge! b.number :b, maximum: 10 + p.merge! b.array :c, minItems: 1, items: b._integer + p.merge! b.boolean :d + p.merge! b.integer :e, enum: [1, 2, 3] + end + end.schema + end + + it 'should fail for empty data' do + expect(JSON::Validator.validate(schema, {})).to eq(false) + end + + it 'should fail for missing required keys' do + expect(JSON::Validator.validate(schema, a: 'ab')).to eq(false) + end + + context 'irrespective of required keys constraints' do + let(:valid_data) { { a: 'ab', b: 9.9, c: [1] } } + + it 'should succeed with valid data' do + expect(JSON::Validator.validate(schema, valid_data)).to eq(true) + end + + it 'should fail for string minLenght' do + min_length = valid_data.merge(a: '') + expect(JSON::Validator.validate(schema, min_length)).to eq(false) + end + + it 'should fail for number maximum' do + maximum = valid_data.merge(b: 10.1) + expect(JSON::Validator.validate(schema, maximum)).to eq(false) + end + + it 'should fail for array minItems' do + min_items = valid_data.merge(c: []) + expect(JSON::Validator.validate(schema, min_items)).to eq(false) + end + + it 'should fail for integer enum' do + enum_fail = valid_data.merge(e: 4) + expect(JSON::Validator.validate(schema, enum_fail)).to eq(false) + end + + it 'should fail for wrong type' do + type_fail = valid_data.merge(d: 4) + expect(JSON::Validator.validate(schema, type_fail)).to eq(false) + end + end + end + + describe 'dsl for empty types' do + subject { builder.new } + + it { expect(subject._boolean).to eq(JsonschemaSerializer::Types::Boolean.new) } + it { expect(subject._integer).to eq(JsonschemaSerializer::Types::Integer.new) } + it { expect(subject._number).to eq(JsonschemaSerializer::Types::Number.new) } + it { expect(subject._object).to eq(JsonschemaSerializer::Types::Object.new) } + it { expect(subject._string).to eq(JsonschemaSerializer::Types::String.new) } + end + + context '.to_json' do + let(:instance) { builder.new } + + it 'should dump a prettified json by default' do + # Testing with regex since jruby add more \n + expected = /{\n \"type\": \"object\",\n \"properties\": {\n+ }\n}/ + expect(instance.to_json).to match(expected) + end + + it 'should allow to dump also not prettified json' do + expected = '{"type":"object","properties":{}}' + expect(instance.to_json(pretty: false)).to eq(expected) + end + end +end diff --git a/spec/jsonschema_serializer/types_spec.rb b/spec/jsonschema_serializer/types_spec.rb new file mode 100644 index 0000000..e191233 --- /dev/null +++ b/spec/jsonschema_serializer/types_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +RSpec.describe 'JsonschemaSerializer types' do + describe JsonschemaSerializer::Types::Base do + subject { JsonschemaSerializer::Types::Base } + + it { subject.respond_to?(:default_hash) } + it { expect(subject.default_hash).to eq({}) } + + it { subject.respond_to?(:new) } + it { expect(subject.new).to eq({}) } + it { expect(subject.new(a: 1)).to eq(a: 1) } + + it { subject.respond_to?(:named) } + it { expect(subject.named(:foo)).to eq(foo: {}) } + it { expect(subject.named(:foo, bar: :baz)).to eq(foo: { bar: :baz }) } + + it { subject.respond_to?(:allowed_class_attributes) } + it { subject.respond_to?(:title) } + it { subject.respond_to?(:description) } + it { subject.respond_to?(:default) } + it { expect(subject.allowed_class_attributes).to eq([nil, nil, nil]) } + + it { subject.respond_to?(:class_attributes) } + it { expect(subject.class_attributes).to eq([]) } + + context 'a subclass' do + class PartialSerializer < JsonschemaSerializer::Types::Base + title 'a title' + end + + it 'PartialSerializer.allowed_class_attributes' do + expect(PartialSerializer.allowed_class_attributes).to match_array( + [{ title: 'a title' }, nil, nil] + ) + end + + it 'PartialSerializer.class_attributes' do + expect(PartialSerializer.class_attributes) + .to match_array([{ title: 'a title' }]) + end + + it 'PartialSerializer.new' do + expect(PartialSerializer.new).to eq(title: 'a title') + end + end + end + + describe JsonschemaSerializer::Types::Array do + subject { JsonschemaSerializer::Types::Array } + + it { expect(subject.superclass).to eq(JsonschemaSerializer::Types::Base) } + it { expect(subject.default_hash).to eq(type: :array) } + it { expect(subject.new(items: {}).to_json).to eq(type: :array) } + + let(:name) { :abc } + it { expect(subject.named(name, items: {})).to eq(name => {type: :array, items: {}}) } + it 'needs items key' do + expect { subject.new }.to raise_error(ArgumentError, /items/) + end + + it { expect(subject.new(items: {})).to eq(type: :array, items: {}) } + it do + expect(subject.named(:foo, items: {})).to eq( + foo: { type: :array, items: {} } + ) + end + + it do + expect(subject.named(:foo, bar: :baz, items: {})).to eq( + foo: { type: :array, bar: :baz, items: {} } + ) + end + end + + describe JsonschemaSerializer::Types::Boolean do + subject { JsonschemaSerializer::Types::Boolean } + + it { expect(subject.superclass).to eq(JsonschemaSerializer::Types::Base) } + it { expect(subject.default_hash).to eq(type: :boolean) } + it { expect(subject.new).to eq(type: :boolean) } + it { expect(subject.new(a: 1)).to eq(type: :boolean, a: 1) } + it { expect(subject.named(:foo)).to eq(foo: { type: :boolean }) } + it do + expect(subject.named(:foo, bar: :baz)).to eq( + foo: { type: :boolean, bar: :baz } + ) + end + + context 'a subclass' do + class BooleanSerializer < JsonschemaSerializer::Types::Boolean + title 'BooleanSerializer' + description 'a dummy serializer for testing purposes' + default true + end + + it 'should include class level attribute declaration' do + expect(BooleanSerializer.new).to eq( + title: 'BooleanSerializer', + description: 'a dummy serializer for testing purposes', + default: true, + type: :boolean + ) + end + end + end + + describe JsonschemaSerializer::Types::Integer do + subject { JsonschemaSerializer::Types::Integer } + + it { expect(subject.superclass).to eq(JsonschemaSerializer::Types::Base) } + it { expect(subject.default_hash).to eq(type: :integer) } + it { expect(subject.new).to eq(type: :integer) } + it { expect(subject.new(a: 1)).to eq(type: :integer, a: 1) } + it { expect(subject.named(:foo)).to eq(foo: { type: :integer }) } + it do + expect(subject.named(:foo, bar: :baz)).to eq( + foo: { type: :integer, bar: :baz } + ) + end + end + + describe JsonschemaSerializer::Types::Number do + subject { JsonschemaSerializer::Types::Number } + + it { expect(subject.superclass).to eq(JsonschemaSerializer::Types::Base) } + it { expect(subject.default_hash).to eq(type: :number) } + it { expect(subject.new).to eq(type: :number) } + it { expect(subject.new(a: 1)).to eq(type: :number, a: 1) } + it { expect(subject.named(:foo)).to eq(foo: { type: :number }) } + it do + expect(subject.named(:foo, bar: :baz)).to eq( + foo: { type: :number, bar: :baz } + ) + end + end + + describe JsonschemaSerializer::Types::Object do + subject { JsonschemaSerializer::Types::Object } + + it { expect(subject.superclass).to eq(JsonschemaSerializer::Types::Base) } + it { expect(subject.default_hash).to eq(type: :object, properties: {}) } + it { expect(subject.new).to eq(type: :object, properties: {}) } + it { expect(subject.new(a: 1)).to eq(type: :object, properties: {}, a: 1) } + it { expect(subject.named(:foo)).to eq(foo: { type: :object, properties: {} }) } + it do + expect(subject.named(:foo, bar: :baz)).to eq( + foo: { type: :object, properties: {}, bar: :baz } + ) + end + + context 'a subclass' do + class ObjectSerializer < JsonschemaSerializer::Types::Object + title 'ObjectSerializer' + description 'a dummy serializer for testing purposes' + default '{}' + required :a, :b + properties( + JsonschemaSerializer::Types::Boolean.named(:a), + JsonschemaSerializer::Types::String.named(:b) + ) + end + + it 'should include class level attribute declaration' do + expect(ObjectSerializer.new).to eq( + title: 'ObjectSerializer', + description: 'a dummy serializer for testing purposes', + default: '{}', + type: :object, + properties: { + a: { type: :boolean }, + b: { type: :string } + }, + required: [:a, :b] + ) + end + end + end + + describe JsonschemaSerializer::Types::String do + subject { JsonschemaSerializer::Types::String } + + it { expect(subject.superclass).to eq(JsonschemaSerializer::Types::Base) } + it { expect(subject.default_hash).to eq(type: :string) } + it { expect(subject.new).to eq(type: :string) } + it { expect(subject.new(a: 1)).to eq(type: :string, a: 1) } + it { expect(subject.named(:foo)).to eq(foo: { type: :string }) } + it do + expect(subject.named(:foo, bar: :baz)).to eq( + foo: { type: :string, bar: :baz } + ) + end + end +end