diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c3d28c..d55ef2e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ on: [push] jobs: build: runs-on: ubuntu-latest - container: ruby:3.0 + container: ruby:3.1 steps: - uses: actions/checkout@v2 - name: Bundler cache diff --git a/.rubocop.yml b/.rubocop.yml index 0e6406b..9a01d6f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,6 +3,11 @@ AllCops: TargetRubyVersion: 3.1 Layout/LineLength: Enabled: 100 +Metrics/BlockLength: + Exclude: + # Test blocks are long. + - test/**/*_test.rb + - reynard.gemspec Metrics/ClassLength: Enabled: 150 Exclude: diff --git a/README.md b/README.md index f467dae..7213fc4 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,79 @@ response.code #=> '200' response.content_type #=> 'application/json' response['Content-Type'] #=> 'application/json' response.body #=> '{"name":"Sam Seven"}' +response.parsed_body #=> { "name" => "Sam Seven" } ``` +## Schema and models + +Reynard has an object builder that allows you to get a value object backed by model classes based on the resource schema. + +For example, when the schema for a response is something like this: + +```yaml +book: + type: object + properties: + name: + type: string + author: + type: object + properties: + name: + type: string +``` + +And the parsed body from the response is: + +```json +{ + "name": "Erebus", + "author": { "name": "Palin" } +} +``` + +You should be able to access it using: + +```ruby +response.object.class #=> Reynard::Models::Book +response.object.author.class #=> Reynard::Models::Author +response.object.author.name #=> 'Palin' +``` + +### Model name + +Model names are determined in order: + +1. From the `title` attribute of a schema +2. From the `$ref` pointing to the schema +3. From the path to the definition of the schema + +```yaml +application/json: + schema: + $ref: "#/components/schemas/Book" +components: + schemas: + Book: + type: object + title: LibraryBook +``` + +In this example it would use the `title` and the model name would be `LibraryBook`. Otherwise it would use `Book` from the end of the `$ref`. + +If neither of those are available it would look at the full expanded path. + +``` +books: + type: array + items: + type: object +``` + +For example, in case of an array item it would look at `books` and singularize it to `Book`. + +If you run into issues where Reynard doesn't properly build an object for a nested resource, it's probably because of a naming issue. It's advised to add a `title` property to the schema definition with a unique name in that case. + ## Logging When you want to know what the Reynard client is doing you can enable logging. diff --git a/lib/reynard/http/response.rb b/lib/reynard/http/response.rb index 9e51301..6e8e8d1 100644 --- a/lib/reynard/http/response.rb +++ b/lib/reynard/http/response.rb @@ -39,6 +39,13 @@ def server_error? code.start_with?('5') end + # Returns the parsed response body. + def parsed_body + return @parsed_body if defined?(@parsed_body) + + @parsed_body = MultiJson.load(@http_response.body) + end + # Instantiates an object based on the schema that fits the response. def object return @object if defined?(@object) @@ -62,10 +69,9 @@ def build_object end def build_object_with_media_type(media_type) - ObjectBuilder.new( - media_type:, + ::Reynard::ObjectBuilder.new( schema: @specification.schema(media_type.node), - http_response: @http_response + parsed_body: ).call end diff --git a/lib/reynard/media_type.rb b/lib/reynard/media_type.rb index 4209f33..967d43d 100644 --- a/lib/reynard/media_type.rb +++ b/lib/reynard/media_type.rb @@ -1,21 +1,12 @@ # frozen_string_literal: true class Reynard - # Holds node reference and schema name to a media type in the API specification. + # Holds node reference a media type in the API specification. class MediaType - attr_reader :node, :schema_name + attr_reader :node - def initialize(node:, schema_name:) + def initialize(node:) @node = node - @schema_name = schema_name - end - - def media_type - @node[6] - end - - def response_code - @node[4] end end end diff --git a/lib/reynard/model.rb b/lib/reynard/model.rb index 4209690..72d5a45 100644 --- a/lib/reynard/model.rb +++ b/lib/reynard/model.rb @@ -3,13 +3,18 @@ class Reynard # Superclass for dynamic classes generated by the object builder. class Model + class << self + # Holds references to the full schema for the model if available. + attr_accessor :schema + end + def initialize(attributes) self.attributes = attributes end def attributes=(attributes) attributes.each do |name, value| - instance_variable_set("@#{name}", value) + instance_variable_set("@#{name}", self.class.cast(name, value)) end end @@ -25,5 +30,14 @@ def respond_to_missing?(attribute_name, *) rescue NameError false end + + def self.cast(name, value) + return value unless schema + + property = schema.property_schema(name) + return value unless property + + Reynard::ObjectBuilder.new(schema: property, parsed_body: value).call + end end end diff --git a/lib/reynard/object_builder.rb b/lib/reynard/object_builder.rb index 63a5d14..8056ab5 100644 --- a/lib/reynard/object_builder.rb +++ b/lib/reynard/object_builder.rb @@ -5,60 +5,78 @@ class Reynard # Defines dynamic classes based on schema and instantiates them for a response. class ObjectBuilder - def initialize(media_type:, schema:, http_response:) - @media_type = media_type + attr_reader :schema, :parsed_body + + def initialize(schema:, parsed_body:, model_name: nil) @schema = schema - @http_response = http_response + @parsed_body = parsed_body + @model_name = model_name end - def object_class - if @media_type.schema_name - self.class.model_class(@media_type.schema_name, @schema.object_type) - elsif @schema.object_type == 'array' - Array - else - Reynard::Model - end + def model_name + @model_name || @schema.model_name end - def item_object_class - if @schema.item_schema_name - self.class.model_class(@schema.item_schema_name, 'object') - else - Reynard::Model - end + def model_class + return @model_class if defined?(@model_class) + + @model_class = + self.class.model_class_get(model_name) || self.class.model_class_set(model_name, schema) end def call - if @schema.object_type == 'array' - array = object_class.new - data.each { |attributes| array << item_object_class.new(attributes) } - array + case schema.type + when 'object' + model_class.new(parsed_body) + when 'array' + cast_array else - object_class.new(data) + parsed_body end end - def data - @data ||= MultiJson.load(@http_response.body) + def self.model_class_get(model_name) + Kernel.const_get("::Reynard::Models::#{model_name}") + rescue NameError + nil end - def self.model_class(name, object_type) - model_class_get(name) || model_class_set(name, object_type) + def self.model_class_set(model_name, schema) + if schema.type == 'array' + array_model_class_set(model_name) + else + object_model_class_set(model_name, schema) + end end - def self.model_class_get(name) - Kernel.const_get("::Reynard::Models::#{name}") - rescue NameError - nil + def self.array_model_class_set(model_name) + return Array unless model_name + + ::Reynard::Models.const_set(model_name, Class.new(Array)) end - def self.model_class_set(name, object_type) - if object_type == 'array' - Reynard::Models.const_set(name, Class.new(Array)) - else - Reynard::Models.const_set(name, Class.new(Reynard::Model)) + def self.object_model_class_set(model_name, schema) + return Reynard::Model unless model_name + + model_class = Class.new(Reynard::Model) + model_class.schema = schema + ::Reynard::Models.const_set(model_name, model_class) + end + + private + + def cast_array + return unless parsed_body + + item_schema = schema.item_schema + array = model_class.new + parsed_body.each do |item| + array << self.class.new( + schema: item_schema, + parsed_body: item + ).call end + array end end end diff --git a/lib/reynard/schema.rb b/lib/reynard/schema.rb index 02219f1..efa1486 100644 --- a/lib/reynard/schema.rb +++ b/lib/reynard/schema.rb @@ -1,14 +1,110 @@ # frozen_string_literal: true class Reynard - # Holds reference and object type for a schema in the API specification. + # Holds a references to a schema definition in the specification. class Schema - attr_reader :node, :object_type, :item_schema_name + attr_reader :node, :namespace - def initialize(node:, object_type:, item_schema_name:) + def initialize(specification:, node:, namespace: nil) + @specification = specification @node = node - @object_type = object_type - @item_schema_name = item_schema_name + @namespace = namespace + end + + def type + return @type if defined?(@type) + + @type = @specification.dig(*node, 'type') + end + + def model_name + return @model_name if defined?(@model_name) + + @model_name = find_model_name + end + + # Returns the schema for items when the current schema is an array. + def item_schema + return unless type == 'array' + + self.class.new( + specification: @specification, + node: [*node, 'items'], + namespace: [*namespace, model_name] + ) + end + + # Returns the schema for a propery in the schema. + def property_schema(name) + property_node = [*node, 'properties', name.to_s] + return unless @specification.dig(*property_node) + + self.class.new( + specification: @specification, + node: property_node, + namespace: [*namespace, model_name] + ) + end + + def self.title_model_name(model_name) + return unless model_name + + model_name + .gsub(/[^[:alpha:]]/, ' ') + .gsub(/\s{2,}/, ' ') + .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase } + .strip + end + + # Extracts a model name from a ref when there is a usable value. + # + # ref_model_name("#/components/schemas/Library") => "Library" + def self.ref_model_name(ref) + return unless ref + + normalize_ref_model_name(ref.split('/')&.last) + end + + def self.normalize_ref_model_name(model_name) + # 1. Unescape encoded characters to create an UTF-8 string + # 2. Remove extensions for regularly used external schema files + # 3. Replace all non-alphabetic characters with a space (not allowed in Ruby constant) + # 4. Camelcase + Rack::Utils.unescape_path(model_name) + .gsub(/(.yml|.yaml|.json)\Z/, '') + .gsub(/[^[:alpha:]]/, ' ') + .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase } + .gsub(/\A(.)/) { Regexp.last_match(1).upcase } + end + + private + + # Returns a model name based on the schema's title or $ref. + def find_model_name + title_model_name || ref_model_name || node_model_name + end + + def title_model_name + title = @specification.dig(*node, 'title') + return unless title + + self.class.title_model_name(title) + end + + def ref_model_name + parent = @specification.dig(*node[..-2]) + ref = parent.dig('schema', '$ref') || parent.dig('items', '$ref') + return unless ref + + self.class.ref_model_name(ref) + end + + def node_model_name + self.class.title_model_name(node_property_name.capitalize.gsub(/[_-]/, ' ')) + end + + def node_property_name + node.last == 'items' ? node.at(-2).chomp('s') : node.last end end end diff --git a/lib/reynard/specification.rb b/lib/reynard/specification.rb index fa54396..868e0da 100644 --- a/lib/reynard/specification.rb +++ b/lib/reynard/specification.rb @@ -66,10 +66,7 @@ def media_type(operation_node, response_code, media_type) response, media_type = media_type_response(responses, response_code, media_type) return unless response - MediaType.new( - node: [*operation_node, 'responses', response_code, 'content', media_type], - schema_name: schema_name(response) - ) + MediaType.new(node: [*operation_node, 'responses', response_code, 'content', media_type]) end def media_type_response(responses, response_code, media_type) @@ -83,14 +80,9 @@ def media_type_response(responses, response_code, media_type) end def schema(media_type_node) - schema = dig(*media_type_node, 'schema') - return unless schema - - Schema.new( - node: [*media_type_node, 'schema'], - object_type: schema['type'], - item_schema_name: item_schema_name(schema) - ) + return unless dig(*media_type_node, 'schema') + + Schema.new(specification: self, node: [*media_type_node, 'schema']) end def self.media_type_matches?(media_type, expression) @@ -100,26 +92,6 @@ def self.media_type_matches?(media_type, expression) false end - def self.normalize_model_name(name) - # 1. Unescape encoded characters to create an UTF-8 string - # 2. Remove extensions for regularly used external schema files - # 3. Replace all non-alphabetic characters with a space (not allowed in Ruby constant) - # 4. Camelcase - Rack::Utils.unescape_path(name) - .gsub(/(.yml|.yaml|.json)\Z/, '') - .gsub(/[^[:alpha:]]/, ' ') - .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase } - .gsub(/\A(.)/) { Regexp.last_match(1).upcase } - end - - def self.normalize_model_title(title) - title - .gsub(/[^[:alpha:]]/, ' ') - .gsub(/\s{2,}/, ' ') - .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase } - .strip - end - private def read @@ -156,27 +128,5 @@ def dig_into(data, cursor, path) # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/CyclomaticComplexity # rubocop:enable Metrics/MethodLength - - def schema_name(response) - extract_schema_name(response['schema']) - end - - def item_schema_name(schema) - if schema['type'] == 'array' - extract_schema_name(schema['items']) - else - extract_schema_name(schema) - end - end - - def extract_schema_name(definition) - ref = definition['$ref'] - return self.class.normalize_model_name(ref&.split('/')&.last) if ref - - title = definition['title'] - return unless title - - self.class.normalize_model_title(title) - end end end diff --git a/test/files/openapi/nested.yml b/test/files/openapi/nested.yml new file mode 100644 index 0000000..e8b347e --- /dev/null +++ b/test/files/openapi/nested.yml @@ -0,0 +1,80 @@ +openapi: "3.0.0" +info: + title: Library + version: 1.0.0 + contact: {} + description: It books! +servers: + - url: http://example.com/v1 + - url: http://staging.example.com/v1 +tags: + - name: books + description: Book operations +paths: + /library: + get: + summary: Get the entire library + description: Returns a structure that represents the entire library + operationId: showLibrary + tags: + - books + responses: + '200': + description: A library. + content: + application/json: + schema: + $ref: "#/components/schemas/Library" + default: + description: An error. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Library: + type: object + required: + - id + - name + - books + properties: + id: + type: integer + format: int64 + name: + type: string + books: + type: array + items: + $ref: "#/components/schemas/Book" + Book: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + author: + type: object + properties: + name: + type: string + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/test/integration/reynard_test.rb b/test/integration/reynard_test.rb index cee48f2..c227bcf 100644 --- a/test/integration/reynard_test.rb +++ b/test/integration/reynard_test.rb @@ -15,6 +15,7 @@ def setup end def teardown + super WebMock.disable_net_connect! end diff --git a/test/reynard/http/response_test.rb b/test/reynard/http/response_test.rb index c347af9..020b047 100644 --- a/test/reynard/http/response_test.rb +++ b/test/reynard/http/response_test.rb @@ -40,6 +40,11 @@ def response(code, message) assert_equal @body, response.body end + test 'returns the parse body' do + response = response('200', 'OK') + assert_equal({ 'id' => 12 }, response.parsed_body) + end + test 'builds a response object' do response = response('200', 'OK') assert_equal 12, response.object.id diff --git a/test/reynard/media_type_test.rb b/test/reynard/media_type_test.rb index dbda655..de98438 100644 --- a/test/reynard/media_type_test.rb +++ b/test/reynard/media_type_test.rb @@ -5,23 +5,20 @@ class Reynard class MediaTypeTest < Reynard::Test def setup - @media_type = Reynard::MediaType.new( - node: %w[ - paths - /books/{id} - get - responses - 200 - content - application/json - ], - schema_name: 'Book' - ) + @node = %w[ + paths + /books/{id} + get + responses + 200 + content + application/json + ] + @media_type = Reynard::MediaType.new(node: @node) end - test 'returns media type and response code based on the media type node' do - assert_equal 'application/json', @media_type.media_type - assert_equal '200', @media_type.response_code + test 'returns its own node' do + assert_equal @node, @media_type.node end end end diff --git a/test/reynard/model_test.rb b/test/reynard/model_test.rb index 989d2b8..cbf93b6 100644 --- a/test/reynard/model_test.rb +++ b/test/reynard/model_test.rb @@ -5,7 +5,7 @@ class Reynard class ModelTest < Reynard::Test def setup - @model = Model.new(name: 'James', age: 12) + @model = Model.new(name: 'James', age: 12, address: { zipcode: '66232A' }) end test 'responds to attributes' do @@ -28,5 +28,60 @@ def setup assert_equal 'James', @model.name assert_equal 12, @model.age end + + test 'does not build a model for nested resources' do + assert_kind_of(Hash, @model.address) + end + end + + class SchemaModelTest < Reynard::Test + def setup + @specification = Specification.new(filename: fixture_file('openapi/nested.yml')) + @node = %w[ + paths + /library + get + responses + 200 + content + application/json + schema + properties + books + items + ] + @schema = Schema.new( + specification: @specification, + node: @node + ) + @model_class = Class.new(Reynard::Model) + @model_class.schema = @schema + @model = @model_class.new(name: 'Erebus', author: { name: 'Palin' }) + end + + test 'responds to attributes' do + assert @model.respond_to?(:name, private: false) + end + + test 'does not respond to random attributes' do + refute @model.respond_to?(:unknown, private: false) + end + + test 'can never respond to an attribute that is an invalid instance variable' do + refute @model.respond_to?(:unknown?, private: false) + end + + test 'raises NoMethodError calling an attribute method that is an invalid instance variable' do + assert_raises(NoMethodError) { @model.unknown? } + end + + test 'returns correct values for attributes' do + assert_equal 'Erebus', @model.name + end + + test 'builds a model for nested resources' do + assert_kind_of(Reynard::Models::Author, @model.author) + assert_equal 'Palin', @model.author.name + end end end diff --git a/test/reynard/object_builder_test.rb b/test/reynard/object_builder_test.rb index f79846d..0b6fb71 100644 --- a/test/reynard/object_builder_test.rb +++ b/test/reynard/object_builder_test.rb @@ -4,8 +4,6 @@ class Reynard class ObjectBuilderTest < Reynard::Test - Response = Struct.new(:body, keyword_init: true) - def setup @specification = Specification.new(filename: fixture_file('openapi/simple.yml')) end @@ -14,46 +12,40 @@ def setup operation = @specification.operation('listBooks') media_type = @specification.media_type(operation.node, '200', 'application/json') schema = @specification.schema(media_type.node) - collection = Reynard::ObjectBuilder.new( - media_type:, + books = Reynard::ObjectBuilder.new( schema:, - http_response: Response.new( - body: '[{"id":42,"name":"Black Science"},{"id":51,"name":"Dead Astronauts"}]' - ) + parsed_body: [{ id: 42, name: 'Black Science' }, { id: 51, name: 'Dead Astronauts' }] ).call - assert_kind_of(Reynard::ObjectBuilder.model_class('Books', 'array'), collection) - assert_equal 2, collection.length + assert_kind_of(Array, books) + assert_equal 2, books.length - record = collection[0] - assert_kind_of(Reynard::ObjectBuilder.model_class('Book', 'array'), record) - assert_equal 42, record.id - assert_equal 'Black Science', record.name + book = books[0] + assert_model_name('Book', book) + assert_equal 42, book.id + assert_equal 'Black Science', book.name - record = collection[1] - assert_kind_of(Reynard::ObjectBuilder.model_class('Book', 'array'), record) - assert_equal 51, record.id - assert_equal 'Dead Astronauts', record.name + book = books[1] + assert_model_name('Book', book) + assert_equal 51, book.id + assert_equal 'Dead Astronauts', book.name end test 'builds a singular record' do operation = @specification.operation('fetchBook') media_type = @specification.media_type(operation.node, '200', 'application/json') schema = @specification.schema(media_type.node) - record = Reynard::ObjectBuilder.new( - media_type:, + book = Reynard::ObjectBuilder.new( schema:, - http_response: Response.new(body: '{"id":42,"name":"Black Science"}') + parsed_body: { id: 42, name: 'Black Science' } ).call - assert_kind_of(Reynard::ObjectBuilder.model_class('Book', 'object'), record) - assert_equal 42, record.id - assert_equal 'Black Science', record.name + assert_model_name('Book', book) + assert_equal 42, book.id + assert_equal 'Black Science', book.name end end class ExternalObjectBuilderTest < Reynard::Test - Response = Struct.new(:body, keyword_init: true) - def setup @specification = Specification.new(filename: fixture_file('openapi/external.yml')) end @@ -62,20 +54,17 @@ def setup operation = @specification.operation('fetchAuthor') media_type = @specification.media_type(operation.node, '200', 'application/json') schema = @specification.schema(media_type.node) - record = Reynard::ObjectBuilder.new( - media_type:, + author = Reynard::ObjectBuilder.new( schema:, - http_response: Response.new(body: '{"id":42,"name":"Jerry Writer"}') + parsed_body: { id: 42, name: 'Jerry Writer' } ).call - assert_kind_of(Reynard::ObjectBuilder.model_class('Author', 'object'), record) - assert_equal 42, record.id - assert_equal 'Jerry Writer', record.name + assert_model_name('Author', author) + assert_equal 42, author.id + assert_equal 'Jerry Writer', author.name end end class TitledObjectBuilderTest < Reynard::Test - Response = Struct.new(:body, keyword_init: true) - def setup @specification = Specification.new(filename: fixture_file('openapi/titled.yml')) end @@ -85,20 +74,22 @@ def setup media_type = @specification.media_type(operation.node, '200', 'application/json') schema = @specification.schema(media_type.node) collection = Reynard::ObjectBuilder.new( - media_type:, schema:, - http_response: Response.new( - body: '[{"isbn":"9781534307407","title":"Black Science Premiere Hardcover Volume 1 Remastered Edition (Black Science Omnibus, 1)"}]' - ) + parsed_body: [ + { + isbn: '9781534307407', + title: 'Black Science Premiere Hardcover Volume 1 Remastered Edition (Black Science Omnibus, 1)' + } + ] ).call - assert_kind_of(Array, collection) - record = collection[0] - assert_kind_of(Reynard::ObjectBuilder.model_class('ISBN', 'object'), record) - assert_equal '9781534307407', record.isbn + assert_kind_of(Array, collection) + isbn = collection[0] + assert_model_name('ISBN', isbn) + assert_equal '9781534307407', isbn.isbn assert_equal( 'Black Science Premiere Hardcover Volume 1 Remastered Edition (Black Science Omnibus, 1)', - record.title + isbn.title ) end end @@ -115,12 +106,51 @@ def setup media_type = @specification.media_type(operation.node, '200', 'application/json') schema = @specification.schema(media_type.node) record = Reynard::ObjectBuilder.new( - media_type:, schema:, - http_response: Response.new(body: '{"name":"😇"}') + parsed_body: { name: '😇' } ).call - assert_kind_of(Reynard::ObjectBuilder.model_class('AFRootWithInThe', 'object'), record) + assert_model_name('AFRootWithInThe', record) assert_equal '😇', record.name end end + + class NestedObjectBuilderTest < Reynard::Test + def setup + @specification = Specification.new(filename: fixture_file('openapi/nested.yml')) + end + + test 'builds a collection' do + parsed_body = { + 'id' => 881_234, + 'name' => 'Mainz Public Library', + 'books' => [ + { + 'id' => 42, + 'name' => 'Black Science', + 'author' => { 'name' => 'Remender' } + }, + { + 'id' => 51, + 'name' => 'Dead Astronauts', + 'author' => { 'name' => 'Borne' } + } + ] + } + operation = @specification.operation('showLibrary') + media_type = @specification.media_type(operation.node, '200', 'application/json') + schema = @specification.schema(media_type.node) + library = Reynard::ObjectBuilder.new( + schema:, + parsed_body: + ).call + assert_model_name('Library', library) + assert_kind_of(Array, library.books) + library.books.each do |book| + assert_model_name('Book', book) + assert_model_name('Author', book.author) + end + + assert_equal 'Borne', library.books[1].author.name + end + end end diff --git a/test/reynard/schema_test.rb b/test/reynard/schema_test.rb index a6fd78e..7452a76 100644 --- a/test/reynard/schema_test.rb +++ b/test/reynard/schema_test.rb @@ -4,7 +4,40 @@ class Reynard class SchemaTest < Reynard::Test + test 'formats a model name based on the title specification' do + { + 'AdministrationAgreement' => 'AdministrationAgreement', + 'Library' => 'Library', + 'ISBN' => 'ISBN', + ' A %2F root with 🚕 in the ' => 'AFRootWithInThe' + }.each do |model_name, expected| + assert_equal expected, Schema.title_model_name(model_name) + end + end + + test 'does not return a model name based on a missing title' do + assert_nil Schema.title_model_name(nil) + end + + test 'formats a model name based on a ref to a schema' do + { + '#/components/schemas/Library' => 'Library', + './schemas/author.yml' => 'Author', + '#/components/schemas/%20howdy%E2%9A%A0%EF%B8%8F.Pardner' => 'HowdyPardner', + '#/components/schemas/Service.Subscription' => 'ServiceSubscription' + }.each do |ref, expected| + assert_equal expected, Schema.ref_model_name(ref) + end + end + + test 'does not return a model name based on a missing ref' do + assert_nil Schema.ref_model_name(nil) + end + end + + class SingularTopLevelSchemaTest < Reynard::Test def setup + @specification = Specification.new(filename: fixture_file('openapi/simple.yml')) @node = %w[ paths /books/{id} @@ -16,9 +49,8 @@ def setup schema ] @schema = Schema.new( - node: @node, - object_type: 'object', - item_schema_name: nil + specification: @specification, + node: @node ) end @@ -26,17 +58,33 @@ def setup assert_equal @node, @schema.node end - test 'returns its object type' do - assert_equal 'object', @schema.object_type + test 'returns its type' do + assert_equal 'object', @schema.type + end + + test 'formats a model name and namespace' do + assert_equal 'Book', @schema.model_name + assert_nil @schema.namespace + end + + test 'does not return an item schema' do + assert_nil @schema.item_schema end - test 'does not return an item schema name' do - assert_nil @schema.item_schema_name + test 'returns a schema for its properties' do + schema = @schema.property_schema('id') + assert_equal 'integer', schema.type + assert_equal 'Id', schema.model_name + end + + test 'does not return a schema for an unknown property' do + assert_nil @schema.property_schema('unknown') end end - class CollectionSchemaTest < Reynard::Test + class PluralTopLevelSchemaTest < Reynard::Test def setup + @specification = Specification.new(filename: fixture_file('openapi/simple.yml')) @node = %w[ paths /books @@ -48,9 +96,8 @@ def setup schema ] @schema = Schema.new( - node: @node, - object_type: 'array', - item_schema_name: 'Book' + specification: @specification, + node: @node ) end @@ -58,12 +105,82 @@ def setup assert_equal @node, @schema.node end - test 'returns its object type' do - assert_equal 'array', @schema.object_type + test 'returns its type' do + assert_equal 'array', @schema.type + end + + test 'formats a model name and namespace' do + assert_equal 'Books', @schema.model_name + assert_nil @schema.namespace + end + + test 'returns its item schema' do + schema = @schema.item_schema + assert_equal 'object', schema.type + assert_equal 'Book', schema.model_name end - test 'returns its item schema name' do - assert_equal 'Book', @schema.item_schema_name + test 'does not return a schema for an unknown property' do + assert_nil @schema.property_schema('unknown') + end + end + + class NestedSchemaTest < Reynard::Test + def setup + @specification = Specification.new(filename: fixture_file('openapi/nested.yml')) + @node = %w[ + paths + /library + get + responses + 200 + content + application/json + schema + ] + @schema = Schema.new( + specification: @specification, + node: @node + ) + end + + test 'returns its node' do + assert_equal @node, @schema.node + end + + test 'returns its type' do + assert_equal 'object', @schema.type + end + + test 'formats a model name and namespace' do + assert_equal 'Library', @schema.model_name + assert_nil @schema.namespace + end + + test 'does not return an item schema' do + assert_nil @schema.item_schema + end + + test 'digs into its property schemas' do + schema = @schema.property_schema('books') + assert_equal 'array', schema.type + assert_equal 'Books', schema.model_name + assert_equal %w[Library], schema.namespace + + schema = schema.item_schema + assert_equal 'object', schema.type + assert_equal 'Book', schema.model_name + assert_equal %w[Library Books], schema.namespace + + schema = schema.property_schema('author') + assert_equal 'object', schema.type + assert_equal 'Author', schema.model_name + assert_equal %w[Library Books Book], schema.namespace + + schema = schema.property_schema('name') + assert_equal 'string', schema.type + assert_equal 'Name', schema.model_name + assert_equal %w[Library Books Book Author], schema.namespace end end end diff --git a/test/reynard/specification_test.rb b/test/reynard/specification_test.rb index d874713..5db3dcf 100644 --- a/test/reynard/specification_test.rb +++ b/test/reynard/specification_test.rb @@ -8,19 +8,6 @@ def setup @specification = Specification.new(filename: fixture_file('openapi/simple.yml')) end - test 'normalizes models names' do - examples = { - 'Author' => 'Author', - 'author.yml' => 'Author', - 'general_error.json' => 'GeneralError', - 'GeneralError' => 'GeneralError', - '%20howdy%E2%9A%A0%EF%B8%8F.Pardner' => 'HowdyPardner' - } - assert_equal( - examples.values, examples.keys.map { |example| Specification.normalize_model_name(example) } - ) - end - test 'initializes with an OpenAPI filename' do assert_equal 'Library', @specification.dig('info', 'title') end @@ -118,8 +105,6 @@ def setup operation = @specification.operation('listBooks') media_type = @specification.media_type(operation.node, '200', 'application/json') assert_equal(%w[paths /books get responses 200 content application/json], media_type.node) - assert_equal('200', media_type.response_code) - assert_equal('application/json', media_type.media_type) operation = @specification.operation('searchBooks') media_type = @specification.media_type(operation.node, '200', 'application/json') @@ -127,8 +112,6 @@ def setup %w[paths /search/books get responses 200 content application/json], media_type.node ) - assert_equal('200', media_type.response_code) - assert_equal('application/json', media_type.media_type) end test 'finds the default media type for unknown response code' do @@ -138,8 +121,6 @@ def setup %w[paths /books get responses default content application/json], media_type.node ) - assert_equal('default', media_type.response_code) - assert_equal('application/json', media_type.media_type) end test 'does not find media type for unknown media type' do @@ -155,8 +136,9 @@ def setup %w[paths /books get responses 200 content application/json schema], schema.node ) - assert_equal('array', schema.object_type) - assert_equal('Book', schema.item_schema_name) + assert_equal('array', schema.type) + assert_equal('Books', schema.model_name) + assert_equal('Book', schema.item_schema.model_name) operation = @specification.operation('fetchBook') media_type = @specification.media_type(operation.node, '200', 'application/json') @@ -165,8 +147,9 @@ def setup %w[paths /books/{id} get responses 200 content application/json schema], schema.node ) - assert_equal('object', schema.object_type) - assert_nil schema.item_schema_name + assert_equal('object', schema.type) + assert_equal('Book', schema.model_name) + assert_nil schema.item_schema end test 'uses first response when HTTP response does not have a media type' do @@ -177,8 +160,9 @@ def setup %w[paths /books get responses 200 content application/json schema], schema.node ) - assert_equal('array', schema.object_type) - assert_equal('Book', schema.item_schema_name) + assert_equal('array', schema.type) + assert_equal('Books', schema.model_name) + assert_equal('Book', schema.item_schema.model_name) operation = @specification.operation('fetchBook') media_type = @specification.media_type(operation.node, '200', 'application/json') @@ -187,8 +171,9 @@ def setup %w[paths /books/{id} get responses 200 content application/json schema], schema.node ) - assert_equal('object', schema.object_type) - assert_nil schema.item_schema_name + assert_equal('object', schema.type) + assert_equal('Book', schema.model_name) + assert_nil schema.item_schema end end diff --git a/test/support/assertions.rb b/test/support/assertions.rb new file mode 100644 index 0000000..4f97ea4 --- /dev/null +++ b/test/support/assertions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Assertions + def assert_model_name(class_name, object) + assert_equal("Reynard::Models::#{class_name}", object.class.name) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index c9d8557..a0172ba 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -22,6 +22,12 @@ def load_support class Reynard class Test < Minitest::Test + include Assertions + + def teardown + remove_constants + end + def self.test(description, &) define_method("test_#{description}", &) end @@ -31,5 +37,11 @@ def self.test(description, &) def fixture_file(path) File.join(FILES_ROOT, path) end + + def remove_constants + Reynard::Models.constants.each do |constant| + Reynard::Models.send(:remove_const, constant) + end + end end end