Skip to content

Commit

Permalink
Merge pull request #25 from Manfred/manfred-16-nested-resources
Browse files Browse the repository at this point in the history
Build nested resources based on the schema
  • Loading branch information
Manfred authored Nov 24, 2022
2 parents 8f74a26 + 5ed32d5 commit 7a0eb4c
Show file tree
Hide file tree
Showing 19 changed files with 654 additions and 214 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 9 additions & 3 deletions lib/reynard/http/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down
15 changes: 3 additions & 12 deletions lib/reynard/media_type.rb
Original file line number Diff line number Diff line change
@@ -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
16 changes: 15 additions & 1 deletion lib/reynard/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
88 changes: 53 additions & 35 deletions lib/reynard/object_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit 7a0eb4c

Please sign in to comment.