reynard 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 149366d0bcc24959907a263c6223a2bb75cc7004a06cf153d1ef2dc35e26b91d
4
- data.tar.gz: 8f698ffa4c958564a902d1039807324c9b1182e0a6638dedb16fc6eb3b6eb00f
3
+ metadata.gz: 96421332b89c0816b77704aba7b07f267424c0a238fd5b74c8b91aa9d3943c6d
4
+ data.tar.gz: a6a714f984bd420fe2254b747972ba804968cc4df6ca534b38c50c1cf2b2687b
5
5
  SHA512:
6
- metadata.gz: 5de35106aeb06b3478d807d34f5da335684530f6f4f28c789fe52d8a0aafd7f13dab27154ac39cfdc354c4e65bd6da755ea16d669603eae280c027f12d54d152
7
- data.tar.gz: ff64d0913efc4e8a2b7c5b8420f94cefcf13567633906d48459ce8a653f5a65f8bd9d8c24368c68a8445320012561a24ff42568065232703675af4944b59f301
6
+ metadata.gz: 7cbb02d762e3c38b05b59ec25f8320f3a5830b56aabe376249f2347a098b43cff31935829d9dd831edc383a62531a06b78e735d886bf4f2329475e8785820211
7
+ data.tar.gz: 767b9ba9f0f5eb3fae98d414f778e481fd6b3c0a928128cec29b3a409db44481057afc44c9ac029a98892b71c3d3e09fd80286e0b2e942e47aef97b2921fab22
data/README.md CHANGED
@@ -152,6 +152,72 @@ For example, in case of an array item it would look at `books` and singularize i
152
152
 
153
153
  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.
154
154
 
155
+ ### Properties and model attributes
156
+
157
+ 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.
158
+
159
+ Let's assume there is a payload for an `Author` model that looks like this:
160
+
161
+ ```json
162
+ {"first_name":"Marcél","lastName":"Marcellus","1st-class":false}
163
+ ```
164
+
165
+ Reynard attemps to give access to these properties as much as possible by sanitizing and normalizing them, so you can do the following:
166
+
167
+ ```ruby
168
+ response.object.first_name #=> "Marcél"
169
+ response.object.last_name #=> "Marcellus"
170
+ ```
171
+
172
+ But it's also possible to use the original casing for `lastName`.
173
+
174
+ ```ruby
175
+ response.object.lastName #=> "Marcellus"
176
+ ```
177
+
178
+ However, a method can't start with a number and can't contain dashes in Ruby so the following is not possible:
179
+
180
+ ```
181
+ # Not valid Ruby syntax:
182
+ response.object.1st-class
183
+ ```
184
+
185
+ There are two alternatives for accessing this property:
186
+
187
+ ```ruby
188
+ # The preferred solution for accessing raw property values is through the
189
+ # parsed JSON on the response object.
190
+ response.parsed_body["1st-class"]
191
+ # When you are processing nested models and you don't have access to the
192
+ # response object, you can chose to use the `[]` method.
193
+ response.object["1st-class"]
194
+ # Don't use `send` to access the property, this may not work in future
195
+ # versions.
196
+ response.object.send("1st-class")
197
+ ```
198
+
199
+ #### Mapping properties
200
+
201
+ 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:
202
+
203
+ ```ruby
204
+ reynard.snake_cases({ "1st-class" => "first_class" })
205
+ ```
206
+
207
+ This will allow you to access the property through the `first_class` method without changing the behavior of the rest of the object.
208
+
209
+ ```ruby
210
+ response.object.first_class #=> false
211
+ response.object["1st-class"] #=> false
212
+ ```
213
+
214
+ Don't use this to map common property names that would work fine otherwise, because you could make things really confusing.
215
+
216
+ ```ruby
217
+ # Don't do this.
218
+ reynard.snake_cases({ "name" => "naem" })
219
+ ```
220
+
155
221
  ## Logging
156
222
 
157
223
  When you want to know what the Reynard client is doing you can enable logging.
@@ -8,13 +8,14 @@ class Reynard
8
8
  extend Forwardable
9
9
  def_delegators :@request_context, :verb, :path, :full_path, :url
10
10
 
11
- def initialize(specification:, request_context: nil)
11
+ def initialize(specification:, inflector:, request_context: nil)
12
12
  @specification = specification
13
+ @inflector = inflector
13
14
  @request_context = request_context || build_request_context
14
15
  end
15
16
 
16
17
  def base_url(base_url)
17
- copy(base_url:)
18
+ copy(base_url: base_url)
18
19
  end
19
20
 
20
21
  def operation(operation_name)
@@ -43,7 +44,7 @@ class Reynard
43
44
  end
44
45
 
45
46
  def logger(logger)
46
- copy(logger:)
47
+ copy(logger: logger)
47
48
  end
48
49
 
49
50
  def execute
@@ -59,6 +60,7 @@ class Reynard
59
60
  def copy(**properties)
60
61
  self.class.new(
61
62
  specification: @specification,
63
+ inflector: @inflector,
62
64
  request_context: @request_context.copy(**properties)
63
65
  )
64
66
  end
@@ -70,8 +72,9 @@ class Reynard
70
72
  def build_response(http_response)
71
73
  Reynard::Http::Response.new(
72
74
  specification: @specification,
75
+ inflector: @inflector,
73
76
  request_context: @request_context,
74
- http_response:
77
+ http_response: http_response
75
78
  )
76
79
  end
77
80
  end
@@ -8,6 +8,7 @@ class Reynard
8
8
  def initialize(path:, ref:)
9
9
  @path = path
10
10
  @relative_path, @anchor = ref.split('#', 2)
11
+ @filename = File.expand_path(@relative_path, @path)
11
12
  end
12
13
 
13
14
  def path
@@ -22,11 +23,14 @@ class Reynard
22
23
  end
23
24
  end
24
25
 
26
+ def filesystem_path
27
+ File.dirname(@filename)
28
+ end
29
+
25
30
  private
26
31
 
27
32
  def filename
28
- filename = File.expand_path(@relative_path, @path)
29
- return filename if filename.start_with?(@path)
33
+ return @filename if @filename.start_with?(@path)
30
34
 
31
35
  raise 'You are only allowed to reference files relative to the specification file.'
32
36
  end
@@ -25,8 +25,12 @@ class Reynard
25
25
  Net::HTTP.const_get(@request_context.verb.capitalize)
26
26
  end
27
27
 
28
+ def request_headers
29
+ { 'User-Agent' => Reynard.user_agent }.merge(@request_context.headers || {})
30
+ end
31
+
28
32
  def build_request
29
- request = request_class.new(uri, @request_context.headers)
33
+ request = request_class.new(uri, request_headers)
30
34
  if @request_context.body
31
35
  @request_context.logger&.debug { @request_context.body }
32
36
  request.body = @request_context.body
@@ -8,8 +8,9 @@ class Reynard
8
8
  extend Forwardable
9
9
  def_delegators :@http_response, :code, :content_type, :[], :body
10
10
 
11
- def initialize(specification:, request_context:, http_response:)
11
+ def initialize(specification:, inflector:, request_context:, http_response:)
12
12
  @specification = specification
13
+ @inflector = inflector
13
14
  @request_context = request_context
14
15
  @http_response = http_response
15
16
  end
@@ -69,9 +70,10 @@ class Reynard
69
70
  end
70
71
 
71
72
  def build_object_with_media_type(media_type)
72
- ::Reynard::ObjectBuilder.new(
73
+ ObjectBuilder.new(
73
74
  schema: @specification.schema(media_type.node),
74
- parsed_body:
75
+ inflector: @inflector,
76
+ parsed_body: parsed_body
75
77
  ).call
76
78
  end
77
79
 
data/lib/reynard/http.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Reynard
4
+ # Contains classes that wrap Net::HTTP to provide a slightly more convenient interface.
4
5
  class Http
5
6
  autoload :Request, 'reynard/http/request'
6
7
  autoload :Response, 'reynard/http/response'
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Transforms property names so they are value Ruby identifiers or more readable to users.
5
+ class Inflector
6
+ def initialize
7
+ @snake_case = {}
8
+ end
9
+
10
+ # Registers additional exceptions to the regular snake-case algorithm. Registering is additive
11
+ # so you can call this multiple times without losing previously registered exceptions.
12
+ def snake_cases(exceptions)
13
+ @snake_case.merge!(exceptions)
14
+ end
15
+
16
+ # Returns the string in snake-case, taking previously registered exceptions into account.
17
+ def snake_case(property)
18
+ @snake_case[property] || self.class.snake_case(property)
19
+ end
20
+
21
+ # Returns the string in snake-case.
22
+ def self.snake_case(property)
23
+ property
24
+ .to_s
25
+ .gsub(/([A-Z])(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) { (Regexp.last_match(1) || Regexp.last_match(2)) << '_' }
26
+ .tr("'\"-", '___')
27
+ .downcase
28
+ end
29
+ end
30
+ end
data/lib/reynard/model.rb CHANGED
@@ -3,30 +3,41 @@
3
3
  class Reynard
4
4
  # Superclass for dynamic classes generated by the object builder.
5
5
  class Model
6
+ extend Forwardable
7
+ def_delegators :@attributes, :[]
8
+
6
9
  class << self
7
10
  # Holds references to the full schema for the model if available.
8
11
  attr_accessor :schema
12
+ # The inflector to use on properties.
13
+ attr_writer :inflector
9
14
  end
10
15
 
11
16
  def initialize(attributes)
17
+ @attributes = {}
18
+ @snake_cases = self.class.snake_cases(attributes.keys)
12
19
  self.attributes = attributes
13
20
  end
14
21
 
15
22
  def attributes=(attributes)
16
23
  attributes.each do |name, value|
17
- instance_variable_set("@#{name}", self.class.cast(name, value))
24
+ @attributes[name.to_s] = self.class.cast(name, value)
18
25
  end
19
26
  end
20
27
 
21
28
  # Until we can set accessors based on the schema
22
29
  def method_missing(attribute_name, *)
23
- instance_variable_get("@#{attribute_name}")
24
- rescue NameError
30
+ attribute_name = attribute_name.to_s
31
+ @attributes[attribute_name] || @attributes.fetch(@snake_cases.fetch(attribute_name))
32
+ rescue KeyError
25
33
  raise NoMethodError, "undefined method `#{attribute_name}' for #{inspect}"
26
34
  end
27
35
 
28
36
  def respond_to_missing?(attribute_name, *)
29
- instance_variable_defined?("@#{attribute_name}")
37
+ attribute_name = attribute_name.to_s
38
+ return true if @attributes.key?(attribute_name)
39
+
40
+ @snake_cases.key?(attribute_name) && @attributes.key?(@snake_cases[attribute_name])
30
41
  rescue NameError
31
42
  false
32
43
  end
@@ -37,7 +48,20 @@ class Reynard
37
48
  property = schema.property_schema(name)
38
49
  return value unless property
39
50
 
40
- Reynard::ObjectBuilder.new(schema: property, parsed_body: value).call
51
+ Reynard::ObjectBuilder.new(schema: property, inflector: inflector, parsed_body: value).call
52
+ end
53
+
54
+ def self.inflector
55
+ @inflector ||= Inflector.new
56
+ end
57
+
58
+ def self.snake_cases(property_names)
59
+ property_names.each_with_object({}) do |property_name, snake_cases|
60
+ snake_case = inflector.snake_case(property_name)
61
+ next if snake_case == property_name
62
+
63
+ snake_cases[snake_case] = property_name
64
+ end
41
65
  end
42
66
  end
43
67
  end
@@ -7,8 +7,9 @@ class Reynard
7
7
  class ObjectBuilder
8
8
  attr_reader :schema, :parsed_body
9
9
 
10
- def initialize(schema:, parsed_body:, model_name: nil)
10
+ def initialize(schema:, inflector:, parsed_body:, model_name: nil)
11
11
  @schema = schema
12
+ @inflector = inflector
12
13
  @parsed_body = parsed_body
13
14
  @model_name = model_name
14
15
  end
@@ -21,7 +22,8 @@ class Reynard
21
22
  return @model_class if defined?(@model_class)
22
23
 
23
24
  @model_class =
24
- self.class.model_class_get(model_name) || self.class.model_class_set(model_name, schema)
25
+ self.class.model_class_get(model_name) ||
26
+ self.class.model_class_set(model_name, schema, @inflector)
25
27
  end
26
28
 
27
29
  def call
@@ -41,11 +43,11 @@ class Reynard
41
43
  nil
42
44
  end
43
45
 
44
- def self.model_class_set(model_name, schema)
46
+ def self.model_class_set(model_name, schema, inflector)
45
47
  if schema.type == 'array'
46
48
  array_model_class_set(model_name)
47
49
  else
48
- object_model_class_set(model_name, schema)
50
+ object_model_class_set(model_name, schema, inflector)
49
51
  end
50
52
  end
51
53
 
@@ -55,11 +57,12 @@ class Reynard
55
57
  ::Reynard::Models.const_set(model_name, Class.new(Array))
56
58
  end
57
59
 
58
- def self.object_model_class_set(model_name, schema)
60
+ def self.object_model_class_set(model_name, schema, inflector)
59
61
  return Reynard::Model unless model_name
60
62
 
61
63
  model_class = Class.new(Reynard::Model)
62
64
  model_class.schema = schema
65
+ model_class.inflector = inflector
63
66
  ::Reynard::Models.const_set(model_name, model_class)
64
67
  end
65
68
 
@@ -73,6 +76,7 @@ class Reynard
73
76
  parsed_body.each do |item|
74
77
  array << self.class.new(
75
78
  schema: item_schema,
79
+ inflector: @inflector,
76
80
  parsed_body: item
77
81
  ).call
78
82
  end
@@ -14,7 +14,7 @@ class Reynard
14
14
 
15
15
  # Digs a value out of the specification, taking $ref into account.
16
16
  def dig(*path)
17
- dig_into(@data, @data, path.dup)
17
+ dig_into(@data, @data, path.dup, File.dirname(@filename))
18
18
  end
19
19
 
20
20
  def servers
@@ -103,7 +103,7 @@ class Reynard
103
103
  # rubocop:disable Metrics/AbcSize
104
104
  # rubocop:disable Metrics/CyclomaticComplexity
105
105
  # rubocop:disable Metrics/MethodLength
106
- def dig_into(data, cursor, path)
106
+ def dig_into(data, cursor, path, filesystem_path)
107
107
  while path.length.positive?
108
108
  cursor = cursor[path.first]
109
109
  return unless cursor
@@ -118,7 +118,8 @@ class Reynard
118
118
  cursor = data
119
119
  # References another file, with an optional anchor to an element in the data.
120
120
  when %r{\A\./}
121
- external = External.new(path: File.dirname(@filename), ref: cursor['$ref'])
121
+ external = External.new(path: filesystem_path, ref: cursor['$ref'])
122
+ filesystem_path = external.filesystem_path
122
123
  path = external.path + path
123
124
  cursor = external.data
124
125
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Reynard
4
- VERSION = '0.6.0'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/reynard.rb CHANGED
@@ -13,11 +13,13 @@ class Reynard
13
13
  extend Forwardable
14
14
  def_delegators :build_context, :logger, :base_url, :operation, :headers, :params
15
15
  def_delegators :@specification, :servers
16
+ def_delegators :@inflector, :snake_cases
16
17
 
17
18
  autoload :Context, 'reynard/context'
18
19
  autoload :External, 'reynard/external'
19
20
  autoload :GroupedParameters, 'reynard/grouped_parameters'
20
21
  autoload :Http, 'reynard/http'
22
+ autoload :Inflector, 'reynard/inflector'
21
23
  autoload :Logger, 'reynard/logger'
22
24
  autoload :MediaType, 'reynard/media_type'
23
25
  autoload :Model, 'reynard/model'
@@ -33,7 +35,8 @@ class Reynard
33
35
  autoload :VERSION, 'reynard/version'
34
36
 
35
37
  def initialize(filename:)
36
- @specification = Specification.new(filename:)
38
+ @specification = Specification.new(filename: filename)
39
+ @inflector = Inflector.new
37
40
  end
38
41
 
39
42
  class << self
@@ -42,6 +45,12 @@ class Reynard
42
45
  attr_writer :http
43
46
  end
44
47
 
48
+ # Returns a value that will be used by default for Reynard's User-Agent headers. Please use
49
+ # the +headers+ setter on the context if you want to change this.
50
+ def self.user_agent
51
+ "Reynard/#{Reynard::VERSION}"
52
+ end
53
+
45
54
  # Returns Reynard's global request interface. This is a global object to allow persistent
46
55
  # connections, caching, and other features that need a persistent object in the process.
47
56
  def self.http
@@ -55,6 +64,6 @@ class Reynard
55
64
  private
56
65
 
57
66
  def build_context
58
- Context.new(specification: @specification)
67
+ Context.new(specification: @specification, inflector: @inflector)
59
68
  end
60
69
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reynard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manfred Stienstra
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-11-24 00:00:00.000000000 Z
11
+ date: 2023-07-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: multi_json
@@ -52,62 +52,6 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
- - !ruby/object:Gem::Dependency
56
- name: minitest
57
- requirement: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - ">="
60
- - !ruby/object:Gem::Version
61
- version: '0'
62
- type: :development
63
- prerelease: false
64
- version_requirements: !ruby/object:Gem::Requirement
65
- requirements:
66
- - - ">="
67
- - !ruby/object:Gem::Version
68
- version: '0'
69
- - !ruby/object:Gem::Dependency
70
- name: rake
71
- requirement: !ruby/object:Gem::Requirement
72
- requirements:
73
- - - ">="
74
- - !ruby/object:Gem::Version
75
- version: '0'
76
- type: :development
77
- prerelease: false
78
- version_requirements: !ruby/object:Gem::Requirement
79
- requirements:
80
- - - ">="
81
- - !ruby/object:Gem::Version
82
- version: '0'
83
- - !ruby/object:Gem::Dependency
84
- name: webmock
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - ">="
88
- - !ruby/object:Gem::Version
89
- version: '0'
90
- type: :development
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - ">="
95
- - !ruby/object:Gem::Version
96
- version: '0'
97
- - !ruby/object:Gem::Dependency
98
- name: webrick
99
- requirement: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - ">="
102
- - !ruby/object:Gem::Version
103
- version: '0'
104
- type: :development
105
- prerelease: false
106
- version_requirements: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - ">="
109
- - !ruby/object:Gem::Version
110
- version: '0'
111
55
  description: |2
112
56
  Reynard is an OpenAPI client for Ruby. It operates directly on the OpenAPI specification without
113
57
  the need to generate any source code.
@@ -126,6 +70,7 @@ files:
126
70
  - lib/reynard/http.rb
127
71
  - lib/reynard/http/request.rb
128
72
  - lib/reynard/http/response.rb
73
+ - lib/reynard/inflector.rb
129
74
  - lib/reynard/media_type.rb
130
75
  - lib/reynard/model.rb
131
76
  - lib/reynard/models.rb
@@ -151,7 +96,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
151
96
  requirements:
152
97
  - - ">"
153
98
  - !ruby/object:Gem::Version
154
- version: '3.1'
99
+ version: '3.0'
155
100
  required_rubygems_version: !ruby/object:Gem::Requirement
156
101
  requirements:
157
102
  - - ">="