reynard 0.7.0 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb92e6d5c262256cca1fa813ac8bdb730ab427eeb227dec64475567fe65f4707
4
- data.tar.gz: dbe5b3b93ac2fd2e702b40f738650bbec0d7feb7ee908b49eab366c2b058723c
3
+ metadata.gz: 2942c304a1a9caaae88253e2bce40188b79bb8a5d25ccd93c8e9a04f7730e386
4
+ data.tar.gz: 12948419634f2926ae083ae364bd35f48a6b79b8f6308df95b80baafe061dce6
5
5
  SHA512:
6
- metadata.gz: 8ce40599d474dc127b6354f55f8157cccfd26f0d3fc786eb599d4da399a4658a7ce5d3f2505c579e712a38413043f03a5f788f8ac352beb55982dbac4a644134
7
- data.tar.gz: 79738aae0d48f599f0f123fd0eb9365ce6de15995b9b7208b516d7205ec1bb023d8b7d14d181f82c238fd3415ab5f77ab102fc7593cee2febdd1c915572604cf
6
+ metadata.gz: 2bcf669808d10dfff594861274b301ff77c99038ddea5e121b0ccd61336beb7089e6c20f4e566c9a29e5b813194f5b79d07c7c067c720b6b303de8c07e862944
7
+ data.tar.gz: 64f7877a9a9e44c77cb357ff36d2ecac691cfce668dc2ba2197f0b68a284e0127fab96c099ca3cb99c0390658466a4fcda3569a2e2479e643d73f95d06cf2a16
data/README.md CHANGED
@@ -48,7 +48,7 @@ reynard.base_url(base_url)
48
48
 
49
49
  ## Calling endpoints
50
50
 
51
- Assuming there is an operation called `employeeByUuid` you can it as shown below.
51
+ Assuming there is an operation with the `operationId` set to `employeeByUuid` you can perform a request as shown below. Note that `operationId` is a required property in the specs.
52
52
 
53
53
  ```ruby
54
54
  response = reynard.
@@ -57,7 +57,7 @@ response = reynard.
57
57
  execute
58
58
  ```
59
59
 
60
- When an operation requires a body, you can add it as structured data.
60
+ When an operation requires a body, you can add it as structured data. It will be converted to JSON automatically.
61
61
 
62
62
  ```ruby
63
63
  response = reynard.
@@ -66,13 +66,7 @@ response = reynard.
66
66
  execute
67
67
  ```
68
68
 
69
- In case the response matches a response in the specification it will attempt to build an object using the specified schema.
70
-
71
- ```ruby
72
- response.object.name #=> 'Sam Seven'
73
- ```
74
-
75
- The response object shared much of its interface with `Net::HTTP::Response`.
69
+ The response object shares much of its interface with `Net::HTTP::Response`.
76
70
 
77
71
  ```ruby
78
72
  response.code #=> '200'
@@ -82,6 +76,24 @@ response.body #=> '{"name":"Sam Seven"}'
82
76
  response.parsed_body #=> { "name" => "Sam Seven" }
83
77
  ```
84
78
 
79
+ You can test for groups of response codes, basically matching `1xx` through `5xx`.
80
+
81
+ ```ruby
82
+ response.informational?
83
+ response.success?
84
+ response.redirection?
85
+ response.client_error?
86
+ response.server_error?
87
+ ```
88
+
89
+ In case the response status and content-type matches a response in the specification it will attempt to build an object using the specified schema.
90
+
91
+ ```ruby
92
+ response.object.name #=> 'Sam Seven'
93
+ ```
94
+
95
+ See below for more details about the object builder.
96
+
85
97
  ## Schema and models
86
98
 
87
99
  Reynard has an object builder that allows you to get a value object backed by model classes based on the resource schema.
@@ -152,6 +164,148 @@ For example, in case of an array item it would look at `books` and singularize i
152
164
 
153
165
  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
166
 
167
+ ### Properties and model attributes
168
+
169
+ 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.
170
+
171
+ Let's assume there is a payload for an `Author` model that looks like this:
172
+
173
+ ```json
174
+ {"first_name":"Marcél","lastName":"Marcellus","1st-class":false}
175
+ ```
176
+
177
+ Reynard attemps to give access to these properties as much as possible by sanitizing and normalizing them, so you can do the following:
178
+
179
+ ```ruby
180
+ response.object.first_name #=> "Marcél"
181
+ response.object.last_name #=> "Marcellus"
182
+ ```
183
+
184
+ But it's also possible to use the original casing for `lastName`.
185
+
186
+ ```ruby
187
+ response.object.lastName #=> "Marcellus"
188
+ ```
189
+
190
+ However, a method can't start with a number and can't contain dashes in Ruby so the following is not possible:
191
+
192
+ ```
193
+ # Not valid Ruby syntax:
194
+ response.object.1st-class
195
+ ```
196
+
197
+ There are two alternatives for accessing this property:
198
+
199
+ ```ruby
200
+ # The preferred solution for accessing raw property values is through the
201
+ # parsed JSON on the response object.
202
+ response.parsed_body["1st-class"]
203
+ # When you are processing nested models and you don't have access to the
204
+ # response object, you can choose to use the `[]` method.
205
+ response.object["1st-class"]
206
+ # Don't use `send` to access the property, this may not work in future
207
+ # versions.
208
+ response.object.send("1st-class")
209
+ ```
210
+
211
+ #### Mapping properties
212
+
213
+ In case you are forced to access a property through a method, you could choose to map irregular property names to method names globally for all models:
214
+
215
+ ```ruby
216
+ reynard.snake_cases({ "1st-class" => "first_class" })
217
+ ```
218
+
219
+ This will allow you to access the property through the `first_class` method without changing the behavior of the rest of the object.
220
+
221
+ ```ruby
222
+ response.object.first_class #=> false
223
+ response.object["1st-class"] #=> false
224
+ ```
225
+
226
+ Don't use this to map common property names that would work fine otherwise, because you could make things really confusing.
227
+
228
+ ```ruby
229
+ # Don't do this.
230
+ reynard.snake_cases({ "name" => "naem" })
231
+ ```
232
+
233
+ ### Optional properties
234
+
235
+ The current version of Reynard does not read or enforce the properties defined in the schema, instead it builds the response object based on the properties returned by the service. This was done deliberately to make it easier to access a server with a newer or older schema than the one used to build the Reynard instance.
236
+
237
+ In the code that means that you may have to check if you are receiving certain attributes, you can do this in a number of ways:
238
+
239
+ ```ruby
240
+ response.object.respond_to?(:name)
241
+ response.parsed_body["name"]
242
+ response.object["name"]
243
+ ```
244
+
245
+ ### Taking control of a model
246
+
247
+ As noted earlier there is a deterministic way in which Reynard decides on a model name. This means that you can define the model name before Reynard gets to it.
248
+
249
+ The easiest way to find out how Reynard does this, is to actually perform the operation and look at the response. Let's look at an example where Reynard creates a `Library` model:
250
+
251
+ ```ruby
252
+ response.object.class #=> Reynard::Models::Library
253
+ response.parsed_body #=> {"name" => "Alexandria"}
254
+ ```
255
+
256
+ One way to ensure that the response object has the required attributes is to defined a `valid?` method on it:
257
+
258
+
259
+ ```ruby
260
+ class Reynard
261
+ module Models
262
+ class Library < Reynard::Model
263
+ def valid?
264
+ (%w[name] - @attributes.keys).empty?
265
+ end
266
+ end
267
+ end
268
+ end
269
+ ```
270
+
271
+ Next time you perform a request you can use your version of `Library`:
272
+
273
+ ```ruby
274
+ if response.object.valid?
275
+ puts "The library is valid!"
276
+ else
277
+ puts "The library is not valid :-( #{response.parsed_object.inspect}"
278
+ end
279
+ ```
280
+
281
+ Another way to do this is to override the `attributes=` method.
282
+
283
+ ```ruby
284
+ def attributes=(attributes)
285
+ super # call super or nested attributes and other features will break
286
+ raise_invalid unless valid?
287
+ end
288
+
289
+ private
290
+
291
+ def raise_invalid
292
+ return if valid?
293
+
294
+ raise(
295
+ ArgumentError,
296
+ "Library may not be initialized without all required attributes."
297
+ )
298
+ end
299
+ ```
300
+
301
+ A third way of dealing with optional attributes is to define an accessor yourself.
302
+
303
+ ```ruby
304
+ def name
305
+ @attributes.fetch("name") { "Unnnamed library" }
306
+ end
307
+ ```
308
+
155
309
  ## Logging
156
310
 
157
311
  When you want to know what the Reynard client is doing you can enable logging.
@@ -168,6 +322,19 @@ The logging should be compatible with the Ruby on Rails logger.
168
322
  reynard.logger(Rails.logger).execute
169
323
  ```
170
324
 
325
+ ## Headers
326
+
327
+ You can add request headers at any time to a Reynard context, these are additive so you can easily have global headers for all requests and specific ones for an operation.
328
+
329
+ ```ruby
330
+ reynard = reynard.headers(
331
+ {
332
+ "User-Agent" => "MyApplication/12.1.1 Reynard/#{Reynard::VERSION}",
333
+ "Accept" => "application/json"
334
+ }
335
+ )
336
+ ```
337
+
171
338
  ## Debugging
172
339
 
173
340
  You can turn on debug logging in `Net::HTTP` by setting the `DEBUG` environment variable. After setting this, all HTTP interaction will be written to STDERR.
@@ -8,8 +8,9 @@ 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
 
@@ -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,6 +72,7 @@ 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
77
  http_response: http_response
75
78
  )
@@ -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
@@ -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
@@ -71,6 +72,7 @@ class Reynard
71
72
  def build_object_with_media_type(media_type)
72
73
  ObjectBuilder.new(
73
74
  schema: @specification.schema(media_type.node),
75
+ inflector: @inflector,
74
76
  parsed_body: parsed_body
75
77
  ).call
76
78
  end
@@ -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,45 @@
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
+ if @attributes.key?(attribute_name)
32
+ @attributes[attribute_name]
33
+ else
34
+ @attributes.fetch(@snake_cases.fetch(attribute_name))
35
+ end
36
+ rescue KeyError
25
37
  raise NoMethodError, "undefined method `#{attribute_name}' for #{inspect}"
26
38
  end
27
39
 
28
40
  def respond_to_missing?(attribute_name, *)
29
- instance_variable_defined?("@#{attribute_name}")
41
+ attribute_name = attribute_name.to_s
42
+ return true if @attributes.key?(attribute_name)
43
+
44
+ @snake_cases.key?(attribute_name) && @attributes.key?(@snake_cases[attribute_name])
30
45
  rescue NameError
31
46
  false
32
47
  end
@@ -37,7 +52,20 @@ class Reynard
37
52
  property = schema.property_schema(name)
38
53
  return value unless property
39
54
 
40
- Reynard::ObjectBuilder.new(schema: property, parsed_body: value).call
55
+ Reynard::ObjectBuilder.new(schema: property, inflector: inflector, parsed_body: value).call
56
+ end
57
+
58
+ def self.inflector
59
+ @inflector ||= Inflector.new
60
+ end
61
+
62
+ def self.snake_cases(property_names)
63
+ property_names.each_with_object({}) do |property_name, snake_cases|
64
+ snake_case = inflector.snake_case(property_name)
65
+ next if snake_case == property_name
66
+
67
+ snake_cases[snake_case] = property_name
68
+ end
41
69
  end
42
70
  end
43
71
  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.7.0'
4
+ VERSION = '0.8.1'
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'
@@ -34,6 +36,7 @@ class Reynard
34
36
 
35
37
  def initialize(filename:)
36
38
  @specification = Specification.new(filename: filename)
39
+ @inflector = Inflector.new
37
40
  end
38
41
 
39
42
  class << self
@@ -61,6 +64,6 @@ class Reynard
61
64
  private
62
65
 
63
66
  def build_context
64
- Context.new(specification: @specification)
67
+ Context.new(specification: @specification, inflector: @inflector)
65
68
  end
66
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.7.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manfred Stienstra
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-03-28 00:00:00.000000000 Z
11
+ date: 2023-07-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: multi_json
@@ -70,6 +70,7 @@ files:
70
70
  - lib/reynard/http.rb
71
71
  - lib/reynard/http/request.rb
72
72
  - lib/reynard/http/response.rb
73
+ - lib/reynard/inflector.rb
73
74
  - lib/reynard/media_type.rb
74
75
  - lib/reynard/model.rb
75
76
  - lib/reynard/models.rb