Skip to content

Commit

Permalink
Merge pull request #31 from Manfred/manfred-30-property-map
Browse files Browse the repository at this point in the history
Map irregular property names to valid Ruby identifiers on the model
  • Loading branch information
Manfred authored Jul 7, 2023
2 parents a84b31f + 678e0c0 commit 963ddfd
Show file tree
Hide file tree
Showing 14 changed files with 321 additions and 17 deletions.
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,72 @@ For example, in case of an array item it would look at `books` and singularize i
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.
### Properties and model attributes
Reynard provides access to JSON properties on the model in a number of ways. There are some restrictions because of Ruby, so it's good to understand them.
Let's assume there is a payload for an `Author` model that looks like this:
```json
{"first_name":"Marcél","lastName":"Marcellus","1st-class":false}
```

Reynard attemps to give access to these properties as much as possible by sanitizing and normalizing them, so you can do the following:

```ruby
response.object.first_name #=> "Marcél"
response.object.last_name #=> "Marcellus"
```

But it's also possible to use the original casing for `lastName`.

```ruby
response.object.lastName #=> "Marcellus"
```

However, a method can't start with a number and can't contain dashes in Ruby so the following is not possible:

```
# Not valid Ruby syntax:
response.object.1st-class
```

There are two alternatives for accessing this property:

```ruby
# The preferred solution for accessing raw property values is through the
# parsed JSON on the response object.
response.parsed_body["1st-class"]
# When you are processing nested models and you don't have access to the
# response object, you can chose to use the `[]` method.
response.object["1st-class"]
# Don't use `send` to access the property, this may not work in future
# versions.
response.object.send("1st-class")
```

#### Mapping properties

In case you are forced to access a property through a method, you could chose to map irregular property names to method names globally for all models:

```ruby
reynard.snake_cases({ "1st-class" => "first_class" })
```

This will allow you to access the property through the `first_class` method without changing the behavior of the rest of the object.

```ruby
response.object.first_class #=> false
response.object["1st-class"] #=> false
```

Don't use this to map common property names that would work fine otherwise, because you could make things really confusing.

```ruby
# Don't do this.
reynard.snake_cases({ "name" => "naem" })
```

## Logging

When you want to know what the Reynard client is doing you can enable logging.
Expand Down
5 changes: 4 additions & 1 deletion lib/reynard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ class Reynard
extend Forwardable
def_delegators :build_context, :logger, :base_url, :operation, :headers, :params
def_delegators :@specification, :servers
def_delegators :@inflector, :snake_cases

autoload :Context, 'reynard/context'
autoload :External, 'reynard/external'
autoload :GroupedParameters, 'reynard/grouped_parameters'
autoload :Http, 'reynard/http'
autoload :Inflector, 'reynard/inflector'
autoload :Logger, 'reynard/logger'
autoload :MediaType, 'reynard/media_type'
autoload :Model, 'reynard/model'
Expand All @@ -34,6 +36,7 @@ class Reynard

def initialize(filename:)
@specification = Specification.new(filename: filename)
@inflector = Inflector.new
end

class << self
Expand Down Expand Up @@ -61,6 +64,6 @@ def self.http
private

def build_context
Context.new(specification: @specification)
Context.new(specification: @specification, inflector: @inflector)
end
end
5 changes: 4 additions & 1 deletion lib/reynard/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ class Context
extend Forwardable
def_delegators :@request_context, :verb, :path, :full_path, :url

def initialize(specification:, request_context: nil)
def initialize(specification:, inflector:, request_context: nil)
@specification = specification
@inflector = inflector
@request_context = request_context || build_request_context
end

Expand Down Expand Up @@ -59,6 +60,7 @@ def build_request_context
def copy(**properties)
self.class.new(
specification: @specification,
inflector: @inflector,
request_context: @request_context.copy(**properties)
)
end
Expand All @@ -70,6 +72,7 @@ def build_request
def build_response(http_response)
Reynard::Http::Response.new(
specification: @specification,
inflector: @inflector,
request_context: @request_context,
http_response: http_response
)
Expand Down
4 changes: 3 additions & 1 deletion lib/reynard/http/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ class Response
extend Forwardable
def_delegators :@http_response, :code, :content_type, :[], :body

def initialize(specification:, request_context:, http_response:)
def initialize(specification:, inflector:, request_context:, http_response:)
@specification = specification
@inflector = inflector
@request_context = request_context
@http_response = http_response
end
Expand Down Expand Up @@ -71,6 +72,7 @@ def build_object
def build_object_with_media_type(media_type)
ObjectBuilder.new(
schema: @specification.schema(media_type.node),
inflector: @inflector,
parsed_body: parsed_body
).call
end
Expand Down
30 changes: 30 additions & 0 deletions lib/reynard/inflector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

class Reynard
# Transforms property names so they are value Ruby identifiers or more readable to users.
class Inflector
def initialize
@snake_case = {}
end

# Registers additional exceptions to the regular snake-case algorithm. Registering is additive
# so you can call this multiple times without losing previously registered exceptions.
def snake_cases(exceptions)
@snake_case.merge!(exceptions)
end

# Returns the string in snake-case, taking previously registered exceptions into account.
def snake_case(property)
@snake_case[property] || self.class.snake_case(property)
end

# Returns the string in snake-case.
def self.snake_case(property)
property
.to_s
.gsub(/([A-Z])(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) { (Regexp.last_match(1) || Regexp.last_match(2)) << '_' }
.tr("'\"-", '___')
.downcase
end
end
end
34 changes: 29 additions & 5 deletions lib/reynard/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,41 @@
class Reynard
# Superclass for dynamic classes generated by the object builder.
class Model
extend Forwardable
def_delegators :@attributes, :[]

class << self
# Holds references to the full schema for the model if available.
attr_accessor :schema
# The inflector to use on properties.
attr_writer :inflector
end

def initialize(attributes)
@attributes = {}
@snake_cases = self.class.snake_cases(attributes.keys)
self.attributes = attributes
end

def attributes=(attributes)
attributes.each do |name, value|
instance_variable_set("@#{name}", self.class.cast(name, value))
@attributes[name.to_s] = self.class.cast(name, value)
end
end

# Until we can set accessors based on the schema
def method_missing(attribute_name, *)
instance_variable_get("@#{attribute_name}")
rescue NameError
attribute_name = attribute_name.to_s
@attributes[attribute_name] || @attributes.fetch(@snake_cases.fetch(attribute_name))
rescue KeyError
raise NoMethodError, "undefined method `#{attribute_name}' for #{inspect}"
end

def respond_to_missing?(attribute_name, *)
instance_variable_defined?("@#{attribute_name}")
attribute_name = attribute_name.to_s
return true if @attributes.key?(attribute_name)

@snake_cases.key?(attribute_name) && @attributes.key?(@snake_cases[attribute_name])
rescue NameError
false
end
Expand All @@ -37,7 +48,20 @@ def self.cast(name, value)
property = schema.property_schema(name)
return value unless property

Reynard::ObjectBuilder.new(schema: property, parsed_body: value).call
Reynard::ObjectBuilder.new(schema: property, inflector: inflector, parsed_body: value).call
end

def self.inflector
@inflector ||= Inflector.new
end

def self.snake_cases(property_names)
property_names.each_with_object({}) do |property_name, snake_cases|
snake_case = inflector.snake_case(property_name)
next if snake_case == property_name

snake_cases[snake_case] = property_name
end
end
end
end
14 changes: 9 additions & 5 deletions lib/reynard/object_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ class Reynard
class ObjectBuilder
attr_reader :schema, :parsed_body

def initialize(schema:, parsed_body:, model_name: nil)
def initialize(schema:, inflector:, parsed_body:, model_name: nil)
@schema = schema
@inflector = inflector
@parsed_body = parsed_body
@model_name = model_name
end
Expand All @@ -21,7 +22,8 @@ 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)
self.class.model_class_get(model_name) ||
self.class.model_class_set(model_name, schema, @inflector)
end

def call
Expand All @@ -41,11 +43,11 @@ def self.model_class_get(model_name)
nil
end

def self.model_class_set(model_name, schema)
def self.model_class_set(model_name, schema, inflector)
if schema.type == 'array'
array_model_class_set(model_name)
else
object_model_class_set(model_name, schema)
object_model_class_set(model_name, schema, inflector)
end
end

Expand All @@ -55,11 +57,12 @@ def self.array_model_class_set(model_name)
::Reynard::Models.const_set(model_name, Class.new(Array))
end

def self.object_model_class_set(model_name, schema)
def self.object_model_class_set(model_name, schema, inflector)
return Reynard::Model unless model_name

model_class = Class.new(Reynard::Model)
model_class.schema = schema
model_class.inflector = inflector
::Reynard::Models.const_set(model_name, model_class)
end

Expand All @@ -73,6 +76,7 @@ def cast_array
parsed_body.each do |item|
array << self.class.new(
schema: item_schema,
inflector: @inflector,
parsed_body: item
).call
end
Expand Down
7 changes: 7 additions & 0 deletions test/files/snake_case.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
firstName: first_name
LastName: last_name
ORIGINAL: original
ignore__Experience: ignore__experience
__pragma: __pragma
first5': first5_
12RulesFOR: 12_rules_for
47 changes: 45 additions & 2 deletions test/reynard/context_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ class ContextTest < Reynard::Test
def setup
@specification = Specification.new(filename: fixture_file('openapi/simple.yml'))
@request_context = RequestContext.new(base_url: @specification.default_base_url, headers: {})
@context = Context.new(specification: @specification, request_context: @request_context)
@inflector = Inflector.new
@context = Context.new(
specification: @specification, inflector: @inflector, request_context: @request_context
)
end

test 'does not have a verb without an operation' do
Expand Down Expand Up @@ -202,7 +205,10 @@ class BareContextTest < Reynard::Test
def setup
@specification = Specification.new(filename: fixture_file('openapi/bare.yml'))
@request_context = RequestContext.new(base_url: @specification.default_base_url, headers: {})
@context = Context.new(specification: @specification, request_context: @request_context)
@inflector = Inflector.new
@context = Context.new(
specification: @specification, inflector: @inflector, request_context: @request_context
)
end

test 'returns a generic result when response is not defined' do
Expand All @@ -214,4 +220,41 @@ def setup
assert_equal 'Howdy', response.object.message
end
end

class InflectorContextTest < Reynard::Test
def setup
@specification = Specification.new(filename: fixture_file('openapi/nested.yml'))
@request_context = RequestContext.new(base_url: @specification.default_base_url, headers: {})
@inflector = Inflector.new
@inflector.snake_cases({ '1st-class' => 'first_class' })
@context = Context.new(
specification: @specification, inflector: @inflector, request_context: @request_context
)
end

test 'allows access to irregular properties through snake case methods' do
stub_request(:get, 'http://example.com/v1/library').and_return(
status: 200, body: JSON.dump(
{
'name' => '1st Library',
'books' => [
{
'name' => 'Erebus', 'author' => {
'name' => 'Palin', 'streetName' => 'Townstreet', '1st-class' => 'false'
}
}
]
}
)
)
response = @context.operation('showLibrary').execute
assert_kind_of Reynard::Model, response.object
author = response.object.books[0].author
assert_equal 'Palin', author.name
assert_equal 'Townstreet', author.street_name
assert_equal 'Townstreet', author.streetName
assert_equal 'false', author.first_class
assert_equal 'false', author['1st-class']
end
end
end
2 changes: 2 additions & 0 deletions test/reynard/http/response_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Http
class ResponseTest < Reynard::Test
def setup
@specification = Specification.new(filename: fixture_file('openapi/simple.yml'))
@inflector = Inflector.new
@request_context = RequestContext.new(
base_url: @specification.default_base_url,
headers: {},
Expand All @@ -27,6 +28,7 @@ def response(code, message)

@response = Response.new(
specification: @specification,
inflector: @inflector,
request_context: @request_context,
http_response: @http_response
)
Expand Down
Loading

0 comments on commit 963ddfd

Please sign in to comment.