reynard 0.5.1 → 0.6.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: 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: []