structured_params-openapi 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 42d370e916a8b590b35d26a0e8b81ca443762ba037b88fec8561da53f354f4f4
4
+ data.tar.gz: 6e2ff90a8fdaae1fabffc1e1fc88dbf62d7ed174ef535ebc94051d719e7c3161
5
+ SHA512:
6
+ metadata.gz: e8a417a0e94cb639d6b5c03bfbd708d16a7ef515aacf15f8a384994c1c93c7358c7c2c7a3a0bb38eeaac7611271fb50d65b4f9e113e5a239d5c47c8c5a198c69
7
+ data.tar.gz: 35b25fcb63864522759c5f1ac0f2dcd7c143dfbba5a0b50c118fbe1d6c5b261df727b9c2fdb726446b6afee7ab4021ad23cc71387860c73db81671df15d5ef0d
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-10-14
4
+
5
+ ## structured_params-openapi 0.1.0
6
+
7
+ First release of the OpenAPI drift check for StructuredParams, extracted into its own gem.
8
+
9
+ ### Features
10
+ - Compares OpenAPI request schemas with `StructuredParams::Params` classes: properties, scalar and nested object/array types, and `required` against presence validation.
11
+ - `structured_params:openapi:drift_check` Rake task with automatic class discovery (`parameter_class_filter`) and explicit mappings (`Registry.register`).
12
+ - Component naming: default convention, `schema_name_resolver`, `schema:` / `pointer:`.
13
+ - Several OpenAPI files: `document_resolver` and `Registry.register(openapi:)`.
14
+ - `ignore_drift(path:, kind:, parameter_class:)` to accept known findings.
15
+ - OpenAPI 3.1 nullable forms and scalar `oneOf`/`anyOf` are compared; missing schemas and broken `$ref`s are errors; skipped checks are printed and `STRUCTURED_PARAMS_STRICT=1` fails on them.
16
+ - English and Japanese guides.
17
+
18
+ ### Installation
19
+ ```ruby
20
+ gem 'structured_params-openapi'
21
+ ```
22
+ Requires `structured_params >= 0.9.5`.
23
+
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Mizuki Yamamoto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # structured_params-openapi
2
+
3
+ English | [日本語](README_ja.md)
4
+
5
+ OpenAPI drift check for [StructuredParams](https://github.com/Syati/structured_params).
6
+ It detects differences between OpenAPI request schemas and `StructuredParams::Params`
7
+ classes, so a change on one side cannot silently diverge from the other.
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem 'structured_params-openapi'
14
+ ```
15
+
16
+ Bundler requires it automatically (`structured_params/openapi`). In a Rails application
17
+ the Rake task `structured_params:openapi:drift_check` is registered for you.
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ # config/initializers/structured_params_openapi.rb
23
+ StructuredParams::OpenAPI.configure do |config|
24
+ config.document = Rails.root.join('docs/api/openapi.yaml').to_s
25
+ config.parameter_class_filter = ->(klass) { klass.name&.start_with?('Api::') }
26
+ end
27
+ ```
28
+
29
+ ```sh
30
+ bundle exec rake structured_params:openapi:drift_check
31
+ ```
32
+
33
+ See the [guide](docs/drift-check.md) for naming conventions, multiple OpenAPI files,
34
+ ignoring findings, and the supported schema features.
data/README_ja.md ADDED
@@ -0,0 +1,34 @@
1
+ # structured_params-openapi
2
+
3
+ [English](README.md) | 日本語
4
+
5
+ [StructuredParams](https://github.com/Syati/structured_params) 用の OpenAPI drift check です。
6
+ OpenAPI のリクエスト schema と `StructuredParams::Params` クラスの差分を検出し、片方だけが
7
+ 変更されて乖離することを防ぎます。
8
+
9
+ ## インストール
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem 'structured_params-openapi'
14
+ ```
15
+
16
+ Bundler が自動で require します(`structured_params/openapi`)。Rails アプリケーションでは、
17
+ Rake タスク `structured_params:openapi:drift_check` も自動で登録されます。
18
+
19
+ ## 使い方
20
+
21
+ ```ruby
22
+ # config/initializers/structured_params_openapi.rb
23
+ StructuredParams::OpenAPI.configure do |config|
24
+ config.document = Rails.root.join('docs/api/openapi.yaml').to_s
25
+ config.parameter_class_filter = ->(klass) { klass.name&.start_with?('Api::') }
26
+ end
27
+ ```
28
+
29
+ ```sh
30
+ bundle exec rake structured_params:openapi:drift_check
31
+ ```
32
+
33
+ 命名規則、複数の OpenAPI ファイル、検出結果の無視、対応している schema の機能については、
34
+ [ガイド](docs/drift-check_ja.md)を参照してください。
@@ -0,0 +1,379 @@
1
+ # OpenAPI Drift Check
2
+
3
+ English | [日本語](drift-check_ja.md)
4
+
5
+ Detects differences between OpenAPI request schemas and `StructuredParams::Params`
6
+ classes, so a change on one side cannot silently diverge from the other.
7
+
8
+ ## Table of contents
9
+
10
+ - [Terms](#terms)
11
+ - [Quick start](#quick-start)
12
+ - [Matching classes to schemas](#matching-classes-to-schemas)
13
+ - [Default naming convention](#default-naming-convention)
14
+ - [Custom naming convention](#custom-naming-convention)
15
+ - [Exceptions for a single class](#exceptions-for-a-single-class)
16
+ - [What is compared](#what-is-compared)
17
+ - [Scalar types](#scalar-types)
18
+ - [Attributes and required properties](#attributes-and-required-properties)
19
+ - [Property names](#property-names)
20
+ - [Using it in RSpec](#using-it-in-rspec)
21
+ - [Ignoring a specific finding](#ignoring-a-specific-finding)
22
+ - [Rake task and Registry](#rake-task-and-registry)
23
+ - [Auto-discovery](#auto-discovery)
24
+ - [Registering exceptions](#registering-exceptions)
25
+ - [Multiple OpenAPI files](#multiple-openapi-files)
26
+ - [Running in CI](#running-in-ci)
27
+ - [Scope, skips and missing schemas](#scope-skips-and-missing-schemas)
28
+
29
+ ## Terms
30
+
31
+ In this guide, a **component** means a named schema under `components.schemas` in the
32
+ OpenAPI document. Each `Params` class is checked against one component, found by name
33
+ (for example `CreateUserParams` → `CreateUser`).
34
+
35
+ ```yaml
36
+ # openapi.yml
37
+ components:
38
+ schemas:
39
+ CreateUser: # <- a "component" (its JSON Pointer is #/components/schemas/CreateUser)
40
+ type: object
41
+ properties:
42
+ name: { type: string }
43
+ ```
44
+
45
+ ## Quick start
46
+
47
+ Add an initializer and keep only the parts you need. The OpenAPI document here is
48
+ generated from TypeSpec, so its components are named like `V1.Orders.CreateRequest`, for
49
+ classes named like `Api::V1::Orders::CreateRequestParameter`.
50
+
51
+ ```ruby
52
+ # config/initializers/structured_params_openapi.rb
53
+ StructuredParams::OpenAPI.configure do |config|
54
+ # Required: the OpenAPI document and the classes to check.
55
+ config.document = Rails.root.join('docs/api/openapi.yaml').to_s
56
+ config.parameter_class_filter = ->(klass) { klass.name&.start_with?('Api::') }
57
+
58
+ # Only if your component names differ from the default convention
59
+ # (CreateUserParams -> CreateUser):
60
+ # Api::V1::Orders::CreateRequestParameter -> "V1.Orders.CreateRequest"
61
+ config.schema_name_resolver = lambda do |klass|
62
+ klass.name.delete_prefix('Api::').delete_suffix('Parameter').gsub('::', '.')
63
+ end
64
+
65
+ # Only for classes that should have a schema but do not yet:
66
+ # record them instead of failing.
67
+ %w[
68
+ Api::V1::Reports::ExportRequestParameter
69
+ Api::V1::Reports::SummaryRequestParameter
70
+ ].each do |name|
71
+ config.ignore_drift(kind: :missing_schema, parameter_class: name)
72
+ end
73
+ end
74
+
75
+ # Only for classes whose component name does not follow the convention above.
76
+ {
77
+ 'Api::V1::Orders::KonbiniRequestParameter' => 'V1.Orders.CreateKonbiniRequest',
78
+ 'Api::V1::Orders::BankTransferRequestParameter' => 'V1.Orders.CreateBankTransferRequest'
79
+ }.each do |parameter_class, schema|
80
+ StructuredParams::OpenAPI::Registry.register(parameter_class, schema: schema)
81
+ end
82
+ ```
83
+
84
+ - `parameter_class_filter` selects the classes to check, so a new `Api::*` class is
85
+ picked up without registration.
86
+ - `schema_name_resolver` maps every class to its component by one rule.
87
+ - `ignore_drift` records a class that has no schema yet, and it stays listed in the Rake
88
+ output. A class that is never meant to be checked is better left out of the filter
89
+ (see [Scope, skips and missing schemas](#scope-skips-and-missing-schemas)).
90
+ - `Registry.register` covers the few classes that break the naming rule.
91
+
92
+ Then run the Rake task to see what is left to fix:
93
+
94
+ ```sh
95
+ bundle exec rake structured_params:openapi:drift_check
96
+ ```
97
+
98
+ ## Matching classes to schemas
99
+
100
+ ### Default naming convention
101
+
102
+ By default, `CreateUserParams` is matched with `#/components/schemas/CreateUser`: the
103
+ class's namespace and a trailing `Param`, `Params`, `Parameter`, `Parameters`, or `Form`
104
+ are removed.
105
+
106
+ ### Custom naming convention
107
+
108
+ If your components follow a different naming convention, override it globally with
109
+ `schema_name_resolver`. For example, when the OpenAPI document names the components
110
+ for `Api::V1::CreateUserParams` and `Api::V2::CreateUserParams` as `V1CreateUserRequest`
111
+ and `V2CreateUserRequest`:
112
+
113
+ ```yaml
114
+ # openapi.yml
115
+ components:
116
+ schemas:
117
+ V1CreateUserRequest: # <- the default convention would look for CreateUser
118
+ type: object
119
+ properties:
120
+ name: { type: string }
121
+ V2CreateUserRequest: ...
122
+ ```
123
+
124
+ ```ruby
125
+ StructuredParams::OpenAPI.configure do |config|
126
+ config.schema_name_resolver = ->(parameter_class) do
127
+ # Api::V1::CreateUserParams -> "V1CreateUser" -> "V1CreateUserRequest"
128
+ name = parameter_class.name.delete_prefix('Api::').delete_suffix('Params').gsub('::', '')
129
+ "#{name}Request"
130
+ end
131
+ end
132
+ ```
133
+
134
+ The resolver returns a component name, which replaces the default convention for every
135
+ class. Only the suffix `Params` is removed here, so a `CreateUserForm` would become
136
+ `CreateUserFormRequest`.
137
+
138
+ Keep the namespace in the returned name. If you drop it (for example with `demodulize`),
139
+ `Api::V1::CreateUserParams` and `Api::V2::CreateUserParams` both look for
140
+ `CreateUserRequest`, which does not exist, and report `missing_schema`. If each version
141
+ lives in its own OpenAPI file instead, keep the component name as is and switch files with
142
+ `document_resolver` (see [Multiple OpenAPI files](#multiple-openapi-files)).
143
+
144
+ ### Exceptions for a single class
145
+
146
+ For an exceptional class, pass its component name as `schema:`. It also accepts a
147
+ JSON Pointer (`'/components/schemas/CreateUserRequest'`, `'#/…'`) or an inline Hash.
148
+ `pointer:` selects a pointer explicitly, and `openapi:` overrides the configured
149
+ document for one call (a file path or a Hash).
150
+
151
+ ```ruby
152
+ StructuredParams::OpenAPI::DriftCheck.call(
153
+ parameter_class: CreateUserParams,
154
+ schema: 'CreateUserRequest'
155
+ )
156
+ ```
157
+
158
+ ## What is compared
159
+
160
+ ### Scalar types
161
+
162
+ | Schema `type` | Accepted attribute types |
163
+ |---------------|--------------------------|
164
+ | `string` | `:string`, `:date`, `:datetime`, `:time` (`format` is not inspected) |
165
+ | `integer` | `:integer`, `:big_integer` |
166
+ | `number` | `:float`, `:decimal` |
167
+ | `boolean`, `object`, `array` | the same type |
168
+
169
+ A schema with neither `type` nor `properties` (for example an enum-only schema) is
170
+ reported as a `type_mismatch`. Nested object and array types are compared recursively.
171
+
172
+ ### Attributes and required properties
173
+
174
+ Schema properties are compared with `attribute` declarations.
175
+
176
+ Schema `required` is compared with presence validation, and this mapping is only an
177
+ approximation. OpenAPI's `required` means one thing: the key must exist. In Rails,
178
+ a missing key and a `nil` value both leave the attribute `nil`, so no validator can
179
+ express "the key exists", and nothing says whether `presence`, `allow_nil` or
180
+ `allow_blank` was intended. The check therefore uses an unconditional
181
+ `validates ..., presence: true` as a stand-in ("unconditional" means none of the `if:`,
182
+ `unless:`, `on:`, `allow_nil:` or `allow_blank:` options).
183
+
184
+ A required property that the schema allows to be null (`nullable: true`, a `type` array
185
+ including `'null'`, or a `oneOf`/`anyOf` with a `null` option) needs no `presence`, since
186
+ `presence` would reject the null the contract permits, so it is not reported.
187
+
188
+ A `presence` that carries one of those options may let a missing key through, so the
189
+ check cannot confirm it satisfies `required` and reports `missing_presence_validation`.
190
+ That is not necessarily a bug. When the Ruby side is intentional, accept it per
191
+ property with [`ignore_drift`](#ignoring-a-specific-finding). When `presence` is
192
+ too strict for the whole API (it also rejects `false` and `""`, which `required` allows),
193
+ pass `required_validation: :none` and check required-key behavior separately.
194
+
195
+ ### Property names
196
+
197
+ Use `property_names` when the JSON property name differs from the Ruby attribute name.
198
+
199
+ ```ruby
200
+ StructuredParams::OpenAPI::DriftCheck.call(
201
+ parameter_class: CreateUserParams,
202
+ property_names: { user_id: 'userId' }
203
+ )
204
+ ```
205
+
206
+ ## Using it in RSpec
207
+
208
+ Use the result as the failure message so a CI failure lists every detected difference.
209
+
210
+ ```ruby
211
+ it 'matches the request schema' do
212
+ result = StructuredParams::OpenAPI::DriftCheck.call(
213
+ parameter_class: CreateUserParams
214
+ )
215
+
216
+ expect(result).to be_valid, result.to_message
217
+ end
218
+ ```
219
+
220
+ ## Ignoring a specific finding
221
+
222
+ `required_validation: :none` and the Registry both operate on a whole class, so they
223
+ aren't precise enough to accept one known finding while still checking everything else
224
+ at that path. For that, configure an ignore rule instead:
225
+
226
+ ```ruby
227
+ StructuredParams::OpenAPI.configure do |config|
228
+ config.ignore_drift(path: 'wallet_information.wallet_type', kind: :missing_presence_validation)
229
+ end
230
+ ```
231
+
232
+ - The rule applies wherever that path occurs, including once per parent class for a
233
+ nested object reused by several top-level parameter classes.
234
+ - Omit `kind:` to ignore every error kind at that path. Valid kinds are
235
+ `missing_attribute`, `unexpected_attribute`, `missing_presence_validation`,
236
+ `type_mismatch`, `missing_schema` and `unresolved_reference`; anything else raises
237
+ `ArgumentError`.
238
+ - Pass `parameter_class:` (a class or its name) to limit a rule to one class.
239
+ - Paths use attribute names joined with `.`, with `[]` for array items (for example
240
+ `items[].sku`). Each of `path:`, `kind:` and `parameter_class:` narrows a rule and an
241
+ omitted one matches everything, but at least one is required. A missing schema
242
+ (`missing_schema`) concerns the whole class rather than a property, so omit `path:`.
243
+ - Skips are never ignored.
244
+
245
+ Ignored findings move to `result.ignored` instead of `result.errors`, so `valid?`
246
+ treats them as resolved and they don't appear in `to_message` — but they stay available
247
+ on the result object for anyone who wants to audit what's being suppressed and why,
248
+ rather than disappearing silently.
249
+
250
+ ## Rake task and Registry
251
+
252
+ ### Auto-discovery
253
+
254
+ The gem provides `structured_params:openapi:drift_check` in Rails applications. The task
255
+ automatically checks classes selected by `parameter_class_filter`, so a new
256
+ `Api::*Params` class needs no registration.
257
+
258
+ - Without a filter, only registered classes are checked, and the task warns when
259
+ nothing was checked.
260
+ - Anonymous classes are never auto-discovered, which is also why the filter example
261
+ guards with `name&.`.
262
+
263
+ ### Registering exceptions
264
+
265
+ Use the Registry only for an exceptional component name or property mapping; explicit
266
+ registrations override auto-discovery for the same class, and a later registration for
267
+ the same class replaces an earlier one.
268
+
269
+ ```ruby
270
+ StructuredParams::OpenAPI::Registry.register(
271
+ LegacyUserParams,
272
+ schema: 'LegacyCreateUserRequest'
273
+ )
274
+ ```
275
+
276
+ `parameter_class` may also be a class name String, which is resolved lazily when the
277
+ task runs. This is useful for registering from a plain `config/initializers/*.rb` file,
278
+ where Rails hasn't finished setting up autoloading yet and referencing the class
279
+ constant directly would raise `NameError`.
280
+
281
+ ```ruby
282
+ StructuredParams::OpenAPI::Registry.register(
283
+ 'LegacyUserParams',
284
+ schema: 'LegacyCreateUserRequest'
285
+ )
286
+ ```
287
+
288
+ ### Multiple OpenAPI files
289
+
290
+ When an application has several OpenAPI files (for example `admin_api.yml` and
291
+ `front_api.yml`), set `document_resolver` so each auto-discovered class is checked
292
+ against its own file. Return `nil` to fall back to `config.document`.
293
+
294
+ ```ruby
295
+ StructuredParams::OpenAPI.configure do |config|
296
+ config.parameter_class_filter = ->(klass) { klass.name&.match?(/\A(AdminApi|FrontApi)::/) }
297
+ config.document_resolver = ->(klass) do
298
+ case klass.name
299
+ when /\AAdminApi::/ then Rails.root.join('openapi/admin_api.yml').to_s
300
+ when /\AFrontApi::/ then Rails.root.join('openapi/front_api.yml').to_s
301
+ end
302
+ end
303
+ end
304
+ ```
305
+
306
+ For a one-off class, `Registry.register(..., openapi:)` (a file path or Hash) overrides
307
+ the resolver. Without either, the configured `document` is used.
308
+
309
+ ```ruby
310
+ StructuredParams::OpenAPI::Registry.register(
311
+ AdminUserParams,
312
+ openapi: Rails.root.join('openapi/admin.yml').to_s
313
+ )
314
+ ```
315
+
316
+ ### Running in CI
317
+
318
+ ```sh
319
+ bundle exec rake structured_params:openapi:drift_check
320
+ ```
321
+
322
+ Set `STRUCTURED_PARAMS_STRICT=1` to also fail on skipped checks (see below).
323
+
324
+ ## Scope, skips and missing schemas
325
+
326
+ Handled as if they were plain schemas:
327
+
328
+ - OpenAPI 3.1 nullable forms: `oneOf`/`anyOf` with a `{type: null}` option and a `type`
329
+ array with `'null'` (for example `[string, 'null']`). Once the null option is removed
330
+ they are compared like 3.0 `nullable: true`;
331
+ - `oneOf`/`anyOf` of plain scalars (for example `string` or `integer`), which match when
332
+ the attribute fits any option;
333
+ - a single-entry `allOf`, as TypeSpec emits for `$ref` plus a `description`.
334
+
335
+ The current scope deliberately skips:
336
+
337
+ - `oneOf`/`anyOf` of objects, and multi-entry `allOf`;
338
+ - a `type` array of several non-null types;
339
+ - external references.
340
+
341
+ Recursive schemas are compared once per ancestor chain; the repeated visit is dropped
342
+ silently.
343
+
344
+ Skips never fail a run, but they are not silent: the Rake task prints every skipped and
345
+ ignored entry with a summary line, and `result.skipped` / `result.to_report` expose them
346
+ in RSpec. Set `STRUCTURED_PARAMS_STRICT=1` (or pass `strict: true` to
347
+ `Registry.check_all!`) to fail on skipped checks too.
348
+
349
+ A class whose resolved schema name has no matching component is reported as a
350
+ `missing_schema` error, as is a dangling or circular local `$ref`
351
+ (`unresolved_reference`), so a renamed component or a typo cannot silently drop
352
+ coverage. A class that intentionally has no schema (for example one backed by query
353
+ parameters) can be handled in two ways:
354
+
355
+ - If it is never meant to be checked, leave it out of `parameter_class_filter`. The
356
+ class is not checked at all, and nothing is recorded about it.
357
+
358
+ ```ruby
359
+ NO_SCHEMA = %w[Api::V1::SearchParams].freeze
360
+
361
+ StructuredParams::OpenAPI.configure do |config|
362
+ config.parameter_class_filter = ->(klass) do
363
+ klass.name&.start_with?('Api::') && NO_SCHEMA.exclude?(klass.name)
364
+ end
365
+ end
366
+ ```
367
+
368
+ - If a schema should exist but does not yet, ignore the finding for that class. It moves
369
+ to `result.ignored` and is listed by the Rake task, so it can be revisited later.
370
+
371
+ ```ruby
372
+ StructuredParams::OpenAPI.configure do |config|
373
+ config.ignore_drift(kind: :missing_schema, parameter_class: 'Api::V1::SearchParams')
374
+ end
375
+ ```
376
+
377
+ `result.to_message` is prefixed with the checked class's name, so `check_all!`'s
378
+ combined `DriftError` message reads as one block per class instead of a flat,
379
+ unattributed list of paths.