skooma 0.3.8 → 0.4.0

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: 1425cf06717c3675b02d873d78da9f50b8b652748c33a86e838be37fcb164e7b
4
- data.tar.gz: 761abc98b0394bf38330010c18a8536e21cc700308e834572406d2ee2e3a02ff
3
+ metadata.gz: 908f9232ad0bab42d205ee2aee29af1dc362f33a8a2a094d831098423e2a1099
4
+ data.tar.gz: f7f001ef01777d970c2ed4997baf387b478d857913f25bbabca1d9d733a51ca3
5
5
  SHA512:
6
- metadata.gz: ffdf92bbb6d9feb0945cda5e8ff854bb883f03a3404f9ffdc753683ccebaa283f0d8c79e020fb41474b6b6ba01c1e10f4255a64b0bb635e9c16135c33b0dfdf0
7
- data.tar.gz: 7c2aef17f070a2aeeb29d7b6cf3799b885f2eb0922fa27ffe671ae2aa7607df8a0f10426b95c0bd01b05e26a941a969e186568690de745340a8c121275d1bc3f
6
+ metadata.gz: 3745de933867d3b250d0ec085313162eda5bb9e57f2db4140a2c445b44de8c180c168f4177af6fc0ac82813c0328adafa145a584ab52b648be8ba3b46c522393
7
+ data.tar.gz: 3dadbfdfeb878cbb9cbf24b07ab613ccfbaeea803381de996103968d39f02aafaebae27552b0a916abb5eef4c9b7c8f9690df07a87f238756040b9aa94c5b25b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning].
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-06-10
11
+
12
+ ### Added
13
+
14
+ - Parse `multipart/form-data` and `application/x-www-form-urlencoded` request bodies before validation. File parts are read as binary strings, so uploads validate against `type: string` / `format: binary` schemas. Fixes file upload validation failing with errors like "body id required". ([@skryukov])
15
+ - Respect the Media Type Object's `encoding` field for form bodies: fields with a JSON `contentType` are decoded before validation (multipart object-typed properties default to JSON per the spec). ([@skryukov])
16
+ - Allow passing a custom coverage store to the RSpec/Minitest helpers via `coverage_store:` — any object implementing `load_data`, `save_data`, and `clear`. Useful for writing one coverage file per parallel CI runner and merging afterwards. ([@skryukov])
17
+ - Support object-valued path parameters (`simple`, `label`, and `matrix` styles, explode-aware) and `form`-style object query parameters. Exploded form objects (`?x=1&y=2`) are gathered by matching the schema's `properties` names; non-exploded forms flatten under the parameter's name (`?point=x,1,y,2`). Properties are coerced to their declared types. ([@skryukov])
18
+ - Support for external `$ref`s in OpenAPI documents. References like `$ref: './responses.yaml#/UsersResponse'` now resolve against the spec file's directory (or any source registered on the registry) and are wrapped with the appropriate OpenAPI object type — Response, Parameter, Header, RequestBody, PathItem, or a plain JSON Schema for `schema:` refs. Chained and self-recursive external refs are supported. ([@skryukov])
19
+ - Support array-valued query parameters: respect the `style` and `explode` keywords (`form`, `spaceDelimited`, and `pipeDelimited` styles), coerce array items to the declared `items` type, and map the non-standard bracket convention (`ids[]=1&ids[]=2`) to array params. ([@dslh])
20
+ - Support object-valued query parameters declared with the `deepObject` style (`filter[id]=1&filter[name]=foo`), coercing each property to its declared type. Parameter coercion now descends into array items (positional `prefixItems` first, then `items`) and object properties (`properties` first, then `additionalProperties`), while request/response bodies keep their JSON-native types. ([@skryukov])
21
+ - Support array-valued header (`simple` style) and cookie (`form` style) parameters, splitting the delimited value and coercing each item to the declared `items` type. ([@skryukov])
22
+ - Support cookie parameters, which were previously ignored entirely. ([@skryukov])
23
+ - Support array-valued path parameters across the `simple`, `label`, and `matrix` styles (with `explode`), coercing each item to the declared `items` type. ([@skryukov])
24
+ - Parse `content`-typed parameters (e.g. `content: {application/json: …}`) using the parameter's own media type before validation, instead of validating the raw string against a media type taken from the response. **Behavior change:** such values are now decoded per their media type (e.g. JSON), so a value that previously passed as a raw string may need to be sent in its serialized form (e.g. `"100"` rather than `100` for a JSON string). ([@skryukov])
25
+
26
+ ### Changed
27
+
28
+ - Bumped minimum `json_skooma` to `~> 0.2.7` for the new typed `UnexpectedSchemaClassError` and public `Registry#load_json` (0.2.6 was yanked due to a packaging issue). ([@skryukov])
29
+
10
30
  ## [0.3.8] - 2026-04-16
11
31
 
12
32
  ### Fixed
@@ -183,6 +203,7 @@ and this project adheres to [Semantic Versioning].
183
203
  [@aburgel]: https://github.com/aburgel
184
204
  [@alexkalderimis]: https://github.com/alexkalderimis
185
205
  [@barnaclebarnes]: https://github.com/barnaclebarnes
206
+ [@dslh]: https://github.com/dslh
186
207
  [@Envek]: https://github.com/Envek
187
208
  [@goodtouch]: https://github.com/goodtouch
188
209
  [@jandouwebeekman]: https://github.com/jandouwebeekman
@@ -192,7 +213,8 @@ and this project adheres to [Semantic Versioning].
192
213
  [@ursm]: https://github.com/ursm
193
214
  [@visini]: https://github.com/visini
194
215
 
195
- [Unreleased]: https://github.com/skryukov/skooma/compare/v0.3.8...HEAD
216
+ [Unreleased]: https://github.com/skryukov/skooma/compare/v0.4.0...HEAD
217
+ [0.4.0]: https://github.com/skryukov/skooma/compare/v0.3.8...v0.4.0
196
218
  [0.3.8]: https://github.com/skryukov/skooma/compare/v0.3.7...v0.3.8
197
219
  [0.3.7]: https://github.com/skryukov/skooma/compare/v0.3.6...v0.3.7
198
220
  [0.3.6]: https://github.com/skryukov/skooma/compare/v0.3.5...v0.3.6
data/README.md CHANGED
@@ -55,6 +55,14 @@ RSpec.configure do |config|
55
55
  # To enable coverage, pass `coverage: :report` option,
56
56
  # and to raise an error when an operation is not covered, pass `coverage: :strict` option:
57
57
  config.include Skooma::RSpec[path_to_openapi, coverage: :report], type: :request
58
+
59
+ # To control where coverage data is stored (e.g. one file per parallel CI runner),
60
+ # pass a custom store via `coverage_store:` — any object implementing
61
+ # `load_data`, `save_data(defined, covered)`, and `clear` works.
62
+ # Treat the path entries as opaque values: their shape may gain dimensions
63
+ # (e.g. content type) in future versions.
64
+ store = Skooma::CoverageStore.new(file_path: "tmp/skooma_coverage_#{ENV["TEST_ENV_NUMBER"]}.json")
65
+ config.include Skooma::RSpec[path_to_openapi, coverage: :report, coverage_store: store], type: :request
58
66
  end
59
67
  ```
60
68
 
@@ -129,6 +137,10 @@ ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, path_p
129
137
  # and to raise an error when an operation is not covered, pass `coverage: :strict` option:
130
138
  ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, coverage: :report], type: :request
131
139
 
140
+ # To control where coverage data is stored, pass a custom store via `coverage_store:`:
141
+ store = Skooma::CoverageStore.new(file_path: "tmp/skooma_coverage_#{ENV["TEST_ENV_NUMBER"]}.json")
142
+ ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, coverage: :report, coverage_store: store], type: :request
143
+
132
144
  # EXPERIMENTAL
133
145
  # To enable support for readOnly and writeOnly keywords, pass `enforce_access_modes: true` option:
134
146
  ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, enforce_access_modes: true], type: :request
@@ -176,6 +188,165 @@ class ItemsTest < ActionDispatch::IntegrationTest
176
188
  end
177
189
  ```
178
190
 
191
+ ### Splitting specs across files
192
+
193
+ Skooma resolves external `$ref`s relative to the OpenAPI document's directory, so you can split a large spec into multiple files:
194
+
195
+ ```yaml
196
+ # docs/openapi.yaml
197
+ paths:
198
+ /users:
199
+ get:
200
+ responses:
201
+ '200':
202
+ $ref: './responses.yaml#/UsersResponse'
203
+ /health:
204
+ $ref: './paths.yaml#/Health'
205
+ ```
206
+
207
+ References are wrapped with the appropriate OpenAPI object type based on context — `$ref` inside `responses` loads as a Response, inside `parameters` as a Parameter, inside `schema` as a JSON Schema, and so on. Chained (A → B → C) and self-recursive schemas work out of the box.
208
+
209
+ Only local files are resolved by default. To load refs from other sources (e.g. HTTP), register a source on the registry:
210
+
211
+ ```ruby
212
+ schema = Skooma::Minitest[path_to_openapi].schema
213
+ schema.registry.add_source(
214
+ "https://example.com/schemas/",
215
+ JSONSkooma::Sources::Remote.new("https://example.com/schemas/")
216
+ )
217
+ ```
218
+
219
+ ### Array query parameters
220
+
221
+ Skooma deserializes array-valued query parameters according to their `style` and `explode`
222
+ keywords (`form`, `spaceDelimited`, and `pipeDelimited` styles are supported),
223
+ and coerces each item to the type declared in the `items` schema:
224
+
225
+ ```yaml
226
+ - in: query
227
+ name: ids
228
+ schema:
229
+ type: array
230
+ items:
231
+ type: integer
232
+ ```
233
+
234
+ ```
235
+ GET /things?ids=1&ids=2&ids=3 # ids => [1, 2, 3]
236
+ ```
237
+
238
+ As a convenience, the non-standard Rails/Rack bracket convention is also recognized:
239
+ `GET /things?ids[]=1&ids[]=2` matches the array parameter named `ids`.
240
+ The bracket form is only used when the parameter declares `type: array` and
241
+ the query string contains no exact `ids` key.
242
+
243
+ ### Object query parameters
244
+
245
+ Skooma deserializes object-valued query parameters declared with the `deepObject`
246
+ style, gathering the bracketed members and coercing each property to the type
247
+ declared in its `properties` schema:
248
+
249
+ ```yaml
250
+ - in: query
251
+ name: filter
252
+ style: deepObject
253
+ explode: true
254
+ schema:
255
+ type: object
256
+ properties:
257
+ id:
258
+ type: integer
259
+ name:
260
+ type: string
261
+ ```
262
+
263
+ ```
264
+ GET /things?filter[id]=1&filter[name]=foo # filter => { "id" => 1, "name" => "foo" }
265
+ ```
266
+
267
+ The default `form` style is also supported. Exploded form objects (the default)
268
+ drop the parameter name — each property declared in the schema becomes its own
269
+ query key — while non-exploded objects flatten under the parameter's name:
270
+
271
+ ```
272
+ GET /things?x=1&y=2 # form, explode: point => { "x" => 1, "y" => 2 }
273
+ GET /things?point=x,1,y,2 # form: point => { "x" => 1, "y" => 2 }
274
+ ```
275
+
276
+ Note that exploded form objects are gathered by matching the schema's
277
+ `properties` names, so members allowed only via `additionalProperties` are not
278
+ recognized.
279
+
280
+ ### Header and cookie parameters
281
+
282
+ Array-valued header (`simple` style) and cookie (`form` style) parameters are
283
+ deserialized from their delimited form and each item is coerced to the declared
284
+ `items` type:
285
+
286
+ ```
287
+ X-Ids: 1,2,3 # X-Ids => [1, 2, 3]
288
+ Cookie: ids=1,2,3 # ids => [1, 2, 3]
289
+ ```
290
+
291
+ ### Path parameters
292
+
293
+ Array-valued path parameters are deserialized according to their `style`
294
+ (`simple`, `label`, `matrix`) and `explode` keywords, with each item coerced to
295
+ the declared `items` type:
296
+
297
+ ```
298
+ GET /things/1,2,3 # simple: id => [1, 2, 3]
299
+ GET /things/.1.2.3 # label, explode: id => [1, 2, 3]
300
+ GET /things/;id=1;id=2 # matrix, explode: id => [1, 2]
301
+ ```
302
+
303
+ Object-valued path parameters flatten their properties into the segment and are
304
+ rebuilt with each property coerced via the `properties` schema:
305
+
306
+ ```
307
+ GET /points/x,1,y,2 # simple: point => { "x" => 1, "y" => 2 }
308
+ GET /points/x=1,y=2 # simple, explode: point => { "x" => 1, "y" => 2 }
309
+ GET /points/;x=1;y=2 # matrix, explode: point => { "x" => 1, "y" => 2 }
310
+ ```
311
+
312
+ ### Form and multipart request bodies
313
+
314
+ `application/x-www-form-urlencoded` and `multipart/form-data` request bodies
315
+ are parsed before validation, so file uploads validate against their OpenAPI
316
+ schemas. File parts are read as binary strings, matching
317
+ `type: string` / `format: binary` properties:
318
+
319
+ ```yaml
320
+ requestBody:
321
+ content:
322
+ multipart/form-data:
323
+ schema:
324
+ type: object
325
+ properties:
326
+ id: {type: string}
327
+ file: {type: string, format: binary}
328
+ ```
329
+
330
+ The Media Type Object's `encoding` field is respected: a multipart or
331
+ urlencoded field whose `contentType` is a JSON media type is decoded before
332
+ validation, so its schema validates the structured value. For multipart
333
+ bodies, object-typed properties default to JSON decoding per the spec:
334
+
335
+ ```yaml
336
+ content:
337
+ multipart/form-data:
338
+ schema:
339
+ type: object
340
+ properties:
341
+ meta: {type: object} # decoded as JSON (spec default)
342
+ file: {type: string, format: binary}
343
+ encoding:
344
+ meta: {contentType: application/json}
345
+ ```
346
+
347
+ Custom parsers for other media types can be registered via
348
+ `Skooma::BodyParsers.register("application/xml", ->(body, headers:) { ... })`.
349
+
179
350
  ## Alternatives
180
351
 
181
352
  - [openapi_first](https://github.com/ahx/openapi_first)
@@ -183,9 +354,7 @@ end
183
354
 
184
355
  ## Feature plans
185
356
 
186
- - Full support for external `$ref`s
187
357
  - Full OpenAPI 3.1.0 support:
188
- - respect `style` and `explode` keywords
189
358
  - xml
190
359
  - Callbacks and webhooks validations
191
360
  - Example validations
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rack/utils"
4
+ require "rack/multipart"
5
+
3
6
  module Skooma
4
7
  module BodyParsers
5
8
  class << self
@@ -49,5 +52,58 @@ module Skooma
49
52
  end
50
53
  end
51
54
  register "application/json", JSONParser
55
+
56
+ module FormURLEncodedParser
57
+ def self.call(body, **_options)
58
+ Rack::Utils.parse_nested_query(body)
59
+ # The set of parse error classes differs across Rack 2/3; a malformed
60
+ # body falls back to the raw string so schema validation rejects it.
61
+ rescue
62
+ body
63
+ end
64
+ end
65
+ register "application/x-www-form-urlencoded", FormURLEncodedParser
66
+
67
+ # Parses multipart bodies with Rack's multipart parser (the boundary is
68
+ # taken from the request's own Content-Type header). File parts are
69
+ # replaced by their content read as a binary string, so they validate
70
+ # against `type: string` / `format: binary` schemas.
71
+ module MultipartParser
72
+ def self.call(body, headers: nil, **_options)
73
+ content_type = headers && headers["Content-Type"]&.value
74
+ return body if content_type.nil? || body.nil?
75
+
76
+ input = StringIO.new(body.to_s.dup.force_encoding(Encoding::BINARY))
77
+ params = Rack::Multipart.parse_multipart(
78
+ "CONTENT_TYPE" => content_type,
79
+ "CONTENT_LENGTH" => input.size.to_s,
80
+ "rack.input" => input
81
+ )
82
+ return body if params.nil?
83
+
84
+ simplify(params)
85
+ # Rack 2/3 raise different errors on malformed multipart (Multipart::Error,
86
+ # EOFError, ...); fall back to the raw string so validation rejects it.
87
+ rescue
88
+ body
89
+ end
90
+
91
+ def self.simplify(value)
92
+ case value
93
+ when Hash
94
+ if value.key?(:tempfile)
95
+ value[:tempfile].read.force_encoding(Encoding::BINARY)
96
+ else
97
+ value.transform_values { |member| simplify(member) }
98
+ end
99
+ when Array
100
+ value.map { |member| simplify(member) }
101
+ else
102
+ value
103
+ end
104
+ end
105
+ private_class_method :simplify
106
+ end
107
+ register "multipart/form-data", MultipartParser
52
108
  end
53
109
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skooma
4
+ # External $ref resolution: upstream treats a referenced document as a single
5
+ # JSONSchema and errors out when the fragment points at a non-schema node
6
+ # inside a raw map (e.g. `responses.yaml#/Users`). Here we load the raw
7
+ # document, navigate to the fragment, and wrap the subtree as the referrer's
8
+ # own class so OpenAPI semantics are preserved.
9
+ module ExternalRefs
10
+ def resolve_ref(uri)
11
+ super
12
+ rescue JSONSkooma::UnexpectedSchemaClassError
13
+ load_external_ref(resolve_uri(uri))
14
+ end
15
+
16
+ private
17
+
18
+ def load_external_ref(resolved_uri)
19
+ base_uri = resolved_uri.dup.tap { |u| u.fragment = nil }
20
+ raw_doc = registry.load_json(base_uri)
21
+
22
+ data =
23
+ if resolved_uri.fragment.nil? || resolved_uri.fragment.empty?
24
+ raw_doc
25
+ else
26
+ JSONSkooma::JSONPointer.new(resolved_uri.fragment).eval(raw_doc)
27
+ end
28
+
29
+ raise JSONSkooma::RegistryError, "Could not resolve $ref #{resolved_uri}" if data.nil?
30
+
31
+ wrap_external_data(data, resolved_uri)
32
+ end
33
+
34
+ def wrap_external_data(data, uri)
35
+ wrapped = self.class.new(
36
+ data,
37
+ parent: root,
38
+ registry: registry,
39
+ cache_id: cache_id,
40
+ uri: uri,
41
+ metaschema_uri: metaschema_uri
42
+ )
43
+ wrapped.resolve_references
44
+ wrapped
45
+ end
46
+ end
47
+ end
@@ -7,6 +7,10 @@ module Skooma
7
7
  value = self&.value
8
8
  return self if value.nil?
9
9
 
10
+ Coercible.coerce_value(value, json)
11
+ end
12
+
13
+ def self.coerce_value(value, json)
10
14
  case json["type"]
11
15
  when "integer"
12
16
  begin
@@ -28,12 +32,77 @@ module Skooma
28
32
  value
29
33
  # convert_object(value, schema)
30
34
  when "array"
31
- # convert_array(value, schema)
32
- value
35
+ coerce_array_items(value, json["items"])
33
36
  else
34
37
  value
35
38
  end
36
39
  end
40
+
41
+ def self.coerce_array_items(value, items_schema)
42
+ return value unless value.is_a?(Array)
43
+ return value unless schema_object?(items_schema)
44
+
45
+ value.map { |item| item.is_a?(String) ? coerce_value(item, items_schema) : item }
46
+ end
47
+
48
+ # Recursive coercion for *parameters* only. Parameter values arrive as
49
+ # all-strings, so we descend into array items (positional `prefixItems`
50
+ # first, then `items`) and object properties (`properties` first, then
51
+ # `additionalProperties`), coercing each to its declared type.
52
+ # Request/response *bodies* are only shallowly coerced (top level, via
53
+ # `coerce` above), so a *nested* JSON body value like `{"count": "5"}`
54
+ # against `integer` stays invalid.
55
+ def deep_coerce(json)
56
+ return if value.nil?
57
+
58
+ Coercible.deep_coerce_value(value, json)
59
+ end
60
+
61
+ def self.deep_coerce_value(value, json)
62
+ return value if value.nil?
63
+
64
+ case json["type"]
65
+ when "array"
66
+ return value unless value.is_a?(Array)
67
+
68
+ prefix_items = json["prefixItems"]
69
+ items = json["items"]
70
+
71
+ value.map.with_index do |item, index|
72
+ schema = prefix_schema(prefix_items, index) || items
73
+ schema_object?(schema) ? deep_coerce_value(item, schema) : item
74
+ end
75
+ when "object"
76
+ return value unless value.is_a?(Hash)
77
+
78
+ properties = json["properties"]
79
+ additional_properties = json["additionalProperties"]
80
+
81
+ value.each_with_object({}) do |(key, item), coerced|
82
+ schema = properties.respond_to?(:key?) ? properties[key] : nil
83
+ schema ||= additional_properties
84
+ coerced[key] = schema_object?(schema) ? deep_coerce_value(item, schema) : item
85
+ end
86
+ else
87
+ # Scalar schema: only scalar values are coercible. A structural value
88
+ # here means a type mismatch (e.g. `deepObject` against a scalar
89
+ # schema) — leave it for validation to reject rather than coerce.
90
+ (value.is_a?(Hash) || value.is_a?(Array)) ? value : coerce_value(value, json)
91
+ end
92
+ end
93
+
94
+ # True when `node` is a real subschema (a JSON object), excluding boolean
95
+ # schemas like `items: true`.
96
+ def self.schema_object?(node)
97
+ node.is_a?(JSONSkooma::JSONSchema) && node.type == "object"
98
+ end
99
+
100
+ # The positional subschema for `index` when `prefixItems` is present.
101
+ def self.prefix_schema(prefix_items, index)
102
+ return unless prefix_items.is_a?(JSONSkooma::JSONNode) && prefix_items.type == "array"
103
+
104
+ prefix_items[index]
105
+ end
37
106
  end
38
107
 
39
108
  class Attribute < JSONSkooma::JSONNode
@@ -22,7 +22,7 @@ module Skooma
22
22
  properties_result = result.sibling(instance, "properties")
23
23
  instance.each_key do |name|
24
24
  res = properties_result&.children&.[](instance[name]&.path)&.[]name
25
- forbidden << name.tap { puts "adding #{name}" } if res && annotation_exists?(res, key: only_key)
25
+ forbidden << name if res && annotation_exists?(res, key: only_key)
26
26
  end
27
27
  end
28
28
 
@@ -50,7 +50,7 @@ module Skooma
50
50
  def annotation_exists?(result, key:)
51
51
  return result if result.key == key && result.annotation
52
52
 
53
- result.each_children do |child|
53
+ result.children[result.instance.path]&.each_value do |child|
54
54
  return child if annotation_exists?(child, key: key)
55
55
  end
56
56
 
@@ -17,7 +17,7 @@ module Skooma
17
17
  private
18
18
 
19
19
  def wrap_value(value)
20
- JSONSkooma::JSONSchema.new(
20
+ Objects::Schema.new(
21
21
  value,
22
22
  key: key,
23
23
  parent: parent_schema,
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rack/utils"
4
+
3
5
  module Skooma
4
6
  module Matchers
5
7
  class ConformResponseSchema < ConformRequestSchema
@@ -37,7 +39,7 @@ module Skooma
37
39
  private
38
40
 
39
41
  def status_matches?
40
- @mapped_response["response"]["status"] == @expected
42
+ @mapped_response["response"]["status"] == @expected_code
41
43
  end
42
44
  end
43
45
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "pathname"
4
5
 
5
6
  module Skooma
@@ -44,7 +45,7 @@ module Skooma
44
45
  end
45
46
  end
46
47
 
47
- def initialize(helper_methods_module, openapi_path, base_uri: "https://skoomarb.dev/", path_prefix: "", enforce_access_modes: false, use_patterns_for_path_matching: false, **params)
48
+ def initialize(helper_methods_module, openapi_path, base_uri: "https://skoomarb.dev/", path_prefix: "", enforce_access_modes: false, use_patterns_for_path_matching: false, coverage_store: nil, **params)
48
49
  super()
49
50
 
50
51
  registry = create_test_registry
@@ -59,7 +60,10 @@ module Skooma
59
60
  @schema.path_prefix = path_prefix
60
61
  @schema.enforce_access_modes = enforce_access_modes
61
62
 
62
- storage = Skooma::CoverageStore.new(
63
+ # Any object implementing CoverageStore's interface (load_data,
64
+ # save_data, clear) can be passed via `coverage_store:` — e.g. to
65
+ # write one file per parallel CI runner and merge afterwards.
66
+ storage = coverage_store || Skooma::CoverageStore.new(
63
67
  file_path: File.join(Dir.pwd, "tmp", "skooma_coverage_#{Digest::SHA256.hexdigest(source_uri)[0..8]}.json")
64
68
  )
65
69
  @coverage = Coverage.new(@schema, mode: params[:coverage], format: params[:coverage_format], storage: storage)
@@ -3,6 +3,8 @@
3
3
  module Skooma
4
4
  module Objects
5
5
  class Base < JSONSkooma::JSONSchema
6
+ include ExternalRefs
7
+
6
8
  DEFAULT_OPTIONS = {
7
9
  registry: REGISTRY_NAME
8
10
  }.freeze
@@ -6,9 +6,87 @@ module Skooma
6
6
  class MediaType < Base
7
7
  def kw_classes
8
8
  [
9
- Keywords::OAS31::Schema
9
+ Keywords::Schema
10
10
  ]
11
11
  end
12
+
13
+ module Keywords
14
+ # Applies the Media Type Object's `encoding` field before schema
15
+ # validation. For `multipart/*` and `application/x-www-form-urlencoded`
16
+ # bodies, fields arrive as raw strings; a field whose `contentType` is
17
+ # a JSON media type (explicitly via `encoding`, or by the multipart
18
+ # default for object-typed properties) is decoded first, so its schema
19
+ # validates the structured value rather than the serialized string.
20
+ class Schema < Skooma::Keywords::OAS31::Schema
21
+ self.key = "schema"
22
+
23
+ URLENCODED = "application/x-www-form-urlencoded"
24
+
25
+ def evaluate(instance, result)
26
+ super(apply_encoding(instance), result)
27
+ end
28
+
29
+ private
30
+
31
+ def apply_encoding(instance)
32
+ return instance unless form_media_type?
33
+
34
+ value = instance.value
35
+ return instance unless value.is_a?(Hash)
36
+
37
+ encoding = parent_schema["encoding"]
38
+ decoded = value.each_with_object({}) do |(field, field_value), hash|
39
+ hash[field] = decode_field(field, field_value, encoding)
40
+ end
41
+
42
+ Instance::Attribute.new(decoded, key: instance.key, parent: instance.parent)
43
+ end
44
+
45
+ def media_type
46
+ parent_schema.key.to_s.split(";").first.to_s.strip.downcase
47
+ end
48
+
49
+ def form_media_type?
50
+ media_type.start_with?("multipart/") || media_type == URLENCODED
51
+ end
52
+
53
+ def decode_field(field, value, encoding)
54
+ content_type = field_content_type(field, encoding)
55
+ return value unless json_media_type?(content_type)
56
+
57
+ parser = BodyParsers[content_type]
58
+ case value
59
+ when String
60
+ parser.call(value)
61
+ when Array
62
+ value.map { |item| item.is_a?(String) ? parser.call(item) : item }
63
+ else
64
+ value
65
+ end
66
+ end
67
+
68
+ def field_content_type(field, encoding)
69
+ explicit = encoding&.[](field)&.[]("contentType")&.value
70
+ return explicit if explicit
71
+
72
+ # The spec's default contentType for multipart object-typed
73
+ # properties is application/json.
74
+ return unless media_type.start_with?("multipart/")
75
+
76
+ property = json["properties"]&.[](field)
77
+ return unless property.is_a?(JSONSkooma::JSONSchema)
78
+
79
+ "application/json" if property["type"]&.value == "object"
80
+ end
81
+
82
+ def json_media_type?(content_type)
83
+ return false if content_type.nil?
84
+
85
+ normalized = content_type.split(";").first.to_s.strip.downcase
86
+ normalized == "application/json" || normalized.end_with?("+json")
87
+ end
88
+ end
89
+ end
12
90
  end
13
91
  end
14
92
  end
@@ -13,7 +13,25 @@ module Skooma
13
13
  def evaluate(instance, result)
14
14
  return if instance.value.nil?
15
15
 
16
- super(ValueParser.call(instance, result), result)
16
+ value = ValueParser.call(instance, result)
17
+ return result.discard if value.nil?
18
+
19
+ # A parameter's `content` declares the media type its value is
20
+ # serialized in (typically exactly one entry). Parse the raw value
21
+ # with the matching body parser, then validate the parsed value
22
+ # against that media type's schema. (The header version picks the
23
+ # media type from the response Content-Type, which is wrong here.)
24
+ json.each do |media_type, media_type_object|
25
+ parsed = Instance::Attribute.new(
26
+ BodyParsers[media_type].call(value.value),
27
+ key: value.key,
28
+ parent: value.parent
29
+ )
30
+ result.call(parsed, media_type) do |media_type_result|
31
+ media_type_object.evaluate(parsed, media_type_result)
32
+ result.failure("Invalid content for media type #{media_type}") unless media_type_result.passed?
33
+ end
34
+ end
17
35
  end
18
36
  end
19
37
  end
@@ -9,7 +9,10 @@ module Skooma
9
9
  self.depends_on = %w[in name style explode allowReserved allowEmptyValue]
10
10
 
11
11
  def evaluate(instance, result)
12
- if json.value && ValueParser.call(instance, result)&.value.nil?
12
+ return unless json.value
13
+
14
+ value = ValueParser.call(instance, result, schema: parent_schema["schema"])
15
+ if value&.value.nil?
13
16
  result.failure("Parameter is required")
14
17
  end
15
18
  end
@@ -9,10 +9,13 @@ module Skooma
9
9
  self.depends_on = %w[in name style explode allowReserved allowEmptyValue]
10
10
 
11
11
  def evaluate(instance, result)
12
- value = ValueParser.call(instance, result)
12
+ value = ValueParser.call(instance, result, schema: json)
13
13
  return result.discard if value.nil?
14
14
 
15
- super(value, result)
15
+ # Parameters coerce deeply (their values arrive as all-strings);
16
+ # bodies keep OAS31::Schema's shallow `coerce`, so body validation
17
+ # is unaffected.
18
+ json.evaluate(value.deep_coerce(json), result)
16
19
  end
17
20
  end
18
21
  end
@@ -7,72 +7,302 @@ module Skooma
7
7
  class Parameter
8
8
  module Keywords
9
9
  module ValueParser
10
+ # https://spec.openapis.org/oas/v3.1.0#style-values
11
+ ARRAY_STYLE_DELIMITERS = {
12
+ "form" => ",",
13
+ "simple" => ",",
14
+ "spaceDelimited" => " ",
15
+ "pipeDelimited" => "|"
16
+ }.freeze
17
+
18
+ # Default `style` per parameter location (OpenAPI 3.1 parameter object).
19
+ DEFAULT_STYLE = {
20
+ "query" => "form",
21
+ "path" => "simple",
22
+ "header" => "simple",
23
+ "cookie" => "form"
24
+ }.freeze
25
+
10
26
  class << self
11
- def call(instance, result)
12
- type = result.sibling(instance, "in")&.annotation
13
- raise Error, "Missing `in` key #{result.path}" unless type
27
+ def call(instance, result, schema: nil)
28
+ location = result.sibling(instance, "in")&.annotation
29
+ raise Error, "Missing `in` key #{result.path}" unless location
14
30
 
15
31
  key = result.sibling(instance, "name")&.annotation
16
32
  raise Error, "Missing `name` key #{result.path}" unless key
17
33
 
18
- case type
34
+ case location
19
35
  when "query"
20
- parse_query(instance)[key]
36
+ query_param_value(instance, result, key, schema: schema)
21
37
  when "header"
22
- instance["headers"][key]
38
+ header_param_value(instance, result, key, schema: schema)
23
39
  when "path"
24
- path_item_result = result.parent
25
- path_item_result = path_item_result.parent until path_item_result.key.start_with?("/")
26
-
27
- Instance::Attribute.new(
28
- path_item_result.annotation["path_attributes"][key],
29
- key: "path",
30
- parent: instance["path"]
31
- )
40
+ path_param_value(instance, result, key, schema: schema)
32
41
  when "cookie"
33
- # instance["headers"]["Cookie"]
42
+ cookie_param_value(instance, result, key, schema: schema)
34
43
  else
35
- raise Error, "Unknown location: #{type}"
44
+ raise Error, "Unknown location: #{location}"
36
45
  end
37
46
  end
38
47
 
39
48
  private
40
49
 
41
- def parse_query(instance)
42
- params = {}
43
- instance["query"]&.value.to_s.split(/[&;]/).each do |pairs|
44
- key, value = pairs.split("=", 2).collect { |v| CGI.unescape(v) }
45
- next unless key
50
+ # The declared shape of the parameter value: :array, :object, or
51
+ # :primitive (scalars, missing schemas, and `content` params).
52
+ def shape_of(schema)
53
+ return :primitive unless schema.is_a?(JSONSkooma::JSONSchema) && schema.type == "object"
54
+
55
+ case schema["type"]&.value
56
+ when "array" then :array
57
+ when "object" then :object
58
+ else :primitive
59
+ end
60
+ end
61
+
62
+ def query_param_value(instance, result, key, schema:)
63
+ shape = shape_of(schema)
46
64
 
47
- params[key] = value
65
+ # `deepObject` spreads an object across bracketed keys
66
+ # (`filter[a]=1&filter[b]=2`) and is the only style that consumes
67
+ # more than one query key, so it is handled before the scalar/array
68
+ # path.
69
+ if result.sibling(instance, "style")&.annotation == "deepObject"
70
+ return deep_object_value(instance, key)
48
71
  end
49
72
 
73
+ if shape == :object
74
+ return form_object_value(instance, result, key, schema)
75
+ end
76
+
77
+ array = shape == :array
78
+ params = parse_query(instance["query"]&.value)
79
+ values = params[key]
80
+ # Support the non-standard Rails/Rack/PHP bracket convention
81
+ # (`ids[]=1&ids[]=2`) for array params declared as `name: ids`.
82
+ values = params["#{key}[]"] if values.nil? && array
83
+ return nil if values.nil?
84
+
85
+ value =
86
+ if array && !values.last.nil?
87
+ deserialize_array(values, instance, result)
88
+ else
89
+ # the last value wins for scalars and value-less keys (e.g. `?ids`)
90
+ values.last
91
+ end
92
+
93
+ Instance::Attribute.new(value, key: key, parent: instance["query"])
94
+ end
95
+
96
+ # Header values are a single string; an array is delimited inline
97
+ # (`simple` style, comma-separated). `explode` does not change the
98
+ # serialization for headers, so it is not consulted. Object-valued
99
+ # headers are out of scope.
100
+ def header_param_value(instance, result, key, schema:)
101
+ attribute = instance["headers"][key]
102
+ return attribute unless shape_of(schema) == :array
103
+ return attribute if attribute.nil? || attribute.value.nil?
104
+
50
105
  Instance::Attribute.new(
51
- params,
52
- key: "query",
53
- parent: instance["query"]
106
+ split_delimited(attribute.value, style_for(result, instance, "header")),
107
+ key: key,
108
+ parent: instance["headers"]
54
109
  )
55
110
  end
56
111
 
57
- def style(value, instance, result)
58
- case result.sibling(instance, "style")
59
- when "simple"
60
- value
112
+ # Path values are a single captured segment. `simple` (the default)
113
+ # is comma-delimited; `label` carries a `.` prefix and `matrix` a
114
+ # `;name=` prefix, and for arrays `explode` switches the item
115
+ # separator. Objects flatten into the segment as key/value pairs —
116
+ # comma-interleaved (`role,admin`) or `=`-joined (`role=admin`)
117
+ # when exploded.
118
+ def path_param_value(instance, result, key, schema:)
119
+ raw = path_attributes(result)[key]
120
+ return nil if raw.nil?
121
+
122
+ style = style_for(result, instance, "path")
123
+ explode = result.sibling(instance, "explode")&.annotation || false
124
+ value =
125
+ case shape_of(schema)
126
+ when :array
127
+ deserialize_path_array(raw, key, style, explode)
128
+ when :object
129
+ deserialize_path_object(raw, key, style, explode)
130
+ else
131
+ strip_path_prefix(raw, key, style)
132
+ end
133
+
134
+ Instance::Attribute.new(value, key: "path", parent: instance["path"])
135
+ end
136
+
137
+ def path_attributes(result)
138
+ path_item_result = result.parent
139
+ path_item_result = path_item_result.parent until path_item_result.key.start_with?("/")
140
+ path_item_result.annotation["path_attributes"]
141
+ end
142
+
143
+ def strip_path_prefix(raw, name, style)
144
+ case style
145
+ when "label" then raw.delete_prefix(".")
146
+ when "matrix" then raw.delete_prefix(";#{name}=")
147
+ else raw
148
+ end
149
+ end
150
+
151
+ def deserialize_path_array(raw, name, style, explode)
152
+ case style
61
153
  when "label"
62
- value.split(".")
154
+ strip_path_prefix(raw, name, style).split(explode ? "." : ",")
63
155
  when "matrix"
64
- value.split(";")
65
- when "form"
66
- value.split("&")
67
- when "spaceDelimited"
68
- value.split(" ")
69
- when "pipeDelimited"
70
- value.split("|")
71
- when "deepObject"
72
- raise Error, "Not implemented yet"
156
+ if explode
157
+ # `;id=3;id=4;id=5` — keep only this parameter's pairs
158
+ raw.delete_prefix(";").split(";").map { |pair|
159
+ pair_name, pair_value = pair.split("=", 2)
160
+ pair_value if pair_name == name
161
+ }.compact
162
+ else
163
+ # `;id=3,4,5`
164
+ strip_path_prefix(raw, name, style).split(",")
165
+ end
166
+ else # simple: `3,4,5`
167
+ raw.split(",")
168
+ end
169
+ end
170
+
171
+ # Object path params flatten properties into the segment:
172
+ # simple: `role,admin,name,Alex`
173
+ # simple explode: `role=admin,name=Alex`
174
+ # label: `.role,admin,name,Alex`
175
+ # label explode: `.role=admin.name=Alex`
176
+ # matrix: `;point=role,admin,name,Alex`
177
+ # matrix explode: `;role=admin;name=Alex`
178
+ # A malformed flattening (odd member count, missing `=`) returns the
179
+ # raw string so schema validation rejects it.
180
+ def deserialize_path_object(raw, name, style, explode)
181
+ members =
182
+ if explode
183
+ body = (style == "matrix") ? raw.delete_prefix(";") : strip_path_prefix(raw, name, style)
184
+ separator = {"label" => ".", "matrix" => ";"}.fetch(style, ",")
185
+ body.split(separator).map { |pair| pair.split("=", 2) }
186
+ else
187
+ strip_path_prefix(raw, name, style).split(",").each_slice(2).to_a
188
+ end
189
+
190
+ return raw unless members.all? { |pair| pair.size == 2 }
191
+
192
+ members.to_h
193
+ end
194
+
195
+ # Cookies carry one string per name; an array is a delimited inline
196
+ # value (`form` style, comma-separated). `explode` is not consulted
197
+ # because cookies cannot repeat a name. Object-valued cookies are
198
+ # out of scope.
199
+ def cookie_param_value(instance, result, key, schema:)
200
+ raw = parse_cookies(instance["headers"]["Cookie"]&.value)[key]
201
+ return nil if raw.nil?
202
+
203
+ value = (shape_of(schema) == :array) ? split_delimited(raw, style_for(result, instance, "cookie")) : raw
204
+ Instance::Attribute.new(value, key: key, parent: instance["headers"])
205
+ end
206
+
207
+ # deepObject: gather the bracketed properties of `key` into a hash.
208
+ # Property values stay strings; type coercion happens later via
209
+ # Instance#deep_coerce against the object's `properties` schema.
210
+ # (Nested objects and array-valued properties are out of scope.)
211
+ def deep_object_value(instance, key)
212
+ prefix = "#{key}["
213
+ object = {}
214
+ parse_query(instance["query"]&.value).each do |param_key, values|
215
+ next unless param_key.start_with?(prefix) && param_key.end_with?("]")
216
+
217
+ property = param_key.delete_prefix(prefix).delete_suffix("]")
218
+ next if property.empty?
219
+
220
+ object[property] = values.last
221
+ end
222
+ return nil if object.empty?
223
+
224
+ Instance::Attribute.new(object, key: key, parent: instance["query"])
225
+ end
226
+
227
+ # `form`-style object query params (the default style). Exploded
228
+ # objects drop the parameter name entirely — each declared property
229
+ # becomes its own query key (`?x=1&y=2`) — so members are gathered
230
+ # by matching the schema's `properties` names; `additionalProperties`
231
+ # members cannot be recognized and are out of scope. Non-exploded
232
+ # objects flatten under the parameter's own name (`?point=x,1,y,2`).
233
+ def form_object_value(instance, result, key, schema)
234
+ params = parse_query(instance["query"]&.value)
235
+ explode = result.sibling(instance, "explode")&.annotation
236
+ explode = true if explode.nil?
237
+
238
+ if explode
239
+ properties = schema["properties"]
240
+ return nil unless properties.respond_to?(:each)
241
+
242
+ object = {}
243
+ properties.each do |property, _subschema|
244
+ values = params[property]
245
+ object[property] = values.last unless values.nil?
246
+ end
247
+ return nil if object.empty?
248
+
249
+ Instance::Attribute.new(object, key: key, parent: instance["query"])
73
250
  else
74
- raise Error, "Unknown style: #{result.sibling(instance, "style")}"
251
+ values = params[key]
252
+ return nil if values.nil? || values.last.nil?
253
+
254
+ members = values.last.split(",").each_slice(2).to_a
255
+ # A malformed flattening (odd member count) stays a raw string
256
+ # so schema validation rejects it.
257
+ value = (members.all? { |pair| pair.size == 2 }) ? members.to_h : values.last
258
+ Instance::Attribute.new(value, key: key, parent: instance["query"])
259
+ end
260
+ end
261
+
262
+ def parse_query(query_string)
263
+ params = {}
264
+ query_string.to_s.split(/[&;]/).each do |pair|
265
+ key, value = pair.split("=", 2).collect { |v| CGI.unescape(v) }
266
+ next unless key
267
+
268
+ (params[key] ||= []) << value
269
+ end
270
+
271
+ params
272
+ end
273
+
274
+ # Cookie values are opaque (RFC 6265): split into name=value pairs
275
+ # but do not percent-decode (unlike query strings), so values such as
276
+ # base64 tokens stay intact.
277
+ def parse_cookies(cookie_string)
278
+ cookies = {}
279
+ cookie_string.to_s.split(/;\s*/).each do |pair|
280
+ name, value = pair.split("=", 2)
281
+ next if name.nil? || name.empty?
282
+
283
+ cookies[name] = value
75
284
  end
285
+
286
+ cookies
287
+ end
288
+
289
+ def deserialize_array(values, instance, result)
290
+ style = style_for(result, instance, "query")
291
+ explode = result.sibling(instance, "explode")&.annotation
292
+ explode = style == "form" if explode.nil?
293
+
294
+ # exploded arrays are serialized as repeated keys (`ids=1&ids=2`)
295
+ return values.compact if explode
296
+
297
+ split_delimited(values.last, style)
298
+ end
299
+
300
+ def split_delimited(raw, style)
301
+ raw.split(ARRAY_STYLE_DELIMITERS.fetch(style, ","))
302
+ end
303
+
304
+ def style_for(result, instance, location)
305
+ result.sibling(instance, "style")&.annotation || DEFAULT_STYLE.fetch(location, "form")
76
306
  end
77
307
  end
78
308
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skooma
4
+ module Objects
5
+ # JSON Schema value inside an OpenAPI `schema:` keyword. Subclass of
6
+ # JSONSkooma::JSONSchema that participates in external $ref resolution.
7
+ class Schema < JSONSkooma::JSONSchema
8
+ include ExternalRefs
9
+ end
10
+ end
11
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Skooma
4
- VERSION = "0.3.8"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/skooma.rb CHANGED
@@ -22,6 +22,7 @@ module Skooma
22
22
 
23
23
  JSONSkooma.register_dialect("oas-3.1", Dialects::OAS31)
24
24
  JSONSkooma::Formatters.register :skooma, OutputFormat
25
+ JSONSkooma::Keywords::ValueSchemas.default_schema_class = Objects::Schema
25
26
 
26
27
  class << self
27
28
  def create_registry(name: REGISTRY_NAME)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skooma
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.8
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: zeitwerk
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -29,14 +43,14 @@ dependencies:
29
43
  requirements:
30
44
  - - "~>"
31
45
  - !ruby/object:Gem::Version
32
- version: 0.2.5
46
+ version: 0.2.7
33
47
  type: :runtime
34
48
  prerelease: false
35
49
  version_requirements: !ruby/object:Gem::Requirement
36
50
  requirements:
37
51
  - - "~>"
38
52
  - !ruby/object:Gem::Version
39
- version: 0.2.5
53
+ version: 0.2.7
40
54
  description: Apply a documentation-first approach to API development.
41
55
  email:
42
56
  - me@skryukov.dev
@@ -67,6 +81,7 @@ files:
67
81
  - lib/skooma/coverage_store.rb
68
82
  - lib/skooma/dialects/oas_3_1.rb
69
83
  - lib/skooma/env_mapper.rb
84
+ - lib/skooma/external_refs.rb
70
85
  - lib/skooma/inflector.rb
71
86
  - lib/skooma/instance.rb
72
87
  - lib/skooma/keywords/oas_3_1.rb
@@ -145,6 +160,7 @@ files:
145
160
  - lib/skooma/objects/response/keywords/content.rb
146
161
  - lib/skooma/objects/response/keywords/headers.rb
147
162
  - lib/skooma/objects/response/keywords/links.rb
163
+ - lib/skooma/objects/schema.rb
148
164
  - lib/skooma/output_format.rb
149
165
  - lib/skooma/rspec.rb
150
166
  - lib/skooma/validators/double.rb