reynard 0.0.3

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1655a999a92e83993f4fdba62a9eedc870b18ada0339278d1c899e24f087b3bc
4
+ data.tar.gz: 926420c57d99767386d9aea1ef50b5bda317ea437a11dc3646bf3a5cc8a902be
5
+ SHA512:
6
+ metadata.gz: 6c9834fae550aae2557a6833e4491eed9d426c9fdb5b6c2cf31ff5c9fe80569a00070affdfb76e9c90611400f959df5a7e377dfe906a813655e6b28bc3ced4ac
7
+ data.tar.gz: a22f676bb82b37d6bceb87028aeae10a78f2fd394cdef990eea7661842d1b0a2644ac20e5188f29730c62d54ab36498b1fe7112ebc1ffe872b3f3b6dfa848aff
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright © Manfred Stienstra <manfred@fngtps.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Reynard
2
+
3
+ Reynard is an OpenAPI client for Ruby. It operates directly on the OpenAPI specification without the need to generate any source code.
4
+
5
+ ```ruby
6
+ # A Client does not have a fixed state and creating a new
7
+ # client will never incur a cost over creating the object
8
+ # itself.
9
+ reynard = Reynard.new(filename: 'openapi.yml')
10
+ ```
11
+
12
+ ## Installing
13
+
14
+ Reynard is distributed as a gem called `reynard`.
15
+
16
+ ## Choosing a server
17
+
18
+ An OpenAPI specification may specify multiple servers. There is no automated way to select the ‘correct’ server so Reynard uses the first one by default.
19
+
20
+ For example:
21
+
22
+ ```yaml
23
+ servers:
24
+ - url: http://production.example.com/v1
25
+ - url: http://staging.example.com/v1
26
+ ```
27
+
28
+ Will cause Reynard to choose the production URL.
29
+
30
+ ```ruby
31
+ reynard.url #=> "http://production.example.com/v1"
32
+ ```
33
+
34
+ You can override the `base_url` if you want to use a different one.
35
+
36
+ ```ruby
37
+ reynard.base_url('http://test.example.com/v1')
38
+ ```
39
+
40
+ You also have access to all servers in the specification so you can automatically select one however you want.
41
+
42
+ ```ruby
43
+ base_url = @reynard.servers.map(&:url).find do |url|
44
+ /staging/.match(url)
45
+ end
46
+ reynard.base_url(base_url)
47
+ ```
48
+
49
+ ## Calling endpoints
50
+
51
+ Assuming there is an operation called `employeeByUuid` you can it as shown below.
52
+
53
+ ```ruby
54
+ employee = reynard.
55
+ operation('employeeByUuid').
56
+ params(uuid: uuid).
57
+ execute
58
+ ```
59
+
60
+ ## Copyright and other legal
61
+
62
+ See LICENCE.
data/lib/reynard.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'multi_json'
5
+ require 'rack'
6
+ require 'yaml'
7
+ require 'uri'
8
+
9
+ # Reynard is a convenience class for configuring an HTTP request against an
10
+ # OpenAPI specification.
11
+ class Reynard
12
+ extend Forwardable
13
+ def_delegators :build_context, :base_url, :operation, :headers, :params
14
+ def_delegators :@specification, :servers
15
+
16
+ autoload :Context, 'reynard/context'
17
+ autoload :Http, 'reynard/http'
18
+ autoload :MediaType, 'reynard/media_type'
19
+ autoload :Model, 'reynard/model'
20
+ autoload :Models, 'reynard/models'
21
+ autoload :ObjectBuilder, 'reynard/object_builder'
22
+ autoload :Operation, 'reynard/operation'
23
+ autoload :RequestContext, 'reynard/request_context'
24
+ autoload :Schema, 'reynard/schema'
25
+ autoload :Server, 'reynard/server'
26
+ autoload :Specification, 'reynard/specification'
27
+ autoload :Template, 'reynard/template'
28
+ autoload :GroupedParameters, 'reynard/grouped_parameters'
29
+ autoload :VERSION, 'reynard/version'
30
+
31
+ def initialize(filename:)
32
+ @specification = Specification.new(filename: filename)
33
+ end
34
+
35
+ private
36
+
37
+ def build_context
38
+ Context.new(specification: @specification)
39
+ end
40
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Exposes a public interface to build a request context.
5
+ class Context
6
+ extend Forwardable
7
+ def_delegators :@request_context, :verb, :path, :full_path, :url
8
+
9
+ def initialize(specification:, request_context: nil)
10
+ @specification = specification
11
+ @request_context = request_context || build_request_context
12
+ end
13
+
14
+ def base_url(base_url)
15
+ copy(base_url: base_url)
16
+ end
17
+
18
+ def operation(operation_name)
19
+ copy(operation: @specification.operation(operation_name))
20
+ end
21
+
22
+ def params(params)
23
+ params = params.transform_keys(&:to_s)
24
+ copy(params: @specification.build_grouped_params(@request_context.operation.node, params))
25
+ end
26
+
27
+ def headers(headers)
28
+ copy(headers: headers)
29
+ end
30
+
31
+ def execute
32
+ build_object(build_request.perform)
33
+ end
34
+
35
+ private
36
+
37
+ def build_request_context
38
+ RequestContext.new(base_url: @specification.default_base_url)
39
+ end
40
+
41
+ def copy(**properties)
42
+ self.class.new(
43
+ specification: @specification,
44
+ request_context: @request_context.copy(**properties)
45
+ )
46
+ end
47
+
48
+ def build_request
49
+ Reynard::Http::Request.new(request_context: @request_context)
50
+ end
51
+
52
+ def build_object(http_response)
53
+ media_type = @specification.media_type(
54
+ @request_context.operation.node,
55
+ http_response.code,
56
+ http_response['Content-Type'].split(';').first
57
+ )
58
+ ObjectBuilder.new(
59
+ media_type: media_type,
60
+ schema: @specification.schema(media_type.node),
61
+ http_response: http_response
62
+ ).call
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Groups parameters based on the parameters specification.
5
+ class GroupedParameters
6
+ def initialize(specification, params)
7
+ @specification = pivot(specification)
8
+ @params = params
9
+ end
10
+
11
+ def to_h
12
+ @params.each_with_object({}) do |(name, value), grouped|
13
+ group_name = @specification.dig(name, 'in') || 'query'
14
+ grouped[group_name] ||= {}
15
+ grouped[group_name].merge!({ name => value })
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def pivot(specification)
22
+ return {} unless specification
23
+
24
+ specification.each_with_object({}) do |attribute, pivot|
25
+ pivot[attribute['name']] = attribute
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ class Http
5
+ autoload :Request, 'reynard/http/request'
6
+ end
7
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'rack'
5
+
6
+ class Reynard
7
+ class Http
8
+ # Configures and performs an HTTP request.
9
+ class Request
10
+ attr_reader :uri
11
+
12
+ def initialize(request_context:)
13
+ @request_context = request_context
14
+ @uri = URI(@request_context.url)
15
+ end
16
+
17
+ def perform
18
+ build_http.request(build_request)
19
+ end
20
+
21
+ private
22
+
23
+ def build_request
24
+ case @request_context.verb
25
+ when 'get'
26
+ build_http_get
27
+ when 'post'
28
+ build_http_post
29
+ end
30
+ end
31
+
32
+ def build_http
33
+ http = Net::HTTP.new(uri.hostname, uri.port)
34
+ http.set_debug_output($stderr) if ENV['DEBUG']
35
+ http
36
+ end
37
+
38
+ def build_http_get
39
+ Net::HTTP::Get.new(uri, @request_context.headers)
40
+ end
41
+
42
+ def build_http_post
43
+ post = Net::HTTP::Post.new(uri, @request_context.headers)
44
+ post.body = @request_context.body
45
+ post
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Holds node reference and schema name to a media type in the API specification.
5
+ class MediaType
6
+ attr_reader :node, :schema_name
7
+
8
+ def initialize(node:, schema_name:)
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
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Superclass for dynamic classes generated by the object builder.
5
+ class Model
6
+ def initialize(attributes)
7
+ self.attributes = attributes
8
+ end
9
+
10
+ def attributes=(attributes)
11
+ attributes.each do |name, value|
12
+ instance_variable_set("@#{name}", value)
13
+ end
14
+ end
15
+
16
+ # Until we can set accessors based on the schema
17
+ def method_missing(attribute_name, *)
18
+ instance_variable_get("@#{attribute_name}")
19
+ end
20
+
21
+ def respond_to_missing?(attribute_name, *)
22
+ !!instance_variable_get("@#{attribute_name}")
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Contains all dynamically generated classes.
5
+ module Models
6
+ end
7
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+
5
+ class Reynard
6
+ # Defines dynamic classes based on schema and instantiates them for a response.
7
+ class ObjectBuilder
8
+ def initialize(media_type:, schema:, http_response:)
9
+ @media_type = media_type
10
+ @schema = schema
11
+ @http_response = http_response
12
+ end
13
+
14
+ # Object.const_set(@media_type.schema_name, Class.new(Reynard::Model))
15
+ def object_class
16
+ if @media_type.schema_name
17
+ self.class.model_class(@media_type.schema_name)
18
+ else
19
+ OpenStruct
20
+ end
21
+ end
22
+
23
+ def call
24
+ case @media_type.media_type
25
+ when 'application/json'
26
+ object_class.new(MultiJson.load(@http_response.body))
27
+ else
28
+ FailedRequest.new
29
+ end
30
+ end
31
+
32
+ def self.model_class(name)
33
+ Reynard::Models.const_get(name)
34
+ rescue NameError
35
+ Reynard::Models.const_set(name, Class.new(Reynard::Model))
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Holds the node reference to an operation in the API specification.
5
+ class Operation
6
+ DEFAULT_MEDIA_TYPE = 'application/json'
7
+
8
+ attr_reader :node
9
+
10
+ def initialize(node:)
11
+ @node = node
12
+ end
13
+
14
+ def path
15
+ @node[1]
16
+ end
17
+
18
+ def verb
19
+ @node[2]
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Value class for details about the request.
5
+ RequestContext = Struct.new(
6
+ :base_url,
7
+ :operation,
8
+ :headers,
9
+ :params,
10
+ keyword_init: true
11
+ ) do
12
+ def verb
13
+ operation&.verb
14
+ end
15
+
16
+ def query
17
+ Rack::Utils.build_query(params['query']) if query_params?
18
+ end
19
+
20
+ def path
21
+ return unless operation&.path
22
+
23
+ Template.new(operation.path, params ? params.fetch('path', {}) : {}).result
24
+ end
25
+
26
+ def full_path
27
+ query_params? ? "#{path}?#{query}" : path
28
+ end
29
+
30
+ def url
31
+ return unless base_url
32
+
33
+ "#{base_url}#{full_path}"
34
+ end
35
+
36
+ def copy(**properties)
37
+ copy = dup
38
+ properties.each { |attribute, value| copy.send("#{attribute}=", value) }
39
+ copy
40
+ end
41
+
42
+ private
43
+
44
+ def query_params?
45
+ return false unless params
46
+
47
+ !params.fetch('query', {}).empty?
48
+ end
49
+
50
+ def path_params?
51
+ params&.key?('path')
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Holds reference and object type for a schema in the API specification.
5
+ class Schema
6
+ attr_reader :node, :object_type
7
+
8
+ def initialize(node:, object_type:)
9
+ @node = node
10
+ @object_type = object_type
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Holds information about a server definition in the specification.
5
+ class Server < Model
6
+ end
7
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Wraps the YAML representation of an OpenAPI specification.
5
+ class Specification
6
+ def initialize(filename:)
7
+ @filename = filename
8
+ @data = read
9
+ end
10
+
11
+ # Digs a value out of the specification, taking $ref into account.
12
+ def dig(*path)
13
+ dig_into(@data, @data, path.dup)
14
+ end
15
+
16
+ def servers
17
+ dig('servers').map { |attributes| Server.new(attributes) }
18
+ end
19
+
20
+ def default_base_url
21
+ servers.first&.url
22
+ end
23
+
24
+ # The specification tells us where a parameter should be included, they can be placed in path,
25
+ # query, header, or cookie. In order to get them in the correct place, we group them by their
26
+ # location.
27
+ #
28
+ # build_grouped_params(operation_node, { 'q' => 'face' }) #=>
29
+ # { 'query' => { 'q' => 'face' } }
30
+ def build_grouped_params(operation_node, params)
31
+ return {} unless params
32
+
33
+ GroupedParameters.new(dig(*operation_node, 'parameters'), params).to_h
34
+ end
35
+
36
+ def operation(operation_name)
37
+ dig('paths').each do |path, operations|
38
+ operations.each do |verb, operation|
39
+ return Operation.new(node: ['paths', path, verb]) if operation_name == operation['operationId']
40
+ end
41
+ end
42
+ nil
43
+ end
44
+
45
+ def media_type(operation_node, response_code, media_type)
46
+ responses = dig(*operation_node, 'responses')
47
+ response_code = responses.key?(response_code) ? response_code : 'default'
48
+ response = responses.dig(response_code, 'content', media_type)
49
+ return unless response
50
+
51
+ MediaType.new(
52
+ node: [*operation_node, 'responses', response_code, 'content', media_type],
53
+ schema_name: schema_name(response)
54
+ )
55
+ end
56
+
57
+ def schema(media_type_node)
58
+ schema = dig(*media_type_node, 'schema')
59
+ return unless schema
60
+
61
+ Schema.new(node: [*media_type_node, 'schema'], object_type: schema['type'])
62
+ end
63
+
64
+ private
65
+
66
+ def read
67
+ File.open(@filename, encoding: 'UTF-8') do |file|
68
+ YAML.safe_load(file)
69
+ end
70
+ end
71
+
72
+ def dig_into(data, cursor, path)
73
+ while path.length.positive?
74
+ cursor = cursor[path.first]
75
+ return unless cursor
76
+
77
+ path.shift
78
+ next unless cursor.respond_to?(:key?) && cursor&.key?('$ref')
79
+
80
+ # We currenly only supply references inside the document starting with #/.
81
+ path = cursor['$ref'][2..].split('/') + path
82
+ cursor = data
83
+ end
84
+ cursor
85
+ end
86
+
87
+ def schema_name(response)
88
+ ref = response.dig('schema', '$ref')
89
+ ref&.split('/')&.last
90
+ end
91
+
92
+ def object_name(_schema)
93
+ 'Book'
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ # Basic implementation of URI templates.
5
+ #
6
+ # See: RFC6570
7
+ class Template
8
+ VARIABLE_RE = /\{([^}]+)\}/.freeze
9
+
10
+ def initialize(template, params)
11
+ @template = template
12
+ @params = params
13
+ end
14
+
15
+ def result
16
+ @template.gsub(VARIABLE_RE) do
17
+ Rack::Utils.escape_path(@params.fetch(Regexp.last_match(1)).to_s)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Reynard
4
+ VERSION = '0.0.3'
5
+ end
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: reynard
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Manfred Stienstra
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-08-05 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: multi_json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rack
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
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: webmock
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
+ description: |2
84
+ Reynard is an OpenAPI client for Ruby. It operates directly on the OpenAPI specification without
85
+ the need to generate any source code.
86
+ email:
87
+ - manfred@fngtps.com
88
+ executables: []
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - LICENSE
93
+ - README.md
94
+ - lib/reynard.rb
95
+ - lib/reynard/context.rb
96
+ - lib/reynard/grouped_parameters.rb
97
+ - lib/reynard/http.rb
98
+ - lib/reynard/http/request.rb
99
+ - lib/reynard/media_type.rb
100
+ - lib/reynard/model.rb
101
+ - lib/reynard/models.rb
102
+ - lib/reynard/object_builder.rb
103
+ - lib/reynard/operation.rb
104
+ - lib/reynard/request_context.rb
105
+ - lib/reynard/schema.rb
106
+ - lib/reynard/server.rb
107
+ - lib/reynard/specification.rb
108
+ - lib/reynard/template.rb
109
+ - lib/reynard/version.rb
110
+ homepage: https://github.com/Manfred/reynard
111
+ licenses:
112
+ - MIT
113
+ metadata: {}
114
+ post_install_message:
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">"
121
+ - !ruby/object:Gem::Version
122
+ version: '2.7'
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubygems_version: 3.2.22
130
+ signing_key:
131
+ specification_version: 4
132
+ summary: Minimal OpenAPI client.
133
+ test_files: []