jsapi 1.3 → 1.4.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: 2abec73a53f67bfebe51e0466e662be1dfc393f3fe5946612a12c94fde8b463b
4
- data.tar.gz: f9c69c6df40d4f88e5175beb185a528ad76f2c6b4cbf6a8f88ac15915943f706
3
+ metadata.gz: 7651bda7e379186840e78ede1870f022b69deca559b6ba5ad6d0cbc8aac5c840
4
+ data.tar.gz: 16a661d4c24e1195f23d6fa0a23d27ba53fb944819b2f9a57c2174456f5ca913
5
5
  SHA512:
6
- metadata.gz: ca369ab50a38016fa37e9199e5a38f9248d2cbcfa51d80c39461dd206d8b09fb6ae60ea29861d211b9a939e408b6e48f35008da577c9f3a789bb6ebb592fafda
7
- data.tar.gz: 8093446b1d7601d9a0e89cae395fa0ced837901b1a9eb0866ebcfd721cebb9e07d611c851735c847a699d71cec4ca474d04e39710ebc244476f6deb635e7fdc7
6
+ metadata.gz: 0e1eebb7f9d53a3e2f28a562edac148093b506ade987384e093530f5d337b85a5aabeb5acae70ca80233f75215e1dbc158b4a786724a8eb77670e63ee0be348d
7
+ data.tar.gz: 51d183148b0575ba9d437c2d3208257ab6e0b1cdec9d3024aa63a6261a56542e9bee8907acc4bb5f042360f90114bf668f1e46836e97dcf1332d30b616f8d264
@@ -12,12 +12,16 @@ module Jsapi
12
12
  self.class.api_definitions
13
13
  end
14
14
 
15
+ ##
16
+ # :method: api_operation
17
+ # :args: operation_name = nil, omit: nil, status: nil, strong: false, &block
18
+ #
15
19
  # Performs an API operation by calling the given block. The request parameters are
16
20
  # passed as an instance of the operation's model class to the block. The object
17
21
  # returned by the block is implicitly rendered according to the appropriate +response+
18
- # specification when the content type is a JSON MIME type. When content type is
22
+ # specification when the content type is a \JSON MIME type. When content type is
19
23
  # <code>application/json-seq</code>, the object returned by the block is streamed in
20
- # JSON sequence text format.
24
+ # \JSON sequence text format.
21
25
  #
22
26
  # api_operation('foo') do |api_params|
23
27
  # # ...
@@ -36,41 +40,68 @@ module Jsapi
36
40
  # - +:nil+ - All of the properties whose value is +nil+ are omitted.
37
41
  #
38
42
  # Raises an +ArgumentError+ when +:omit+ is other than +:empty+, +:nil+ or +nil+.
39
- def api_operation(operation_name = nil,
40
- omit: nil,
41
- status: nil,
42
- strong: false,
43
- &block)
44
- _api_operation(
45
- operation_name,
46
- bang: false,
47
- omit: omit,
48
- status: status,
49
- strong: strong,
50
- &block
51
- )
52
- end
53
43
 
54
- # Like +api_operation+, except that a ParametersInvalid exception is raised on
55
- # invalid request parameters.
44
+ ##
45
+ # :method: api_operation!
46
+ # :args: operation_name = nil, omit: nil, status: nil, strong: false, &block
47
+ #
48
+ # Like +api_operation+, except that a ParametersInvalid exception is raised
49
+ # when request parameters are invalid.
56
50
  #
57
51
  # api_operation!('foo') do |api_params|
58
52
  # # ...
59
53
  # end
60
- #
61
- def api_operation!(operation_name = nil,
62
- omit: nil,
63
- status: nil,
64
- strong: false,
65
- &block)
66
- _api_operation(
67
- operation_name,
68
- bang: true,
69
- omit: omit,
70
- status: status,
71
- strong: strong,
72
- &block
73
- )
54
+
55
+ [true, false].each do |bang|
56
+ define_method(bang ? :api_operation! : :api_operation) \
57
+ do |operation_name = nil, omit: nil, status: nil, strong: false, &block|
58
+ definitions = api_definitions
59
+ operation_model = _find_api_operation_model(operation_name, definitions)
60
+ response_model = _find_api_response_model(operation_model, status, definitions)
61
+ head(status) && return unless block
62
+
63
+ # Perform operation
64
+ api_params = _api_params(operation_model, definitions, strong: strong)
65
+ api_response = Response.new(
66
+ begin
67
+ raise ParametersInvalid.new(api_params) if bang && api_params.invalid?
68
+
69
+ block.call(api_params)
70
+ rescue StandardError => e
71
+ # Lookup a rescue handler
72
+ rescue_handler = definitions.rescue_handler_for(e)
73
+ raise e if rescue_handler.nil?
74
+
75
+ # Change the HTTP status code and response model
76
+ status = rescue_handler.status
77
+ response_model = operation_model.response(status)&.resolve(definitions)
78
+ raise e if response_model.nil?
79
+
80
+ # Call on_rescue callbacks
81
+ definitions.on_rescue_callbacks.each do |callback|
82
+ callback.respond_to?(:call) ? callback.call(e) : send(callback, e)
83
+ end
84
+
85
+ Error.new(e, status: status)
86
+ end,
87
+ response_model, definitions, omit: omit
88
+ )
89
+ # Write response
90
+ media_type = response_model.content_type
91
+
92
+ if media_type == Media::Type::APPLICATION_JSON_SEQ
93
+ self.content_type = media_type.to_s
94
+ response.status = status
95
+
96
+ response.stream.tap do |stream|
97
+ api_response.write_json_seq_to(stream)
98
+ ensure
99
+ stream.close
100
+ end
101
+ elsif media_type.json?
102
+ render(json: api_response, status: status, content_type: media_type.to_s)
103
+ end
104
+ end
74
105
  end
75
106
 
76
107
  # Returns the request parameters as an instance of the operation's model class.
@@ -88,7 +119,7 @@ module Jsapi
88
119
  def api_params(operation_name = nil, strong: false)
89
120
  definitions = api_definitions
90
121
  _api_params(
91
- _find_api_operation(operation_name, definitions),
122
+ _find_api_operation_model(operation_name, definitions),
92
123
  definitions,
93
124
  strong: strong
94
125
  )
@@ -110,89 +141,34 @@ module Jsapi
110
141
  # Raises an +ArgumentError+ when +:omit+ is other than +:empty+, +:nil+ or +nil+.
111
142
  def api_response(result, operation_name = nil, omit: nil, status: nil)
112
143
  definitions = api_definitions
113
- operation = _find_api_operation(operation_name, definitions)
114
- response_model = _api_response(operation, status, definitions)
144
+ operation_model = _find_api_operation_model(operation_name, definitions)
145
+ response_model = _find_api_response_model(operation_model, status, definitions)
115
146
 
116
- Response.new(result, response_model, api_definitions, omit: omit)
147
+ Response.new(result, response_model, definitions, omit: omit)
117
148
  end
118
149
 
119
150
  private
120
151
 
121
- def _api_operation(operation_name, bang:, omit:, status:, strong:, &block)
122
- definitions = api_definitions
123
- operation = _find_api_operation(operation_name, definitions)
124
-
125
- # Perform operation
126
- response_model = _api_response(operation, status, definitions)
127
- head(status) && return unless block
128
-
129
- params = _api_params(operation, definitions, strong: strong)
130
-
131
- result = begin
132
- raise ParametersInvalid.new(params) if bang && params.invalid?
133
-
134
- block.call(params)
135
- rescue StandardError => e
136
- # Lookup a rescue handler
137
- rescue_handler = definitions.rescue_handler_for(e)
138
- raise e if rescue_handler.nil?
139
-
140
- # Change the HTTP status code and response model
141
- status = rescue_handler.status
142
- response_model = operation.response(status)&.resolve(definitions)
143
- raise e if response_model.nil?
144
-
145
- # Call on_rescue callbacks
146
- definitions.on_rescue_callbacks.each do |callback|
147
- if callback.respond_to?(:call)
148
- callback.call(e)
149
- else
150
- send(callback, e)
151
- end
152
- end
153
-
154
- Error.new(e, status: status)
155
- end
156
-
157
- # Write response
158
- return unless response_model.json_type? || response_model.json_seq_type?
159
-
160
- response = Response.new(result, response_model, definitions, omit: omit)
161
- self.content_type = response_model.content_type
162
-
163
- if response_model.json_seq_type?
164
- self.response.status = status
165
-
166
- self.response.stream.tap do |stream|
167
- response.write_json_seq_to(stream)
168
- ensure
169
- stream.close
170
- end
171
- else
172
- render(json: response, status: status)
173
- end
174
- end
175
-
176
- def _api_params(operation, definitions, strong:)
177
- (operation.model || Model::Base).new(
152
+ def _api_params(operation_model, definitions, strong:)
153
+ (operation_model.model || Model::Base).new(
178
154
  Parameters.new(
179
155
  params.except(:action, :controller, :format).permit!,
180
156
  request,
181
- operation,
157
+ operation_model,
182
158
  definitions,
183
159
  strong: strong
184
160
  )
185
161
  )
186
162
  end
187
163
 
188
- def _api_response(operation, status, definitions)
164
+ def _find_api_response_model(operation, status, definitions)
189
165
  response = operation.response(status)
190
166
  return response.resolve(definitions) if response
191
167
 
192
168
  raise "status code not defined: #{status}"
193
169
  end
194
170
 
195
- def _find_api_operation(operation_name, definitions)
171
+ def _find_api_operation_model(operation_name, definitions)
196
172
  operation = definitions.find_operation(operation_name)
197
173
  return operation if operation
198
174
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
-
5
3
  module Jsapi
6
4
  module Controller
7
5
  # Used to wrap request parameters.
@@ -24,9 +22,7 @@ module Jsapi
24
22
  @raw_attributes = {}
25
23
 
26
24
  # Parameters
27
- operation.parameters.each do |name, parameter_model|
28
- parameter_model = parameter_model.resolve(definitions)
29
-
25
+ operation.resolved_parameters(definitions).each do |name, parameter_model|
30
26
  @raw_attributes[name] = JSON.wrap(
31
27
  case parameter_model.in
32
28
  when 'header'
@@ -147,6 +147,17 @@ module Jsapi
147
147
  api_definitions { parameter(name, **keywords, &block) }
148
148
  end
149
149
 
150
+ # Groups operations by path.
151
+ #
152
+ # api_path 'api' do
153
+ # operation 'foo'
154
+ # operation 'bar'
155
+ # end
156
+ #
157
+ def api_path(name, &block)
158
+ api_definitions { path(name, &block) }
159
+ end
160
+
150
161
  # Defines a reusable request body.
151
162
  #
152
163
  # api_request_body 'foo', type: 'string'
@@ -155,6 +155,20 @@ module Jsapi
155
155
  end
156
156
  end
157
157
 
158
+ # Groups operations by path.
159
+ #
160
+ # path 'api' do
161
+ # operation 'foo'
162
+ # operation 'bar'
163
+ # end
164
+ #
165
+ def path(name = nil, &block)
166
+ define('path', name&.inspect) do
167
+ path_model = @meta_model.add_path(name)
168
+ Path.new(path_model, &block) if block
169
+ end
170
+ end
171
+
158
172
  # Specifies a reusable request body.
159
173
  #
160
174
  # request_body 'foo', type: 'string'
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsapi
4
+ module DSL
5
+ class Path < Base
6
+ # Specifies an operation within the current path.
7
+ #
8
+ # operation 'foo' do
9
+ # parameter 'bar', type: 'string'
10
+ # response do
11
+ # property 'foo', type: 'string'
12
+ # end
13
+ # end
14
+ #
15
+ def operation(name = nil, **keywords, &block)
16
+ define('operation', name&.inspect) do
17
+ operation_model = @meta_model.owner.add_operation(name, @meta_model.name, keywords)
18
+ Operation.new(operation_model, &block) if block
19
+ end
20
+ end
21
+
22
+ # Specifies a parameter applicable for all operations in this path.
23
+ #
24
+ # parameter 'foo', type: 'string'
25
+ #
26
+ # See Meta::Path#parameters for further information.
27
+ def parameter(name, **keywords, &block)
28
+ define('parameter', name.inspect) do
29
+ parameter_model = @meta_model.add_parameter(name, keywords)
30
+ Parameter.new(parameter_model, &block) if block
31
+ end
32
+ end
33
+
34
+ # Specifies a nested path.
35
+ def path(name = nil, &block)
36
+ define('path', name&.inspect) do
37
+ path_model = @meta_model.owner.add_path(@meta_model.name + name.to_s)
38
+ Path.new(path_model, &block) if block
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
data/lib/jsapi/dsl.rb CHANGED
@@ -9,6 +9,7 @@ require_relative 'dsl/request_body'
9
9
  require_relative 'dsl/response'
10
10
  require_relative 'dsl/callback'
11
11
  require_relative 'dsl/operation'
12
+ require_relative 'dsl/path'
12
13
  require_relative 'dsl/definitions'
13
14
  require_relative 'dsl/class_methods'
14
15
 
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'type_and_subtype'
4
+
5
+ module Jsapi
6
+ module Media
7
+ # Represents a media range.
8
+ class Range
9
+ include Comparable
10
+ include TypeAndSubtype
11
+
12
+ class << self
13
+ # Transforms +value+ to an instance of this class.
14
+ #
15
+ # Raises an ArgumentError when +value+ could not be transformed.
16
+ def from(value)
17
+ media_range = try_from(value)
18
+ return media_range unless media_range.nil?
19
+
20
+ raise ArgumentError, "invalid media range: #{value.inspect}"
21
+ end
22
+
23
+ # Tries to transform +value+ to an instance of this class. Returns nil
24
+ # if +value+ could not be transformed.
25
+ def try_from(value)
26
+ return value if value.is_a?(Range)
27
+
28
+ type_and_subtype = pattern.match(value.to_s)&.captures
29
+ new(*type_and_subtype) if type_and_subtype&.count == 2
30
+ end
31
+
32
+ private
33
+
34
+ def pattern
35
+ @pattern ||= begin
36
+ name = '[0-9a-zA-Z-]+'
37
+ %r{(\*|#{name})/(\*|(?:#{name}(?:\.#{name})?(?:\+#{name})?))}.freeze
38
+ end
39
+ end
40
+ end
41
+
42
+ # Compares it with +other+ by +priority+.
43
+ def <=>(other)
44
+ return unless other.is_a?(self.class)
45
+
46
+ result = priority <=> other.priority
47
+ return result unless result.zero?
48
+
49
+ result = type <=> other.type
50
+ return result unless result.zero?
51
+
52
+ subtype <=> other.subtype
53
+ end
54
+
55
+ # Returns true if the given media type matches the media range.
56
+ def match?(media_type)
57
+ media_type = Type.from(media_type) unless media_type.nil?
58
+
59
+ (type == '*' || type == media_type&.type) &&
60
+ (subtype == '*' || subtype == media_type&.subtype)
61
+ end
62
+
63
+ # Returns the level of priority of the media range.
64
+ def priority
65
+ @priority ||= (type == '*' ? 2 : 0) + (subtype == '*' ? 1 : 0) + 1
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'type_and_subtype'
4
+
5
+ module Jsapi
6
+ module Media
7
+ # Represents a media type.
8
+ class Type
9
+ include Comparable
10
+ include TypeAndSubtype
11
+
12
+ # The <code>"application/json"</code> media type.
13
+ APPLICATION_JSON = Type.new('application', 'json')
14
+
15
+ # The <code>"application/json-seq"</code> media type.
16
+ APPLICATION_JSON_SEQ = Type.new('application', 'json-seq')
17
+
18
+ class << self
19
+ # Transforms +value+ to an instance of this class.
20
+ #
21
+ # Raises an ArgumentError when +value+ could not be transformed.
22
+ def from(value)
23
+ media_type = try_from(value)
24
+ return media_type unless media_type.nil?
25
+
26
+ raise ArgumentError, "invalid media type: #{value.inspect}"
27
+ end
28
+
29
+ # Tries to transform +value+ to an instance of this class. Returns nil
30
+ # if +value+ could not be transformed.
31
+ def try_from(value)
32
+ return value if value.is_a?(Type)
33
+
34
+ type_and_subtype = pattern.match(value.to_s)&.captures
35
+ new(*type_and_subtype) if type_and_subtype&.count == 2
36
+ end
37
+
38
+ private
39
+
40
+ def pattern
41
+ @pattern ||= begin
42
+ name = '[0-9a-zA-Z-]+'
43
+ %r{(#{name})/(#{name}(?:\.#{name})?(?:\+#{name})?)}.freeze
44
+ end
45
+ end
46
+ end
47
+
48
+ # Compares it with +other+ by +type+ and +subtype+.
49
+ def <=>(other)
50
+ return unless other.is_a?(self.class)
51
+
52
+ result = type <=> other.type
53
+ return result unless result.zero?
54
+
55
+ subtype <=> other.subtype
56
+ end
57
+
58
+ # Returns true if it represents a JSON media type as specified by
59
+ # https://mimesniff.spec.whatwg.org/#json-mime-type.
60
+ def json?
61
+ (type.in?(%w[application text]) && subtype == 'json') ||
62
+ subtype.end_with?('+json')
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsapi
4
+ module Media
5
+ module TypeAndSubtype # :nodoc:
6
+ def self.included(base)
7
+ base.attr_reader :type, :subtype
8
+ end
9
+
10
+ def initialize(type, subtype)
11
+ @type = type.downcase
12
+ @subtype = subtype.downcase
13
+ end
14
+
15
+ def ==(other)
16
+ other.is_a?(self.class) &&
17
+ type == other.type &&
18
+ subtype == other.subtype
19
+ end
20
+
21
+ alias eql? ==
22
+
23
+ def hash
24
+ @hash ||= [type, subtype].hash
25
+ end
26
+
27
+ def inspect
28
+ "#<#{self.class} #{to_s.inspect}>"
29
+ end
30
+
31
+ def to_s
32
+ @to_s ||= "#{type}/#{subtype}"
33
+ end
34
+
35
+ alias as_json to_s
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'media/range'
4
+ require_relative 'media/type'
@@ -8,7 +8,7 @@ module Jsapi
8
8
  ##
9
9
  # :attr: base_path
10
10
  # The base path of the API. Applies to \OpenAPI 2.0.
11
- attribute :base_path, String
11
+ attribute :base_path, Pathname
12
12
 
13
13
  ##
14
14
  # :attr: callbacks
@@ -65,6 +65,11 @@ module Jsapi
65
65
  # The reusable Parameter objects.
66
66
  attribute :parameters, { String => Parameter }, accessors: %i[reader writer]
67
67
 
68
+ ##
69
+ # :attr: paths
70
+ # The Path objects.
71
+ attribute :paths, { Pathname => Path }, accessors: %i[reader writer]
72
+
68
73
  ##
69
74
  # :attr: rescue_handlers
70
75
  # The RescueHandler objects.
@@ -138,15 +143,27 @@ module Jsapi
138
143
  @parent&.inherited(self)
139
144
  end
140
145
 
141
- def add_operation(name = nil, keywords = {}) # :nodoc:
146
+ def add_operation(name, parent_path = nil, keywords = {}) # :nodoc:
147
+ parent_path, keywords = nil, parent_path if parent_path.is_a?(Hash)
148
+
142
149
  name = name.nil? ? default_operation_name : name.to_s
143
- keywords = keywords.reverse_merge(path: default_operation_path)
144
- (@operations ||= {})[name] = Operation.new(name, keywords)
150
+ parent_path ||= default_operation_name unless keywords[:path].present?
151
+
152
+ (@operations ||= {})[name] = Operation.new(name, parent_path, keywords)
145
153
  end
146
154
 
147
155
  def add_parameter(name, keywords = {}) # :nodoc:
148
156
  name = name.to_s
149
- (@parameters ||= {})[name] = Parameter.new(name, keywords)
157
+
158
+ Parameter.new(name, keywords).tap do |parameter|
159
+ (@parameters ||= {})[name] = parameter
160
+ attribute_changed(:parameters)
161
+ end
162
+ end
163
+
164
+ def add_path(name, keywords = {}) # :nodoc:
165
+ pathname = Pathname.from(name)
166
+ (@paths ||= {})[pathname] ||= Path.new(pathname, self, keywords)
150
167
  end
151
168
 
152
169
  # Returns an array containing itself and all of the +Definitions+ instances
@@ -207,6 +224,14 @@ module Jsapi
207
224
  self
208
225
  end
209
226
 
227
+ # Resets the memoized parameters for the given path.
228
+ def invalidate_path_parameters(pathname)
229
+ pathname = Pathname.from(pathname)
230
+
231
+ @path_parameters&.delete(pathname)
232
+ each_descendant { |descendant| descendant.invalidate_path_parameters(pathname) }
233
+ end
234
+
210
235
  # Returns a hash representing the \JSON \Schema document for +name+.
211
236
  def json_schema_document(name)
212
237
  find_schema(name)&.to_json_schema&.tap do |json_schema_document|
@@ -228,23 +253,31 @@ module Jsapi
228
253
  version = OpenAPI::Version.from(version)
229
254
  operations = objects[:operations].values
230
255
 
231
- openapi_paths =
232
- operations.group_by { |operation| operation.path || default_operation_path }
233
- .transform_values do |operations_by_path|
234
- OpenAPI::PathItem.new(operations_by_path).to_openapi(version, self)
235
- end.presence
236
-
237
- openapi_objects =
238
- if version.major == 2
239
- %i[base_path external_docs info host parameters responses parameters schemas
240
- schemes security_requirements security_schemes tags]
256
+ openapi_paths = operations.group_by(&:full_path).to_h do |key, value|
257
+ [
258
+ key.to_s,
259
+ OpenAPI::PathItem.new(
260
+ value,
261
+ description: path_description(key),
262
+ parameters: path_parameters(key),
263
+ summary: path_summary(key),
264
+ servers: path_servers(key)
265
+ ).to_openapi(version, self)
266
+ ]
267
+ end.presence
268
+
269
+ openapi_objects = (
270
+ %i[external_docs info parameters responses schemas
271
+ security_requirements security_schemes tags] +
272
+ if version == OpenAPI::V2_0
273
+ %i[base_path host schemes]
241
274
  else
242
- %i[callbacks examples external_docs headers info links parameters request_bodies
243
- responses schemas security_requirements security_schemes servers tags]
244
- end.to_h { |key| [key, object_to_openapi(objects[key], version).presence] }
275
+ %i[callbacks examples headers links request_bodies servers]
276
+ end
277
+ ).index_with { |key| object_to_openapi(objects[key], version).presence }
245
278
 
246
279
  with_openapi_extensions(
247
- if version.major == 2
280
+ if version == OpenAPI::V2_0
248
281
  openapi_server = objects[:servers].first || default_server
249
282
  uri = URI(openapi_server.url) if openapi_server
250
283
  {
@@ -252,13 +285,13 @@ module Jsapi
252
285
  swagger: '2.0',
253
286
  info: openapi_objects[:info],
254
287
  host: openapi_objects[:host] || uri&.hostname,
255
- basePath: openapi_objects[:base_path] || uri&.path,
288
+ basePath: openapi_objects[:base_path]&.to_s || uri&.path,
256
289
  schemes: openapi_objects[:schemes] || Array(uri&.scheme).presence,
257
290
  consumes: operations.filter_map do |operation|
258
291
  operation.consumes(self)
259
292
  end.uniq.sort.presence,
260
293
  produces: operations.flat_map do |operation|
261
- operation.produces(self)
294
+ operation.produces(self).map(&:to_s)
262
295
  end.uniq.sort.presence,
263
296
  paths: openapi_paths,
264
297
  definitions: openapi_objects[:schemas],
@@ -269,12 +302,7 @@ module Jsapi
269
302
  else
270
303
  {
271
304
  # Order according to the OpenAPI specification 3.x
272
- openapi:
273
- case version.minor
274
- when 0 then '3.0.3'
275
- when 1 then '3.1.1'
276
- when 2 then '3.2.0'
277
- end,
305
+ openapi: version.to_s,
278
306
  info: openapi_objects[:info],
279
307
  servers:
280
308
  openapi_objects[:servers] ||
@@ -300,6 +328,44 @@ module Jsapi
300
328
  )
301
329
  end
302
330
 
331
+ ##
332
+ # :method: path_description
333
+ # :args: pathname
334
+ # Returns the most accurate description for the given path.
335
+
336
+ ##
337
+ # :method: path_servers
338
+ # :args: pathname
339
+ # Returns the most accurate Server objects for the given path.
340
+
341
+ ##
342
+ # :method: path_summary
343
+ # :args: pathname
344
+ # Returns the most accurate summary for the given path.
345
+
346
+ %i[description servers summary].each do |name|
347
+ define_method(:"path_#{name}") do |arg|
348
+ Pathname.from(arg).ancestors.each do |pathname|
349
+ ancestors.each do |ancestor|
350
+ value = ancestor.path(pathname)&.public_send(name)
351
+ return value if value.present?
352
+ end
353
+ end
354
+ nil
355
+ end
356
+ end
357
+
358
+ # Returns a hash containing the Parameter objects that are applicable to all
359
+ # operations in the given path.
360
+ # :args: pathname
361
+ def path_parameters(arg)
362
+ arg = Pathname.from(arg || '')
363
+
364
+ (@path_parameters ||= {})[arg] ||= arg.ancestors.flat_map do |pathname|
365
+ ancestors.filter_map { |ancestor| ancestor.path(pathname)&.parameters }
366
+ end.reduce(&:reverse_merge) || {}
367
+ end
368
+
303
369
  # Returns the first RescueHandler to handle +exception+, or nil if no one could be found.
304
370
  def rescue_handler_for(exception)
305
371
  objects[:rescue_handlers].find { |r| r.match?(exception) }
@@ -338,15 +404,14 @@ module Jsapi
338
404
  def invalidate_ancestors
339
405
  @ancestors = nil
340
406
  @objects = nil
341
- @children&.each(&:invalidate_ancestors)
342
- @dependent_definitions&.each(&:invalidate_ancestors)
407
+ @path_parameters = nil
408
+ each_descendant(&:invalidate_ancestors)
343
409
  end
344
410
 
345
411
  # Invalidates cached objects.
346
412
  def invalidate_objects
347
413
  @objects = nil
348
- @children&.each(&:invalidate_objects)
349
- @dependent_definitions&.each(&:invalidate_objects)
414
+ each_descendant(&:invalidate_objects)
350
415
  end
351
416
 
352
417
  private
@@ -365,10 +430,6 @@ module Jsapi
365
430
  end
366
431
  end
367
432
 
368
- def default_operation_path
369
- @default_operation_path ||= "/#{default_operation_name}"
370
- end
371
-
372
433
  def default_server
373
434
  @default_server ||=
374
435
  if (name = @owner.try(:name))
@@ -380,6 +441,11 @@ module Jsapi
380
441
  end
381
442
  end
382
443
 
444
+ def each_descendant(&block)
445
+ [*@children, *dependent_definitions].each(&block)
446
+ nil
447
+ end
448
+
383
449
  def objects
384
450
  @objects ||= ancestors.each_with_object({}) do |ancestor, objects|
385
451
  self.class.attribute_names.each do |key|
@@ -30,7 +30,9 @@ module Jsapi
30
30
  # or must be +false+.
31
31
  PRESENT = new(4)
32
32
 
33
- # Creates a new instance from +value+.
33
+ # Transforms +value+ to an instance of this class.
34
+ #
35
+ # Raises an +ArgumentError+ if +value+ could not be transformed.
34
36
  def self.from(value)
35
37
  return value if value.is_a?(Existence)
36
38
 
@@ -4,15 +4,25 @@ module Jsapi
4
4
  module Meta
5
5
  module OpenAPI
6
6
  class PathItem # :nodoc:
7
- def initialize(operations)
7
+ def initialize(operations, keywords = {})
8
8
  @operations = operations
9
+ @summary = keywords[:summary]
10
+ @description = keywords[:description]
11
+ @servers = keywords[:servers]
12
+ @parameters = keywords[:parameters]
9
13
  end
10
14
 
11
15
  def to_openapi(version, definitions)
12
16
  version = OpenAPI::Version.from(version)
13
17
 
14
18
  {}.tap do |fields|
15
- @operations.each do |operation|
19
+ if version >= OpenAPI::V3_0
20
+ fields[:summary] = @summary if @summary.present?
21
+ fields[:description] = @description if @description.present?
22
+ end
23
+
24
+ # Operations
25
+ @operations&.each do |operation|
16
26
  method = operation.method
17
27
  standardized_method = method.downcase
18
28
 
@@ -23,6 +33,18 @@ module Jsapi
23
33
  additional_operations[method] = operation.to_openapi(version, definitions)
24
34
  end
25
35
  end
36
+
37
+ # Servers
38
+ if version >= OpenAPI::V3_0 && @servers.present?
39
+ fields[:servers] = @servers.map { |server| server.to_openapi(version) }
40
+ end
41
+
42
+ # Parameters
43
+ if @parameters.present?
44
+ fields[:parameters] = @parameters.values.map do |parameter|
45
+ parameter.to_openapi(version, definitions)
46
+ end
47
+ end
26
48
  end
27
49
  end
28
50
 
@@ -6,9 +6,9 @@ module Jsapi
6
6
  class Version
7
7
  include Comparable
8
8
 
9
- # Creates an \OpenAPI version from +version+.
9
+ # Transforms +version+ to an instance of this class.
10
10
  #
11
- # Raises an +ArgumentError+ if +version+ isn`t supported.
11
+ # Raises an +ArgumentError+ if +version+ could not be transformed.
12
12
  def self.from(version)
13
13
  return version if version.is_a?(Version)
14
14
 
@@ -48,13 +48,25 @@ module Jsapi
48
48
  minor <=> other.minor
49
49
  end
50
50
 
51
- def inspect
51
+ def inspect # :nodoc:
52
52
  "<#{self.class.name} #{self}>"
53
53
  end
54
54
 
55
55
  def to_s # :nodoc:
56
- "#{major}.#{minor}"
56
+ @to_s ||=
57
+ case [major, minor]
58
+ when [3, 0]
59
+ '3.0.3'
60
+ when [3, 1]
61
+ '3.1.1'
62
+ when [3, 2]
63
+ '3.2.0'
64
+ else
65
+ "#{major}.#{minor}"
66
+ end
57
67
  end
68
+
69
+ alias as_json to_s
58
70
  end
59
71
  end
60
72
  end
@@ -46,10 +46,15 @@ module Jsapi
46
46
  # The parameters of the operation.
47
47
  attribute :parameters, { String => Parameter }, accessors: %i[reader writer]
48
48
 
49
+ ##
50
+ # :attr_reader: parent_path
51
+ # The parent path as a Pathname.
52
+ attribute :parent_path, Pathname, accessors: %i[reader]
53
+
49
54
  ##
50
55
  # :attr: path
51
- # The relative path of the operation.
52
- attribute :path, String
56
+ # The relative path of the operation as a Pathname.
57
+ attribute :path, Pathname
53
58
 
54
59
  ##
55
60
  # :attr: request_body
@@ -95,8 +100,11 @@ module Jsapi
95
100
  # The tags used to group operations in an \OpenAPI document.
96
101
  attribute :tags, [String]
97
102
 
98
- def initialize(name = nil, keywords = {})
103
+ def initialize(name, parent_path = nil, keywords = {})
104
+ parent_path, keywords = nil, parent_path if parent_path.is_a?(Hash)
105
+
99
106
  @name = name&.to_s
107
+ @parent_path = Pathname.from(parent_path)
100
108
  super(keywords)
101
109
  end
102
110
 
@@ -104,18 +112,30 @@ module Jsapi
104
112
  (@parameters ||= {})[name.to_s] = Parameter.new(name, keywords)
105
113
  end
106
114
 
107
- # Returns the MIME type consumed by the operation.
115
+ # Returns the full path of the operation as a Pathname.
116
+ def full_path
117
+ parent_path + path
118
+ end
119
+
120
+ # Returns the media type consumed by the operation.
108
121
  def consumes(definitions)
109
122
  request_body&.resolve(definitions)&.content_type
110
123
  end
111
124
 
112
- # Returns an array containing the MIME types produced by the operation.
125
+ # Returns an array containing the media types produced by the operation.
113
126
  def produces(definitions)
114
127
  responses.values.filter_map do |response|
115
128
  response.resolve(definitions).content_type
116
129
  end.uniq.sort
117
130
  end
118
131
 
132
+ # Merges the parameters of this operation and the common parameters of all
133
+ # parent pathes and resolves them.
134
+ def resolved_parameters(definitions)
135
+ (definitions.path_parameters(full_path).presence&.merge(parameters) || parameters)
136
+ .transform_values { |parameter| parameter.resolve(definitions) }
137
+ end
138
+
119
139
  # Returns a hash representing the \OpenAPI operation object.
120
140
  def to_openapi(version, definitions)
121
141
  version = OpenAPI::Version.from(version)
@@ -134,7 +154,7 @@ module Jsapi
134
154
  result[:consumes] = [consumes]
135
155
  end
136
156
  if (produces = produces(definitions)).present?
137
- result[:produces] = produces
157
+ result[:produces] = produces.map(&:to_s)
138
158
  end
139
159
  result[:schemes] = schemes if schemes.present?
140
160
  elsif servers.present?
@@ -11,7 +11,7 @@ module Jsapi
11
11
 
12
12
  ##
13
13
  # :attr: content_type
14
- # The content type used to describe complex parameters in \OpenAPI 3.0 and higher.
14
+ # The media type used to describe complex parameters in \OpenAPI 3.0 and higher.
15
15
  attribute :content_type, String
16
16
 
17
17
  ##
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsapi
4
+ module Meta
5
+ # Specifies a path.
6
+ class Path < Model::Base
7
+ ##
8
+ # :attr: description
9
+ # The description that applies to all operations in this path.
10
+ # Applies to \OpenAPI 3.0 and higher.
11
+ attribute :description, String
12
+
13
+ ##
14
+ # :attr_reader: name
15
+ # The relative path as a Pathname.
16
+ attribute :name, Pathname, accessors: %i[reader]
17
+
18
+ ##
19
+ # :attr: parameters
20
+ # The Parameter objects applicable for all operations in this path.
21
+ attribute :parameters, { String => Parameter }, accessors: %i[reader writer]
22
+
23
+ ##
24
+ # :attr_reader: owner
25
+ attribute :owner, accessors: %i[reader]
26
+
27
+ ##
28
+ # :attr: summary
29
+ # The summary that applies to all operations in this path.
30
+ # Applies to \OpenAPI 3.0 and higher.
31
+ attribute :summary, String
32
+
33
+ ##
34
+ # :attr: servers
35
+ # The Server objects that applies to all operations in this path.
36
+ # Applies to \OpenAPI 3.0 and higher.
37
+ attribute :servers, [Server]
38
+
39
+ # Creates a new path with the given name and owner.
40
+ def initialize(name, owner, keywords = {})
41
+ @name = Pathname.from(name)
42
+ @owner = owner
43
+ super(keywords)
44
+ end
45
+
46
+ def add_parameter(name, keywords = {}) # :nodoc:
47
+ name = name.to_s
48
+
49
+ Parameter.new(name, keywords).tap do |parameter|
50
+ (@parameters ||= {})[name] = parameter
51
+ @owner.try(:invalidate_path_parameters, self.name)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsapi
4
+ module Meta
5
+ # Represents a relative path name.
6
+ class Pathname
7
+ class << self
8
+ # Transforms +name+ to an instance of this class.
9
+ def from(name)
10
+ return name if name.is_a?(Pathname)
11
+
12
+ name.nil? ? new : new(name)
13
+ end
14
+ end
15
+
16
+ attr_reader :segments
17
+
18
+ delegate :hash, to: :segments
19
+
20
+ def initialize(*segments) # :nodoc:
21
+ @segments = segments.flat_map do |segment|
22
+ segment = segment.to_s.delete_prefix('/')
23
+ segment.present? ? segment.split('/', -1) : ''
24
+ end
25
+ end
26
+
27
+ def ==(other) # :nodoc:
28
+ other.is_a?(Pathname) && segments == other.segments
29
+ end
30
+
31
+ alias eql? ==
32
+
33
+ # Creates a new Pathname by appending +other+ to itself.
34
+ # Returns itself if +other+ is nil.
35
+ def +(other)
36
+ return self if other.nil?
37
+
38
+ Pathname.new(*@segments, *Pathname.from(other).segments)
39
+ end
40
+
41
+ # Returns an array containing itself and all parent pathnames.
42
+ def ancestors
43
+ @ancestors ||= @segments.count.downto(0).map do |i|
44
+ Pathname.new(*@segments[0, i])
45
+ end
46
+ end
47
+
48
+ def inspect # :nodoc:
49
+ "#<#{self.class} #{to_s.inspect}>"
50
+ end
51
+
52
+ # Returns the relative path name as a string.
53
+ def to_s
54
+ @to_s ||= @segments.presence&.each_with_index&.map do |segment, index|
55
+ index.zero? && segment.blank? ? '//' : "/#{segment}"
56
+ end&.join || '/'
57
+ end
58
+ end
59
+ end
60
+ end
@@ -7,15 +7,12 @@ module Jsapi
7
7
  class Base < Model::Base
8
8
  include OpenAPI::Extensions
9
9
 
10
- JSON_TYPE = %r{(^application/|^text/|\+)json$}.freeze # :nodoc:
11
- JSON_SEQ_TYPE = 'application/json-seq' # :nodoc:
12
-
13
10
  delegate_missing_to :schema
14
11
 
15
12
  ##
16
13
  # :attr: content_type
17
- # The content type, <code>"application/json"</code> by default.
18
- attribute :content_type, String, default: 'application/json'
14
+ # The media type of the response, <code>"application/json"</code> by default.
15
+ attribute :content_type, Media::Type, default: Media::Type::APPLICATION_JSON
19
16
 
20
17
  ##
21
18
  # :attr: description
@@ -66,17 +63,6 @@ module Jsapi
66
63
  @schema = Schema.new(keywords)
67
64
  end
68
65
 
69
- # Returns true if content type is a JSON MIME type as specified by
70
- # https://mimesniff.spec.whatwg.org/#json-mime-type.
71
- def json_type?
72
- content_type.match?(JSON_TYPE)
73
- end
74
-
75
- # Returns true if content type is <code>"application/json-seq"</code>.
76
- def json_seq_type?
77
- content_type == JSON_SEQ_TYPE
78
- end
79
-
80
66
  # Returns a hash representing the \OpenAPI response object.
81
67
  def to_openapi(version, definitions)
82
68
  version = OpenAPI::Version.from(version)
@@ -91,7 +77,7 @@ module Jsapi
91
77
  end.compact.presence,
92
78
  examples: (
93
79
  if (example = examples.values.first).present?
94
- { content_type => example.resolve(definitions).value }
80
+ { content_type.to_s => example.resolve(definitions).value }
95
81
  end
96
82
  )
97
83
  }
@@ -103,8 +89,9 @@ module Jsapi
103
89
  header.to_openapi(version)
104
90
  end.presence,
105
91
  content: {
106
- content_type => {
107
- **if json_seq_type? && schema.array? && version >= OpenAPI::V3_2
92
+ content_type.to_s => {
93
+ **if content_type == Media::Type::APPLICATION_JSON_SEQ &&
94
+ schema.array? && version >= OpenAPI::V3_2
108
95
  { itemSchema: schema.items.to_openapi(version) }
109
96
  else
110
97
  { schema: schema.to_openapi(version) }
@@ -4,6 +4,7 @@ module Jsapi
4
4
  module Meta
5
5
  module Schema
6
6
  class Boundary
7
+ # Transforms +value+ to an instance of this class.
7
8
  def self.from(value)
8
9
  case value
9
10
  when Boundary
data/lib/jsapi/meta.rb CHANGED
@@ -24,6 +24,8 @@ require_relative 'meta/property'
24
24
  require_relative 'meta/schema'
25
25
  require_relative 'meta/request_body'
26
26
  require_relative 'meta/parameter'
27
+ require_relative 'meta/pathname'
28
+ require_relative 'meta/path'
27
29
  require_relative 'meta/response'
28
30
  require_relative 'meta/operation'
29
31
  require_relative 'meta/rescue_handler'
data/lib/jsapi/version.rb CHANGED
@@ -5,6 +5,6 @@ module Jsapi
5
5
  # NOTE: See https://bundler.io/guides/creating_gem.html
6
6
 
7
7
  # The current GEM version.
8
- VERSION = '1.3'
8
+ VERSION = '1.4.1'
9
9
  end
10
10
  end
data/lib/jsapi.rb CHANGED
@@ -4,6 +4,7 @@ require 'jsapi/invalid_value_helper'
4
4
  require 'jsapi/invalid_value_error'
5
5
  require 'jsapi/invalid_argument_error'
6
6
  require 'jsapi/configuration'
7
+ require 'jsapi/media'
7
8
  require 'jsapi/model'
8
9
  require 'jsapi/meta'
9
10
  require 'jsapi/dsl'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jsapi
3
3
  version: !ruby/object:Gem::Version
4
- version: '1.3'
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Göller
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-11-02 00:00:00.000000000 Z
11
+ date: 2025-11-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email: denis@dmgoeller.de
@@ -34,6 +34,7 @@ files:
34
34
  - lib/jsapi/dsl/examples.rb
35
35
  - lib/jsapi/dsl/operation.rb
36
36
  - lib/jsapi/dsl/parameter.rb
37
+ - lib/jsapi/dsl/path.rb
37
38
  - lib/jsapi/dsl/request_body.rb
38
39
  - lib/jsapi/dsl/response.rb
39
40
  - lib/jsapi/dsl/schema.rb
@@ -49,6 +50,10 @@ files:
49
50
  - lib/jsapi/json/object.rb
50
51
  - lib/jsapi/json/string.rb
51
52
  - lib/jsapi/json/value.rb
53
+ - lib/jsapi/media.rb
54
+ - lib/jsapi/media/range.rb
55
+ - lib/jsapi/media/type.rb
56
+ - lib/jsapi/media/type_and_subtype.rb
52
57
  - lib/jsapi/meta.rb
53
58
  - lib/jsapi/meta/callable.rb
54
59
  - lib/jsapi/meta/callable/symbol_evaluator.rb
@@ -86,6 +91,8 @@ files:
86
91
  - lib/jsapi/meta/parameter.rb
87
92
  - lib/jsapi/meta/parameter/base.rb
88
93
  - lib/jsapi/meta/parameter/reference.rb
94
+ - lib/jsapi/meta/path.rb
95
+ - lib/jsapi/meta/pathname.rb
89
96
  - lib/jsapi/meta/property.rb
90
97
  - lib/jsapi/meta/reference_error.rb
91
98
  - lib/jsapi/meta/request_body.rb