reynard 0.9.0 → 0.10.1

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: 52cb53bbff626e0dce10485bf5cc023639b024024a237e34a73d09ae632ef9db
4
- data.tar.gz: 1b66f322e4f7bfe5d0a5bd63096f218994bd55ed747a2fb2e1fa72c4af57ef30
3
+ metadata.gz: c3c34bb5538fc723eb3089aed2fcc9297caecd7c12802081fb1c0ffd5b930885
4
+ data.tar.gz: fd266bc7821ef67989261e66923605b3ea0b249f19c028ada40815928c070b7b
5
5
  SHA512:
6
- metadata.gz: 15a3077ed0ee0c2331cab0182adae4b3970d83a4113f82d2d8aa4e528edd93678dd10a7e346d54e8d3e659dd5ad3509415f9de714a61a013e1937c3781eb686d
7
- data.tar.gz: 80f49722dbde52f83aa9260aea1f71ccc6b97c7871f0818a71b4d42b4a79683cc404a0fa0ad8714f0d4fa4d3def54d2e140be1b35abe215699098da25c9b7b00
6
+ metadata.gz: edd450cc95fc83f6f16c8e8a77d7600f98d8524fe75a1abc78b383cd6442458acf3351e7ea0513cb539a00870bea79729a57120a4c0b556e54c937dcc0275e63
7
+ data.tar.gz: b00d56c46b6c29f5ccf01a83a6fc8f62e53a44953787441bb13af36a7025a0712f75ad537507f917644acff2f134edf71502461cb930e0bbe1c5a4a1b0ff9f6f
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ostruct'
4
-
5
3
  class Reynard
6
4
  # Exposes a public interface to build a request context.
7
5
  class Context
@@ -15,7 +13,7 @@ class Reynard
15
13
  end
16
14
 
17
15
  def base_url(base_url)
18
- copy(base_url: base_url)
16
+ copy(base_url:)
19
17
  end
20
18
 
21
19
  def operation(operation_name)
@@ -44,7 +42,7 @@ class Reynard
44
42
  end
45
43
 
46
44
  def logger(logger)
47
- copy(logger: logger)
45
+ copy(logger:)
48
46
  end
49
47
 
50
48
  def execute
@@ -74,7 +72,7 @@ class Reynard
74
72
  specification: @specification,
75
73
  inflector: @inflector,
76
74
  request_context: @request_context,
77
- http_response: http_response
75
+ http_response:
78
76
  )
79
77
  end
80
78
  end
@@ -3,9 +3,16 @@
3
3
  require 'rack'
4
4
 
5
5
  class Reynard
6
- # Loads external references.
6
+ # Loads data for external references from disk.
7
7
  class External
8
- def initialize(path:, ref:)
8
+ # Build a new external reference loader.
9
+ #
10
+ # @param basepath [String] base path of the OpenAPI specification, we never load any files
11
+ # higher in the directory tree
12
+ # @param path [String] base path of the current file, used to resolve relative paths
13
+ # @param ref [String] the $ref value we actually resolve
14
+ def initialize(basepath:, path:, ref:)
15
+ @basepath = basepath
9
16
  @path = path
10
17
  @relative_path, @anchor = ref.split('#', 2)
11
18
  @filename = File.expand_path(@relative_path, @path)
@@ -14,7 +21,10 @@ class Reynard
14
21
  def path
15
22
  return [] unless @anchor
16
23
 
17
- @anchor.split('/')[1..]
24
+ # Remove explicit absolute (ie. /) and explicitly relative (ie. ./) instructions from the
25
+ # path expression because we're starting at the root of an external file. We currently don't
26
+ # support the // syntax.
27
+ @anchor.to_s.gsub(%r{\A\.?/}, '').split('/')
18
28
  end
19
29
 
20
30
  def data
@@ -30,7 +40,7 @@ class Reynard
30
40
  private
31
41
 
32
42
  def filename
33
- return @filename if @filename.start_with?(@path)
43
+ return @filename if @filename.start_with?(@basepath)
34
44
 
35
45
  raise 'You are only allowed to reference files relative to the specification file.'
36
46
  end
@@ -3,14 +3,21 @@
3
3
  class Reynard
4
4
  # Groups parameters based on the parameters specification.
5
5
  class GroupedParameters
6
- def initialize(specification, params)
7
- @specification = pivot(specification)
6
+ def initialize(specification:, node:, params:)
7
+ @specification = specification
8
+ @node = node
8
9
  @params = params
9
10
  end
10
11
 
12
+ def constraints
13
+ return @constraints if defined?(@constraints)
14
+
15
+ @constraints = actualize(*@node, 'parameters') || actualize(*@node[..-2], 'parameters') || {}
16
+ end
17
+
11
18
  def to_h
12
19
  @params.each_with_object({}) do |(name, value), grouped|
13
- group_name = @specification.dig(name, 'in') || 'query'
20
+ group_name = constraints.dig(name, 'in') || 'query'
14
21
  grouped[group_name] ||= {}
15
22
  grouped[group_name].merge!({ name => value })
16
23
  end
@@ -18,12 +25,16 @@ class Reynard
18
25
 
19
26
  private
20
27
 
21
- def pivot(specification)
22
- return {} unless specification
28
+ def actualize(*node)
29
+ parameters = @specification.dig(*node)
30
+ return unless parameters
23
31
 
24
- specification.each_with_object({}) do |attribute, pivot|
25
- pivot[attribute['name']] = attribute
32
+ pivot = {}
33
+ parameters.each.with_index do |attributes, index|
34
+ attributes = @specification.dig(*node, index) if attributes.key?('$ref')
35
+ pivot[attributes['name']] = attributes
26
36
  end
37
+ pivot
27
38
  end
28
39
  end
29
40
  end
@@ -73,7 +73,7 @@ class Reynard
73
73
  ObjectBuilder.new(
74
74
  schema: @specification.schema(media_type.node),
75
75
  inflector: @inflector,
76
- parsed_body: parsed_body
76
+ parsed_body:
77
77
  ).call
78
78
  end
79
79
 
data/lib/reynard/model.rb CHANGED
@@ -76,7 +76,7 @@ class Reynard
76
76
  property = schema.property_schema(name)
77
77
  return value unless property
78
78
 
79
- ::Reynard::ObjectBuilder.new(schema: property, inflector: inflector, parsed_body: value).call
79
+ ::Reynard::ObjectBuilder.new(schema: property, inflector:, parsed_body: value).call
80
80
  end
81
81
 
82
82
  def self.inflector
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ostruct'
4
-
5
3
  class Reynard
6
4
  # Defines dynamic classes based on schema and instantiates them for a response.
7
5
  class ObjectBuilder
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ class Schema
5
+ # Rummages through the specification to find a suitable model name for a response object.
6
+ class ModelNaming
7
+ def initialize(specification:, node:)
8
+ @specification = specification
9
+ @node = node
10
+ end
11
+
12
+ def model_name
13
+ model_name = determine_model_name
14
+ if class_type == :array
15
+ "#{model_name}Collection"
16
+ else
17
+ model_name
18
+ end
19
+ end
20
+
21
+ def self.title_model_name(model_name)
22
+ return unless model_name
23
+
24
+ model_name
25
+ .gsub(/[^[:alpha:]]/, ' ')
26
+ .gsub(/\s{2,}/, ' ')
27
+ .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase }
28
+ .strip
29
+ end
30
+
31
+ # Extracts a model name from a ref when there is a usable value.
32
+ #
33
+ # ref_model_name("#/components/schemas/Library") => "Library"
34
+ def self.ref_model_name(ref)
35
+ return unless ref
36
+
37
+ normalize_ref_model_name(ref.split('/')&.last)
38
+ end
39
+
40
+ def self.normalize_ref_model_name(model_name)
41
+ # 1. Unescape encoded characters to create an UTF-8 string
42
+ # 2. Remove extensions for regularly used external schema files
43
+ # 3. Replace all non-alphabetic characters with a space (not allowed in Ruby constant)
44
+ # 4. Camelcase
45
+ Rack::Utils.unescape_path(model_name)
46
+ .gsub(/(.yml|.yaml|.json)\Z/, '')
47
+ .gsub(/[^[:alpha:]]/, ' ')
48
+ .gsub(/(\s+)([[:alpha:]])/) { Regexp.last_match(2).upcase }
49
+ .gsub(/\A(.)/) { Regexp.last_match(1).upcase }
50
+ end
51
+
52
+ def self.singularize(name)
53
+ case name
54
+ when /ies$/
55
+ "#{name[0..-4]}y"
56
+ else
57
+ name.chomp('s')
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def class_type
64
+ @specification.dig(*@node).key?('items') ? :array : :object
65
+ end
66
+
67
+ def determine_model_name
68
+ title_model_name || ref_model_name || node_model_name
69
+ end
70
+
71
+ # Returns a model name when it was explicitly set using the title property in the specification.
72
+ def title_model_name
73
+ title = @specification.dig(*@node, 'title')
74
+ return unless title
75
+
76
+ self.class.title_model_name(title)
77
+ end
78
+
79
+ # Returns a model name based on the schema's $ref value, usually this contains a usable
80
+ # identifier at the end like /books.yml or /Books.
81
+ def ref_model_name
82
+ parent = @specification.dig(*@node[..-2])
83
+ ref = parent.dig('schema', '$ref') || parent.dig('items', '$ref')
84
+ return unless ref
85
+
86
+ self.class.ref_model_name(ref)
87
+ end
88
+
89
+ # Returns a model name based on the node path to schema in the specification.
90
+ def node_model_name
91
+ self.class.title_model_name(node_path_name.capitalize.gsub(/[_-]/, ' '))
92
+ end
93
+
94
+ def node_path_name
95
+ if node_anyonymous?
96
+ request_path_model_name
97
+ elsif @node.last == 'items'
98
+ # Use the property name as the model name for its items, for example in the case of
99
+ # schema > properties > birds > items => bird.
100
+ self.class.singularize(@node.at(-2))
101
+ else
102
+ # Usually this means we are dealing with a property's name a not a model, for example in
103
+ # the case of schema > properties > color => color.
104
+ @node.last
105
+ end
106
+ end
107
+
108
+ # Returns true when the node path doesn't have identifyable segments other than the request
109
+ # path for the resource.
110
+ #
111
+ # For example, when the last part of the path looks like this:
112
+ # get > responses > 200 > content > application|json > schema
113
+ def node_anyonymous?
114
+ @node.last == 'schema' || @node.last(2) == %w[schema items]
115
+ end
116
+
117
+ # Finds the first segment starting from the end of the request path that is not a parameter
118
+ # and transforms that to make a model name.
119
+ #
120
+ # For example:
121
+ # /books/{id} => "book"
122
+ def request_path_model_name
123
+ self.class.singularize(@node[1].split('/').reverse.find { |part| !part.start_with?('{') })
124
+ end
125
+ end
126
+ end
127
+ end
@@ -3,6 +3,8 @@
3
3
  class Reynard
4
4
  # Holds a references to a schema definition in the specification.
5
5
  class Schema
6
+ autoload :ModelNaming, 'reynard/schema/model_naming'
7
+
6
8
  attr_reader :node, :namespace
7
9
 
8
10
  def initialize(specification:, node:, namespace: nil)
@@ -18,9 +20,7 @@ class Reynard
18
20
  end
19
21
 
20
22
  def model_name
21
- return @model_name if defined?(@model_name)
22
-
23
- @model_name = find_model_name
23
+ @model_name || model_naming.model_name
24
24
  end
25
25
 
26
26
  # Returns the schema for items when the current schema is an array.
@@ -46,65 +46,10 @@ class Reynard
46
46
  )
47
47
  end
48
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
49
  private
81
50
 
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
51
+ def model_naming
52
+ ModelNaming.new(specification: @specification, node: @node)
108
53
  end
109
54
  end
110
55
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ class Specification
5
+ # Finds nodes in a specification that match a query.
6
+ class Finder
7
+ def initialize(specification:, query:)
8
+ @specification = specification
9
+ @query = query
10
+ end
11
+
12
+ def find_each(&)
13
+ find_into([], &)
14
+ end
15
+
16
+ private
17
+
18
+ def find_into(path, &block)
19
+ data = @specification.dig(*path)
20
+
21
+ yield path if data.respond_to?(:key?) && data.key?('type') && (@query.type == data['type'])
22
+
23
+ return unless data.respond_to?(:each_key)
24
+
25
+ data.each_key do |key|
26
+ find_into([*path, key], &block)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ class Specification
5
+ # Describes a query for a node in a specification.
6
+ class Query
7
+ attr_reader :type
8
+
9
+ def initialize(type: nil)
10
+ @type = type
11
+ end
12
+ end
13
+ end
14
+ end
@@ -5,6 +5,9 @@ require 'rack'
5
5
  class Reynard
6
6
  # Wraps the YAML representation of an OpenAPI specification.
7
7
  class Specification
8
+ autoload :Finder, 'reynard/specification/finder'
9
+ autoload :Query, 'reynard/specification/query'
10
+
8
11
  VERBS = %w[get put post delete options head patch trace].freeze
9
12
 
10
13
  def initialize(filename:)
@@ -17,6 +20,15 @@ class Reynard
17
20
  dig_into(@data, @data, path.dup, File.dirname(@filename))
18
21
  end
19
22
 
23
+ # Yields all nodes in the specification matching the specified type.
24
+ #
25
+ # specification.find_each(type: 'object') {}
26
+ #
27
+ # Please don't use this in a hot paths in production, primarily meant for testing and tooling.
28
+ def find_each(type:, &block)
29
+ Finder.new(specification: self, query: Query.new(type:)).find_each(&block)
30
+ end
31
+
20
32
  def servers
21
33
  dig('servers').map { |attributes| Server.new(attributes) }
22
34
  end
@@ -35,13 +47,9 @@ class Reynard
35
47
  return {} unless params
36
48
 
37
49
  GroupedParameters.new(
38
- [
39
- # Parameters can be shared between methods on a path or be specific to an operation. The
40
- # parameters on the operation level override those at the path level.
41
- dig(*operation_node, 'parameters'),
42
- dig(*operation_node[..-2], 'parameters')
43
- ].compact.flatten,
44
- params
50
+ specification: self,
51
+ node: operation_node,
52
+ params:
45
53
  ).to_h
46
54
  end
47
55
 
@@ -52,7 +60,8 @@ class Reynard
52
60
  end
53
61
 
54
62
  def operation(operation_name)
55
- dig('paths').each do |path, operations|
63
+ dig('paths').each_key do |path|
64
+ operations = dig('paths', path)
56
65
  operations.slice(*VERBS).each do |verb, operation|
57
66
  return Operation.new(node: ['paths', path, verb]) if operation_name == operation['operationId']
58
67
  end
@@ -101,7 +110,6 @@ class Reynard
101
110
  end
102
111
 
103
112
  # rubocop:disable Metrics/AbcSize
104
- # rubocop:disable Metrics/CyclomaticComplexity
105
113
  # rubocop:disable Metrics/MethodLength
106
114
  def dig_into(data, cursor, path, filesystem_path)
107
115
  while path.length.positive?
@@ -117,8 +125,8 @@ class Reynard
117
125
  path = Rack::Utils.unescape_path(cursor['$ref'][2..]).split('/') + path
118
126
  cursor = data
119
127
  # References another file, with an optional anchor to an element in the data.
120
- when %r{\A\./}
121
- external = External.new(path: filesystem_path, ref: cursor['$ref'])
128
+ else
129
+ external = External.new(basepath:, path: filesystem_path, ref: cursor['$ref'])
122
130
  filesystem_path = external.filesystem_path
123
131
  path = external.path + path
124
132
  cursor = external.data
@@ -127,7 +135,10 @@ class Reynard
127
135
  cursor
128
136
  end
129
137
  # rubocop:enable Metrics/AbcSize
130
- # rubocop:enable Metrics/CyclomaticComplexity
131
138
  # rubocop:enable Metrics/MethodLength
139
+
140
+ def basepath
141
+ File.dirname(File.expand_path(@filename))
142
+ end
132
143
  end
133
144
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Reynard
4
- VERSION = '0.9.0'
4
+ VERSION = '0.10.1'
5
5
  end
data/lib/reynard.rb CHANGED
@@ -34,7 +34,7 @@ class Reynard
34
34
  autoload :VERSION, 'reynard/version'
35
35
 
36
36
  def initialize(filename:)
37
- @specification = Specification.new(filename: filename)
37
+ @specification = Specification.new(filename:)
38
38
  @inflector = Inflector.new
39
39
  end
40
40
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reynard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Manfred Stienstra
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-02-28 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: multi_json
@@ -78,9 +77,12 @@ files:
78
77
  - lib/reynard/operation.rb
79
78
  - lib/reynard/request_context.rb
80
79
  - lib/reynard/schema.rb
80
+ - lib/reynard/schema/model_naming.rb
81
81
  - lib/reynard/serialized_body.rb
82
82
  - lib/reynard/server.rb
83
83
  - lib/reynard/specification.rb
84
+ - lib/reynard/specification/finder.rb
85
+ - lib/reynard/specification/query.rb
84
86
  - lib/reynard/template.rb
85
87
  - lib/reynard/version.rb
86
88
  homepage: https://github.com/Manfred/reynard
@@ -88,7 +90,6 @@ licenses:
88
90
  - MIT
89
91
  metadata:
90
92
  rubygems_mfa_required: 'true'
91
- post_install_message:
92
93
  rdoc_options: []
93
94
  require_paths:
94
95
  - lib
@@ -96,15 +97,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
96
97
  requirements:
97
98
  - - ">"
98
99
  - !ruby/object:Gem::Version
99
- version: '3.0'
100
+ version: '3.1'
100
101
  required_rubygems_version: !ruby/object:Gem::Requirement
101
102
  requirements:
102
103
  - - ">="
103
104
  - !ruby/object:Gem::Version
104
105
  version: '0'
105
106
  requirements: []
106
- rubygems_version: 3.5.6
107
- signing_key:
107
+ rubygems_version: 3.6.7
108
108
  specification_version: 4
109
109
  summary: Minimal OpenAPI client.
110
110
  test_files: []