reynard 0.6.0 → 0.8.0

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: 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
  - - ">="