reynard 0.5.1 → 0.6.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: 891b142e2ca908078c960db4b36040b407974da43395d02b7ee3f768a1c758b4
4
- data.tar.gz: 5deefbad34c0364cfd3c3a04910b882499c3b96d7c4d642fdb10fbdcc3e9898f
3
+ metadata.gz: 149366d0bcc24959907a263c6223a2bb75cc7004a06cf153d1ef2dc35e26b91d
4
+ data.tar.gz: 8f698ffa4c958564a902d1039807324c9b1182e0a6638dedb16fc6eb3b6eb00f
5
5
  SHA512:
6
- metadata.gz: 393b2cab7867c45c53b761f42b03e609e653600981aa1283cf3f7c0213379adb9a1eeb1530b3a0f826e07d1ceb5c3fab0d8afc114a2001ec773f71d2484bc109
7
- data.tar.gz: c14ebcc55481c202c0c3c7b7dd36e68eb5b72f0297c0339867e6c298d4a3605284dff2e041d33b074c8c9e3b8064f450fdf481fa686e56c1e764af59a1050593
6
+ metadata.gz: 5de35106aeb06b3478d807d34f5da335684530f6f4f28c789fe52d8a0aafd7f13dab27154ac39cfdc354c4e65bd6da755ea16d669603eae280c027f12d54d152
7
+ data.tar.gz: ff64d0913efc4e8a2b7c5b8420f94cefcf13567633906d48459ce8a653f5a65f8bd9d8c24368c68a8445320012561a24ff42568065232703675af4944b59f301
data/README.md CHANGED
@@ -79,8 +79,79 @@ response.code #=> '200'
79
79
  response.content_type #=> 'application/json'
80
80
  response['Content-Type'] #=> 'application/json'
81
81
  response.body #=> '{"name":"Sam Seven"}'
82
+ response.parsed_body #=> { "name" => "Sam Seven" }
82
83
  ```
83
84
 
85
+ ## Schema and models
86
+
87
+ Reynard has an object builder that allows you to get a value object backed by model classes based on the resource schema.
88
+
89
+ For example, when the schema for a response is something like this:
90
+
91
+ ```yaml
92
+ book:
93
+ type: object
94
+ properties:
95
+ name:
96
+ type: string
97
+ author:
98
+ type: object
99
+ properties:
100
+ name:
101
+ type: string
102
+ ```
103
+
104
+ And the parsed body from the response is:
105
+
106
+ ```json
107
+ {
108
+ "name": "Erebus",
109
+ "author": { "name": "Palin" }
110
+ }
111
+ ```
112
+
113
+ You should be able to access it using:
114
+
115
+ ```ruby
116
+ response.object.class #=> Reynard::Models::Book
117
+ response.object.author.class #=> Reynard::Models::Author
118
+ response.object.author.name #=> 'Palin'
119
+ ```
120
+
121
+ ### Model name
122
+
123
+ Model names are determined in order:
124
+
125
+ 1. From the `title` attribute of a schema
126
+ 2. From the `$ref` pointing to the schema
127
+ 3. From the path to the definition of the schema
128
+
129
+ ```yaml
130
+ application/json:
131
+ schema:
132
+ $ref: "#/components/schemas/Book"
133
+ components:
134
+ schemas:
135
+ Book:
136
+ type: object
137
+ title: LibraryBook
138
+ ```
139
+
140
+ 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`.
141
+
142
+ If neither of those are available it would look at the full expanded path.
143
+
144
+ ```
145
+ books:
146
+ type: array
147
+ items:
148
+ type: object
149
+ ```
150
+
151
+ For example, in case of an array item it would look at `books` and singularize it to `Book`.
152
+
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
+
84
155
  ## Logging
85
156
 
86
157
  When you want to know what the Reynard client is doing you can enable logging.
@@ -97,6 +168,16 @@ The logging should be compatible with the Ruby on Rails logger.
97
168
  reynard.logger(Rails.logger).execute
98
169
  ```
99
170
 
171
+ ## Debugging
172
+
173
+ 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.
174
+
175
+ ```sh
176
+ env DEBUG=true ruby script.rb
177
+ ```
178
+
179
+ Internally this will set `http.debug_output = $stderr` on the HTTP object in the client.
180
+
100
181
  ## Mocking
101
182
 
102
183
  You can mock Reynard requests by changing the HTTP implementation. The class **must** implement a single `request` method that accepts an URI and net/http request object. It **must** return a net/http response object or an object with the exact same interface.
@@ -14,7 +14,7 @@ class Reynard
14
14
  end
15
15
 
16
16
  def base_url(base_url)
17
- copy(base_url: base_url)
17
+ copy(base_url:)
18
18
  end
19
19
 
20
20
  def operation(operation_name)
@@ -43,7 +43,7 @@ class Reynard
43
43
  end
44
44
 
45
45
  def logger(logger)
46
- copy(logger: logger)
46
+ copy(logger:)
47
47
  end
48
48
 
49
49
  def execute
@@ -71,7 +71,7 @@ class Reynard
71
71
  Reynard::Http::Response.new(
72
72
  specification: @specification,
73
73
  request_context: @request_context,
74
- http_response: http_response
74
+ http_response:
75
75
  )
76
76
  end
77
77
  end
@@ -14,6 +14,38 @@ class Reynard
14
14
  @http_response = http_response
15
15
  end
16
16
 
17
+ # True when the response code is in the 1xx range.
18
+ def informational?
19
+ code.start_with?('1')
20
+ end
21
+
22
+ # True when the response code is in the 2xx range.
23
+ def success?
24
+ code.start_with?('2')
25
+ end
26
+
27
+ # True when the response code is in the 3xx range.
28
+ def redirection?
29
+ code.start_with?('3')
30
+ end
31
+
32
+ # True when the response code is in the 4xx range.
33
+ def client_error?
34
+ code.start_with?('4')
35
+ end
36
+
37
+ # True when the response code is in the 5xx range.
38
+ def server_error?
39
+ code.start_with?('5')
40
+ end
41
+
42
+ # Returns the parsed response body.
43
+ def parsed_body
44
+ return @parsed_body if defined?(@parsed_body)
45
+
46
+ @parsed_body = MultiJson.load(@http_response.body)
47
+ end
48
+
17
49
  # Instantiates an object based on the schema that fits the response.
18
50
  def object
19
51
  return @object if defined?(@object)
@@ -37,10 +69,9 @@ class Reynard
37
69
  end
38
70
 
39
71
  def build_object_with_media_type(media_type)
40
- ObjectBuilder.new(
41
- media_type: media_type,
72
+ ::Reynard::ObjectBuilder.new(
42
73
  schema: @specification.schema(media_type.node),
43
- http_response: @http_response
74
+ parsed_body:
44
75
  ).call
45
76
  end
46
77
 
@@ -1,21 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Reynard
4
- # Holds node reference and schema name to a media type in the API specification.
4
+ # Holds node reference a media type in the API specification.
5
5
  class MediaType
6
- attr_reader :node, :schema_name
6
+ attr_reader :node
7
7
 
8
- def initialize(node:, schema_name:)
8
+ def initialize(node:)
9
9
  @node = node
10
- @schema_name = schema_name
11
- end
12
-
13
- def media_type
14
- @node[6]
15
- end
16
-
17
- def response_code
18
- @node[4]
19
10
  end
20
11
  end
21
12
  end
data/lib/reynard/model.rb CHANGED
@@ -3,13 +3,18 @@
3
3
  class Reynard
4
4
  # Superclass for dynamic classes generated by the object builder.
5
5
  class Model
6
+ class << self
7
+ # Holds references to the full schema for the model if available.
8
+ attr_accessor :schema
9
+ end
10
+
6
11
  def initialize(attributes)
7
12
  self.attributes = attributes
8
13
  end
9
14
 
10
15
  def attributes=(attributes)
11
16
  attributes.each do |name, value|
12
- instance_variable_set("@#{name}", value)
17
+ instance_variable_set("@#{name}", self.class.cast(name, value))
13
18
  end
14
19
  end
15
20
 
@@ -21,9 +26,18 @@ class Reynard
21
26
  end
22
27
 
23
28
  def respond_to_missing?(attribute_name, *)
24
- !instance_variable_get("@#{attribute_name}").nil?
29
+ instance_variable_defined?("@#{attribute_name}")
25
30
  rescue NameError
26
31
  false
27
32
  end
33
+
34
+ def self.cast(name, value)
35
+ return value unless schema
36
+
37
+ property = schema.property_schema(name)
38
+ return value unless property
39
+
40
+ Reynard::ObjectBuilder.new(schema: property, parsed_body: value).call
41
+ end
28
42
  end
29
43
  end
@@ -5,60 +5,78 @@ require 'ostruct'
5
5
  class Reynard
6
6
  # Defines dynamic classes based on schema and instantiates them for a response.
7
7
  class ObjectBuilder
8
- def initialize(media_type:, schema:, http_response:)
9
- @media_type = media_type
8
+ attr_reader :schema, :parsed_body
9
+
10
+ def initialize(schema:, parsed_body:, model_name: nil)
10
11
  @schema = schema
11
- @http_response = http_response
12
+ @parsed_body = parsed_body
13
+ @model_name = model_name
12
14
  end
13
15
 
14
- def object_class
15
- if @media_type.schema_name
16
- self.class.model_class(@media_type.schema_name, @schema.object_type)
17
- elsif @schema.object_type == 'array'
18
- Array
19
- else
20
- Reynard::Model
21
- end
16
+ def model_name
17
+ @model_name || @schema.model_name
22
18
  end
23
19
 
24
- def item_object_class
25
- if @schema.item_schema_name
26
- self.class.model_class(@schema.item_schema_name, 'object')
27
- else
28
- Reynard::Model
29
- end
20
+ def model_class
21
+ return @model_class if defined?(@model_class)
22
+
23
+ @model_class =
24
+ self.class.model_class_get(model_name) || self.class.model_class_set(model_name, schema)
30
25
  end
31
26
 
32
27
  def call
33
- if @schema.object_type == 'array'
34
- array = object_class.new
35
- data.each { |attributes| array << item_object_class.new(attributes) }
36
- array
28
+ case schema.type
29
+ when 'object'
30
+ model_class.new(parsed_body)
31
+ when 'array'
32
+ cast_array
37
33
  else
38
- object_class.new(data)
34
+ parsed_body
39
35
  end
40
36
  end
41
37
 
42
- def data
43
- @data ||= MultiJson.load(@http_response.body)
38
+ def self.model_class_get(model_name)
39
+ Kernel.const_get("::Reynard::Models::#{model_name}")
40
+ rescue NameError
41
+ nil
44
42
  end
45
43
 
46
- def self.model_class(name, object_type)
47
- model_class_get(name) || model_class_set(name, object_type)
44
+ def self.model_class_set(model_name, schema)
45
+ if schema.type == 'array'
46
+ array_model_class_set(model_name)
47
+ else
48
+ object_model_class_set(model_name, schema)
49
+ end
48
50
  end
49
51
 
50
- def self.model_class_get(name)
51
- Kernel.const_get("::Reynard::Models::#{name}")
52
- rescue NameError
53
- nil
52
+ def self.array_model_class_set(model_name)
53
+ return Array unless model_name
54
+
55
+ ::Reynard::Models.const_set(model_name, Class.new(Array))
54
56
  end
55
57
 
56
- def self.model_class_set(name, object_type)
57
- if object_type == 'array'
58
- Reynard::Models.const_set(name, Class.new(Array))
59
- else
60
- Reynard::Models.const_set(name, Class.new(Reynard::Model))
58
+ def self.object_model_class_set(model_name, schema)
59
+ return Reynard::Model unless model_name
60
+
61
+ model_class = Class.new(Reynard::Model)
62
+ model_class.schema = schema
63
+ ::Reynard::Models.const_set(model_name, model_class)
64
+ end
65
+
66
+ private
67
+
68
+ def cast_array
69
+ return unless parsed_body
70
+
71
+ item_schema = schema.item_schema
72
+ array = model_class.new
73
+ parsed_body.each do |item|
74
+ array << self.class.new(
75
+ schema: item_schema,
76
+ parsed_body: item
77
+ ).call
61
78
  end
79
+ array
62
80
  end
63
81
  end
64
82
  end
@@ -1,14 +1,110 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Reynard
4
- # Holds reference and object type for a schema in the API specification.
4
+ # Holds a references to a schema definition in the specification.
5
5
  class Schema
6
- attr_reader :node, :object_type, :item_schema_name
6
+ attr_reader :node, :namespace
7
7
 
8
- def initialize(node:, object_type:, item_schema_name:)
8
+ def initialize(specification:, node:, namespace: nil)
9
+ @specification = specification
9
10
  @node = node
10
- @object_type = object_type
11
- @item_schema_name = item_schema_name
11
+ @namespace = namespace
12
+ end
13
+
14
+ def type
15
+ return @type if defined?(@type)
16
+
17
+ @type = @specification.dig(*node, 'type')
18
+ end
19
+
20
+ def model_name
21
+ return @model_name if defined?(@model_name)
22
+
23
+ @model_name = find_model_name
24
+ end
25
+
26
+ # Returns the schema for items when the current schema is an array.
27
+ def item_schema
28
+ return unless type == 'array'
29
+
30
+ self.class.new(
31
+ specification: @specification,
32
+ node: [*node, 'items'],
33
+ namespace: [*namespace, model_name]
34
+ )
35
+ end
36
+
37
+ # Returns the schema for a propery in the schema.
38
+ def property_schema(name)
39
+ property_node = [*node, 'properties', name.to_s]
40
+ return unless @specification.dig(*property_node)
41
+
42
+ self.class.new(
43
+ specification: @specification,
44
+ node: property_node,
45
+ namespace: [*namespace, model_name]
46
+ )
47
+ end
48
+
49
+ def self.title_model_name(model_name)
50
+ return unless model_name
51
+
52
+ model_name
53
+ .gsub(/[^[:alpha:]]/, ' ')
54
+ .gsub(/\s{2,}/, ' ')
55
+ .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase }
56
+ .strip
57
+ end
58
+
59
+ # Extracts a model name from a ref when there is a usable value.
60
+ #
61
+ # ref_model_name("#/components/schemas/Library") => "Library"
62
+ def self.ref_model_name(ref)
63
+ return unless ref
64
+
65
+ normalize_ref_model_name(ref.split('/')&.last)
66
+ end
67
+
68
+ def self.normalize_ref_model_name(model_name)
69
+ # 1. Unescape encoded characters to create an UTF-8 string
70
+ # 2. Remove extensions for regularly used external schema files
71
+ # 3. Replace all non-alphabetic characters with a space (not allowed in Ruby constant)
72
+ # 4. Camelcase
73
+ Rack::Utils.unescape_path(model_name)
74
+ .gsub(/(.yml|.yaml|.json)\Z/, '')
75
+ .gsub(/[^[:alpha:]]/, ' ')
76
+ .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase }
77
+ .gsub(/\A(.)/) { Regexp.last_match(1).upcase }
78
+ end
79
+
80
+ private
81
+
82
+ # Returns a model name based on the schema's title or $ref.
83
+ def find_model_name
84
+ title_model_name || ref_model_name || node_model_name
85
+ end
86
+
87
+ def title_model_name
88
+ title = @specification.dig(*node, 'title')
89
+ return unless title
90
+
91
+ self.class.title_model_name(title)
92
+ end
93
+
94
+ def ref_model_name
95
+ parent = @specification.dig(*node[..-2])
96
+ ref = parent.dig('schema', '$ref') || parent.dig('items', '$ref')
97
+ return unless ref
98
+
99
+ self.class.ref_model_name(ref)
100
+ end
101
+
102
+ def node_model_name
103
+ self.class.title_model_name(node_property_name.capitalize.gsub(/[_-]/, ' '))
104
+ end
105
+
106
+ def node_property_name
107
+ node.last == 'items' ? node.at(-2).chomp('s') : node.last
12
108
  end
13
109
  end
14
110
  end
@@ -66,10 +66,7 @@ class Reynard
66
66
  response, media_type = media_type_response(responses, response_code, media_type)
67
67
  return unless response
68
68
 
69
- MediaType.new(
70
- node: [*operation_node, 'responses', response_code, 'content', media_type],
71
- schema_name: schema_name(response)
72
- )
69
+ MediaType.new(node: [*operation_node, 'responses', response_code, 'content', media_type])
73
70
  end
74
71
 
75
72
  def media_type_response(responses, response_code, media_type)
@@ -83,14 +80,9 @@ class Reynard
83
80
  end
84
81
 
85
82
  def schema(media_type_node)
86
- schema = dig(*media_type_node, 'schema')
87
- return unless schema
88
-
89
- Schema.new(
90
- node: [*media_type_node, 'schema'],
91
- object_type: schema['type'],
92
- item_schema_name: item_schema_name(schema)
93
- )
83
+ return unless dig(*media_type_node, 'schema')
84
+
85
+ Schema.new(specification: self, node: [*media_type_node, 'schema'])
94
86
  end
95
87
 
96
88
  def self.media_type_matches?(media_type, expression)
@@ -100,26 +92,6 @@ class Reynard
100
92
  false
101
93
  end
102
94
 
103
- def self.normalize_model_name(name)
104
- # 1. Unescape encoded characters to create an UTF-8 string
105
- # 2. Remove extensions for regularly used external schema files
106
- # 3. Replace all non-alphabetic characters with a space (not allowed in Ruby constant)
107
- # 4. Camelcase
108
- Rack::Utils.unescape_path(name)
109
- .gsub(/(.yml|.yaml|.json)\Z/, '')
110
- .gsub(/[^[:alpha:]]/, ' ')
111
- .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase }
112
- .gsub(/\A(.)/) { Regexp.last_match(1).upcase }
113
- end
114
-
115
- def self.normalize_model_title(title)
116
- title
117
- .gsub(/[^[:alpha:]]/, ' ')
118
- .gsub(/\s{2,}/, ' ')
119
- .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase }
120
- .strip
121
- end
122
-
123
95
  private
124
96
 
125
97
  def read
@@ -156,27 +128,5 @@ class Reynard
156
128
  # rubocop:enable Metrics/AbcSize
157
129
  # rubocop:enable Metrics/CyclomaticComplexity
158
130
  # rubocop:enable Metrics/MethodLength
159
-
160
- def schema_name(response)
161
- extract_schema_name(response['schema'])
162
- end
163
-
164
- def item_schema_name(schema)
165
- if schema['type'] == 'array'
166
- extract_schema_name(schema['items'])
167
- else
168
- extract_schema_name(schema)
169
- end
170
- end
171
-
172
- def extract_schema_name(definition)
173
- ref = definition['$ref']
174
- return self.class.normalize_model_name(ref&.split('/')&.last) if ref
175
-
176
- title = definition['title']
177
- return unless title
178
-
179
- self.class.normalize_model_title(title)
180
- end
181
131
  end
182
132
  end
@@ -5,7 +5,7 @@ class Reynard
5
5
  #
6
6
  # See: RFC6570
7
7
  class Template
8
- VARIABLE_RE = /\{([^}]+)\}/.freeze
8
+ VARIABLE_RE = /\{([^}]+)\}/
9
9
 
10
10
  def initialize(template, params)
11
11
  @template = template
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Reynard
4
- VERSION = '0.5.1'
4
+ VERSION = '0.6.0'
5
5
  end
data/lib/reynard.rb CHANGED
@@ -33,7 +33,7 @@ class Reynard
33
33
  autoload :VERSION, 'reynard/version'
34
34
 
35
35
  def initialize(filename:)
36
- @specification = Specification.new(filename: filename)
36
+ @specification = Specification.new(filename:)
37
37
  end
38
38
 
39
39
  class << self
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.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manfred Stienstra
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-08-05 00:00:00.000000000 Z
11
+ date: 2022-11-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: multi_json
@@ -143,7 +143,7 @@ licenses:
143
143
  - MIT
144
144
  metadata:
145
145
  rubygems_mfa_required: 'true'
146
- post_install_message:
146
+ post_install_message:
147
147
  rdoc_options: []
148
148
  require_paths:
149
149
  - lib
@@ -151,15 +151,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
151
151
  requirements:
152
152
  - - ">"
153
153
  - !ruby/object:Gem::Version
154
- version: '2.7'
154
+ version: '3.1'
155
155
  required_rubygems_version: !ruby/object:Gem::Requirement
156
156
  requirements:
157
157
  - - ">="
158
158
  - !ruby/object:Gem::Version
159
159
  version: '0'
160
160
  requirements: []
161
- rubygems_version: 3.1.6
162
- signing_key:
161
+ rubygems_version: 3.3.7
162
+ signing_key:
163
163
  specification_version: 4
164
164
  summary: Minimal OpenAPI client.
165
165
  test_files: []