reynard 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
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: []