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 +4 -4
- data/CHANGELOG.md +23 -1
- data/README.md +171 -2
- data/lib/skooma/body_parsers.rb +56 -0
- data/lib/skooma/external_refs.rb +47 -0
- data/lib/skooma/instance.rb +71 -2
- data/lib/skooma/keywords/oas_3_1/dialect/additional_properties.rb +2 -2
- data/lib/skooma/keywords/oas_3_1/schema.rb +1 -1
- data/lib/skooma/matchers/conform_response_schema.rb +3 -1
- data/lib/skooma/matchers/wrapper.rb +6 -2
- data/lib/skooma/objects/base.rb +2 -0
- data/lib/skooma/objects/media_type.rb +79 -1
- data/lib/skooma/objects/parameter/keywords/content.rb +19 -1
- data/lib/skooma/objects/parameter/keywords/required.rb +4 -1
- data/lib/skooma/objects/parameter/keywords/schema.rb +5 -2
- data/lib/skooma/objects/parameter/keywords/value_parser.rb +270 -40
- data/lib/skooma/objects/schema.rb +11 -0
- data/lib/skooma/version.rb +1 -1
- data/lib/skooma.rb +1 -0
- metadata +19 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 908f9232ad0bab42d205ee2aee29af1dc362f33a8a2a094d831098423e2a1099
|
|
4
|
+
data.tar.gz: f7f001ef01777d970c2ed4997baf387b478d857913f25bbabca1d9d733a51ca3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
data/lib/skooma/body_parsers.rb
CHANGED
|
@@ -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
|
data/lib/skooma/instance.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
|
|
@@ -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"] == @
|
|
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
|
-
|
|
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)
|
data/lib/skooma/objects/base.rb
CHANGED
|
@@ -6,9 +6,87 @@ module Skooma
|
|
|
6
6
|
class MediaType < Base
|
|
7
7
|
def kw_classes
|
|
8
8
|
[
|
|
9
|
-
Keywords::
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
raise Error, "Missing `in` key #{result.path}" unless
|
|
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
|
|
34
|
+
case location
|
|
19
35
|
when "query"
|
|
20
|
-
|
|
36
|
+
query_param_value(instance, result, key, schema: schema)
|
|
21
37
|
when "header"
|
|
22
|
-
instance
|
|
38
|
+
header_param_value(instance, result, key, schema: schema)
|
|
23
39
|
when "path"
|
|
24
|
-
|
|
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
|
-
|
|
42
|
+
cookie_param_value(instance, result, key, schema: schema)
|
|
34
43
|
else
|
|
35
|
-
raise Error, "Unknown location: #{
|
|
44
|
+
raise Error, "Unknown location: #{location}"
|
|
36
45
|
end
|
|
37
46
|
end
|
|
38
47
|
|
|
39
48
|
private
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
key:
|
|
53
|
-
parent: instance["
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
154
|
+
strip_path_prefix(raw, name, style).split(explode ? "." : ",")
|
|
63
155
|
when "matrix"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
data/lib/skooma/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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
|