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.
@@ -0,0 +1,379 @@
1
+ # OpenAPI Drift Check
2
+
3
+ [English](drift-check.md) | 日本語
4
+
5
+ OpenAPI のリクエスト schema と `StructuredParams::Params` クラスの差分を検出し、
6
+ 片方だけが変更されて乖離することを防ぎます。
7
+
8
+ ## 目次
9
+
10
+ - [用語](#用語)
11
+ - [クイックスタート](#クイックスタート)
12
+ - [クラスと schema の対応付け](#クラスと-schema-の対応付け)
13
+ - [既定の命名規則](#既定の命名規則)
14
+ - [命名規則を変更する](#命名規則を変更する)
15
+ - [特定のクラスだけ例外にする](#特定のクラスだけ例外にする)
16
+ - [比較する内容](#比較する内容)
17
+ - [スカラー型](#スカラー型)
18
+ - [attribute と必須 property](#attribute-と必須-property)
19
+ - [property 名の対応](#property-名の対応)
20
+ - [RSpec で使う](#rspec-で使う)
21
+ - [特定の検出結果を無視する](#特定の検出結果を無視する)
22
+ - [Rake タスクと Registry](#rake-タスクと-registry)
23
+ - [自動検出](#自動検出)
24
+ - [例外を登録する](#例外を登録する)
25
+ - [複数の OpenAPI ファイル](#複数の-openapi-ファイル)
26
+ - [CI で実行する](#ci-で実行する)
27
+ - [対象範囲、skip、schema の欠落](#対象範囲skipschema-の欠落)
28
+
29
+ ## 用語
30
+
31
+ このドキュメントで **component** と呼ぶのは、OpenAPI ドキュメントの
32
+ `components.schemas` 配下にある、名前付きの schema のことです。各 `Params` クラスは、
33
+ 名前で見つけた 1 つの component と照合されます(たとえば `CreateUserParams` →
34
+ `CreateUser`)。
35
+
36
+ ```yaml
37
+ # openapi.yml
38
+ components:
39
+ schemas:
40
+ CreateUser: # <- これが「component」(JSON Pointer は #/components/schemas/CreateUser)
41
+ type: object
42
+ properties:
43
+ name: { type: string }
44
+ ```
45
+
46
+ ## クイックスタート
47
+
48
+ initializer を追加し、必要な部分だけ残してください。この例の OpenAPI ドキュメントは
49
+ TypeSpec から生成されているため、component 名は `V1.Orders.CreateRequest` のような形で、
50
+ クラス名は `Api::V1::Orders::CreateRequestParameter` のような形です。
51
+
52
+ ```ruby
53
+ # config/initializers/structured_params_openapi.rb
54
+ StructuredParams::OpenAPI.configure do |config|
55
+ # 必須: OpenAPI ドキュメントと、検査するクラス
56
+ config.document = Rails.root.join('docs/api/openapi.yaml').to_s
57
+ config.parameter_class_filter = ->(klass) { klass.name&.start_with?('Api::') }
58
+
59
+ # component 名が既定の規則(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
+ # schema があるはずだがまだないクラスがある場合だけ:
66
+ # 失敗にせず、記録だけ残す
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
+ # 上の規則に従わない component 名を持つクラスがある場合だけ
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` は検査するクラスを選びます。新しい `Api::*` クラスは、登録なしで
85
+ 対象になります。
86
+ - `schema_name_resolver` は、1 つの規則で全クラスを component に対応付けます。
87
+ - `ignore_drift` は、まだ schema がないクラスを記録します。Rake の出力には一覧表示され
88
+ 続けます。検査する予定のないクラスは、filter から外すほうが適切です
89
+ ([対象範囲、skip、schema の欠落](#対象範囲skipschema-の欠落)を参照)。
90
+ - `Registry.register` は、命名の規則に合わない少数のクラスを個別に対応付けます。
91
+
92
+ あとは Rake タスクを実行すると、直すべき残りが分かります。
93
+
94
+ ```sh
95
+ bundle exec rake structured_params:openapi:drift_check
96
+ ```
97
+
98
+ ## クラスと schema の対応付け
99
+
100
+ ### 既定の命名規則
101
+
102
+ 既定では、`CreateUserParams` を `#/components/schemas/CreateUser` に対応付けます。
103
+ クラスの namespace と、末尾の `Param`、`Params`、`Parameter`、`Parameters`、`Form` を
104
+ 取り除いた名前が component 名になります。
105
+
106
+ ### 命名規則を変更する
107
+
108
+ component の命名規則が異なる場合は、`schema_name_resolver` で全体の規則を変更します。
109
+ たとえば OpenAPI 側で、`Api::V1::CreateUserParams` と `Api::V2::CreateUserParams` に
110
+ 対応する component が `V1CreateUserRequest` と `V2CreateUserRequest` という名前の場合は、
111
+ 次のようにします。
112
+
113
+ ```yaml
114
+ # openapi.yml
115
+ components:
116
+ schemas:
117
+ V1CreateUserRequest: # <- 既定の規則では 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
+ resolver が返すのは component 名で、すべてのクラスで既定の規則の代わりに使われます。
135
+ この例が取り除くのは `Params` だけなので、`CreateUserForm` は `CreateUserFormRequest`
136
+ になります。
137
+
138
+ 返す名前には namespace を残してください。残さない(たとえば `demodulize` で外す)と、
139
+ `Api::V1::CreateUserParams` も `Api::V2::CreateUserParams` も存在しない
140
+ `CreateUserRequest` を探してしまい、`missing_schema` になります。バージョンごとに
141
+ OpenAPI ファイル自体が分かれている場合は、component 名はそのままにして、
142
+ `document_resolver` でファイルを切り替えます
143
+ ([複数の OpenAPI ファイル](#複数の-openapi-ファイル)を参照)。
144
+
145
+ ### 特定のクラスだけ例外にする
146
+
147
+ 例外的なクラスには、`schema:` で component 名を渡します。JSON Pointer
148
+ (`'/components/schemas/CreateUserRequest'`、`'#/…'`)や、インラインの Hash も渡せます。
149
+ `pointer:` で Pointer を明示でき、`openapi:` で 1 回の呼び出しに限って設定済みの
150
+ ドキュメントを上書きできます(ファイルパスまたは Hash)。
151
+
152
+ ```ruby
153
+ StructuredParams::OpenAPI::DriftCheck.call(
154
+ parameter_class: CreateUserParams,
155
+ schema: 'CreateUserRequest'
156
+ )
157
+ ```
158
+
159
+ ## 比較する内容
160
+
161
+ ### スカラー型
162
+
163
+ | schema の `type` | 許容する attribute の型 |
164
+ |------------------|--------------------------|
165
+ | `string` | `:string`、`:date`、`:datetime`、`:time`(`format` は見ません) |
166
+ | `integer` | `:integer`、`:big_integer` |
167
+ | `number` | `:float`、`:decimal` |
168
+ | `boolean`、`object`、`array` | 同じ型 |
169
+
170
+ `type` も `properties` もない schema(たとえば enum だけの schema)は、
171
+ `type_mismatch` として報告されます。ネストした object と array の型は再帰的に比較します。
172
+
173
+ ### attribute と必須 property
174
+
175
+ schema の property は `attribute` 宣言と比較します。
176
+
177
+ schema の `required` は presence 検証と比較しますが、この対応は近似です。OpenAPI の
178
+ `required` が意味するのは「キーが存在すること」だけです。Rails では、キーが無い場合も値が
179
+ `nil` の場合も、属性は `nil` になります。そのため「キーが存在する」ことを表現できる
180
+ バリデータはなく、`presence`、`allow_nil`、`allow_blank` のどれが意図なのかも分かりません。
181
+ そこでこのチェックは、無条件の `validates ..., presence: true` を代わりの目安として使います
182
+ (「無条件」とは、`if:`、`unless:`、`on:`、`allow_nil:`、`allow_blank:` のいずれも指定されて
183
+ いないことです)。
184
+
185
+ schema が null を許す required の property(`nullable: true`、`'null'` を含む `type` 配列、
186
+ `null` を選択肢に持つ `oneOf`/`anyOf`)には、`presence` は不要です。`presence` を付けると、
187
+ 契約が許している null を拒否してしまうため、報告されません。
188
+
189
+ これらのオプションが付いた `presence` は、キーが無くても通る可能性があるため、`required`
190
+ を満たすか確認できず、`missing_presence_validation` として報告されます。これは必ずしも
191
+ バグではありません。Ruby 側が意図的な書き方なら、
192
+ [`ignore_drift`](#特定の検出結果を無視する)で property ごとに許容してください。`presence`
193
+ が API 全体にとって厳しすぎる場合(`required` が許す `false` や `""` も拒否します)は、
194
+ `required_validation: :none` を指定し、必須キーの挙動は別に検証してください。
195
+
196
+ ### property 名の対応
197
+
198
+ JSON の property 名と Ruby の attribute 名が異なる場合は `property_names` を使います。
199
+
200
+ ```ruby
201
+ StructuredParams::OpenAPI::DriftCheck.call(
202
+ parameter_class: CreateUserParams,
203
+ property_names: { user_id: 'userId' }
204
+ )
205
+ ```
206
+
207
+ ## RSpec で使う
208
+
209
+ 結果をそのまま失敗メッセージに使うと、CI の失敗に検出した差分がすべて並びます。
210
+
211
+ ```ruby
212
+ it 'matches the request schema' do
213
+ result = StructuredParams::OpenAPI::DriftCheck.call(
214
+ parameter_class: CreateUserParams
215
+ )
216
+
217
+ expect(result).to be_valid, result.to_message
218
+ end
219
+ ```
220
+
221
+ ## 特定の検出結果を無視する
222
+
223
+ `required_validation: :none` と Registry はクラス全体に作用するため、既知の 1 件だけを
224
+ 許容して、同じパスの他の検査は続けたい場合には粗すぎます。その場合は ignore 規則を
225
+ 設定します。
226
+
227
+ ```ruby
228
+ StructuredParams::OpenAPI.configure do |config|
229
+ config.ignore_drift(path: 'wallet_information.wallet_type', kind: :missing_presence_validation)
230
+ end
231
+ ```
232
+
233
+ - 規則は、そのパスが現れるすべての場所に適用されます。複数のトップレベルクラスが
234
+ 再利用するネストした object では、親クラスごとに 1 回ずつ適用されます。
235
+ - `kind:` を省略すると、そのパスのすべてのエラー種別を無視します。指定できる種別は
236
+ `missing_attribute`、`unexpected_attribute`、`missing_presence_validation`、
237
+ `type_mismatch`、`missing_schema`、`unresolved_reference` で、それ以外は
238
+ `ArgumentError` になります。
239
+ - `parameter_class:`(クラスまたはその名前)を渡すと、規則を 1 つのクラスに限定できます。
240
+ - パスは attribute 名を `.` でつなぎ、配列の要素には `[]` を付けます
241
+ (例: `items[].sku`)。`path:`、`kind:`、`parameter_class:` はどれも規則を絞り込み、
242
+ 省略したものは「すべて」に一致しますが、少なくとも 1 つは必要です。schema が見つからない
243
+ 場合(`missing_schema`)は、property ではなくクラス全体についての指摘なので、`path:`
244
+ を省略します。
245
+ - skip は無視できません。
246
+
247
+ 無視した検出結果は `result.errors` ではなく `result.ignored` に移ります。そのため
248
+ `valid?` は解決済みとして扱い、`to_message` にも表示されません。ただし結果オブジェクト
249
+ には残るので、何をなぜ抑制しているかを後から確認でき、黙って消えることはありません。
250
+
251
+ ## Rake タスクと Registry
252
+
253
+ ### 自動検出
254
+
255
+ gem は Rails アプリケーションに `structured_params:openapi:drift_check` を提供します。
256
+ このタスクは `parameter_class_filter` で選択されたクラスを自動で検査するため、新しい
257
+ `Api::*Params` クラスに登録は不要です。
258
+
259
+ - filter がなければ登録済みのクラスだけが対象で、何も検査されなかった場合は
260
+ タスクが警告します。
261
+ - 無名クラスは自動検出されません。filter の例が `name&.` で守られているのはそのためです。
262
+
263
+ ### 例外を登録する
264
+
265
+ Registry は、例外的な component 名や property の対応付けが必要な場合にだけ使って
266
+ ください。同じクラスに対しては、明示的な登録が自動検出より優先されます。同じクラスを
267
+ 後から登録すると、先の登録を置き換えます。
268
+
269
+ ```ruby
270
+ StructuredParams::OpenAPI::Registry.register(
271
+ LegacyUserParams,
272
+ schema: 'LegacyCreateUserRequest'
273
+ )
274
+ ```
275
+
276
+ `parameter_class` にはクラス名の文字列も渡せます。この場合はタスクの実行時に解決
277
+ されます。Rails の autoload がまだ設定されていない `config/initializers/*.rb` から
278
+ 登録するときに便利です。クラス定数を直接参照すると `NameError` になるためです。
279
+
280
+ ```ruby
281
+ StructuredParams::OpenAPI::Registry.register(
282
+ 'LegacyUserParams',
283
+ schema: 'LegacyCreateUserRequest'
284
+ )
285
+ ```
286
+
287
+ ### 複数の OpenAPI ファイル
288
+
289
+ アプリケーションに複数の OpenAPI ファイルがある場合(たとえば `admin_api.yml` と
290
+ `front_api.yml`)は、`document_resolver` を設定すると、自動検出された各クラスを
291
+ それぞれのファイルに対して検査できます。`nil` を返したクラスは `config.document` に
292
+ フォールバックします。
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
+ 個別のクラスには `Registry.register(..., openapi:)`(ファイルパスまたは Hash)で
307
+ resolver を上書きできます。どちらもなければ、設定済みの `document` を使います。
308
+
309
+ ```ruby
310
+ StructuredParams::OpenAPI::Registry.register(
311
+ AdminUserParams,
312
+ openapi: Rails.root.join('openapi/admin.yml').to_s
313
+ )
314
+ ```
315
+
316
+ ### CI で実行する
317
+
318
+ ```sh
319
+ bundle exec rake structured_params:openapi:drift_check
320
+ ```
321
+
322
+ skip も失敗扱いにしたい場合は、`STRUCTURED_PARAMS_STRICT=1` を設定します(下記参照)。
323
+
324
+ ## 対象範囲、skip、schema の欠落
325
+
326
+ 通常の schema と同じように扱うもの:
327
+
328
+ - OpenAPI 3.1 の nullable の書き方: `{type: null}` を選択肢に持つ `oneOf`/`anyOf` と、
329
+ `'null'` を含む `type` 配列(たとえば `[string, 'null']`)。null の選択肢を除くと、
330
+ 3.0 の `nullable: true` と同じように比較されます。
331
+ - スカラーだけの `oneOf`/`anyOf`(たとえば `string` か `integer`)。attribute がどれか 1 つの
332
+ 選択肢に合えば一致します。
333
+ - 要素が 1 つだけの `allOf`。TypeSpec が `$ref` に `description` を付けるときに出力する形です。
334
+
335
+ 現在の対象範囲では、次のものを意図的に skip します。
336
+
337
+ - object の `oneOf`/`anyOf`、複数要素の `allOf`
338
+ - null 以外の型を複数持つ `type` 配列
339
+ - 外部参照
340
+
341
+ 再帰する schema は、祖先の連鎖ごとに 1 回だけ比較され、繰り返しの訪問は黙って打ち切られます。
342
+
343
+ skip は実行を失敗させませんが、黙ってもいません。Rake タスクは skip と ignore の
344
+ すべての項目をサマリ行とともに表示し、RSpec では `result.skipped` と
345
+ `result.to_report` で確認できます。skip も失敗扱いにしたい場合は、
346
+ `STRUCTURED_PARAMS_STRICT=1` を設定するか、`Registry.check_all!` に `strict: true` を
347
+ 渡してください。
348
+
349
+ 解決した schema 名に対応する component がないクラスは、`missing_schema` エラーとして
350
+ 報告されます。壊れた、または循環するローカルの `$ref` も同様です
351
+ (`unresolved_reference`)。component 名の変更や打ち間違いで、検査が黙って外れること
352
+ はありません。意図的に schema を持たないクラス(たとえば query parameter が元の
353
+ クラス)は、次の 2 通りで扱えます。
354
+
355
+ - 検査する予定がないなら、`parameter_class_filter` から外します。そのクラスはまったく
356
+ 検査されず、記録も残りません。
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
+ - 本来は schema があるはずだが、まだない場合は、そのクラスの指摘を無視します。指摘は
369
+ `result.ignored` に移り、Rake タスクにも一覧表示されるので、後から見直せます。
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` の先頭には検査したクラス名が付くため、`check_all!` がまとめて
378
+ 投げる `DriftError` のメッセージは、パスをただ並べたものではなく、クラスごとの
379
+ ブロックになります。
@@ -0,0 +1,131 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require 'date'
5
+ require 'yaml'
6
+
7
+ module StructuredParams
8
+ # OpenAPI contract configuration and drift-check utilities.
9
+ module OpenAPI
10
+ # Global OpenAPI document and component-name convention for drift checks.
11
+ class Configuration
12
+ # @rbs @ignored_issues: Array[Hash[Symbol, untyped]]
13
+ # @rbs @documents: Hash[String, Array[untyped]]
14
+
15
+ attr_accessor :document #: Hash[untyped, untyped]|String?
16
+ attr_writer :schema_name_resolver #: untyped
17
+ attr_writer :document_resolver #: untyped
18
+ attr_writer :parameter_class_filter #: untyped
19
+
20
+ #: () -> void
21
+ def initialize
22
+ @schema_name_resolver = lambda do |parameter_class|
23
+ parameter_class.name.split('::').last.sub(/(?:Params?|Parameters?|Form)$/, '')
24
+ end
25
+ @ignored_issues = []
26
+ @documents = {}
27
+ end
28
+
29
+ # The OpenAPI document for +parameter_class+: the +document_resolver+ result when it
30
+ # returns one, otherwise the global +document+. Lets one app check classes from several
31
+ # API namespaces (e.g. admin and front) against separate files.
32
+ #: (singleton(StructuredParams::Params)) -> (Hash[untyped, untyped] | String)?
33
+ def document_for(parameter_class)
34
+ @document_resolver&.call(parameter_class) || document
35
+ end
36
+
37
+ # Parses an OpenAPI file once per modification time, so a bulk check across many
38
+ # parameter classes does not re-read the same document for each of them. JSON files
39
+ # work too, since JSON is valid YAML. Only the mtime invalidates the cache, so two writes
40
+ # within the filesystem's timestamp granularity can serve a stale parse. YAML dates such as
41
+ # `example: 2020-01-01` load as Date, hence +permitted_classes+.
42
+ #: (String) -> Hash[untyped, untyped]
43
+ def load_document(path)
44
+ mtime = File.mtime(path)
45
+ cached_mtime, document = @documents[path]
46
+ return document if cached_mtime == mtime
47
+
48
+ document = YAML.safe_load_file(path, aliases: true, permitted_classes: [Date, Time])
49
+ unless document.is_a?(Hash)
50
+ raise ArgumentError,
51
+ "OpenAPI document #{path} must contain a mapping at its top level"
52
+ end
53
+
54
+ @documents[path] = [mtime, document]
55
+ document
56
+ end
57
+
58
+ # Suppresses matching findings instead of raising them as errors. Each of +path+, +kind+
59
+ # and +parameter_class+ narrows the rule; an omitted one matches everything, but at least
60
+ # one must be given. Without +parameter_class+ the rule applies to every drift check,
61
+ # including once per parent class for a nested object reused by several top-level
62
+ # parameter classes. Skips (unsupported schema features) are never ignored; they only
63
+ # appear in Result#skipped.
64
+ #
65
+ # Paths use attribute names joined with +.+ and +[]+ for array items, e.g.
66
+ # +items[].sku+. A schema that cannot be found at all (+:missing_schema+) concerns the
67
+ # whole class rather than a property, so omit +path+ for it.
68
+ #
69
+ # Ignored findings are moved to DriftCheck::Result#ignored rather than dropped, so
70
+ # they stay auditable; #valid? and #to_message treat them as resolved.
71
+ #: (?path: String?, ?kind: Symbol?, ?parameter_class: (Class | String)?) -> void
72
+ def ignore_drift(path: nil, kind: nil, parameter_class: nil)
73
+ if path.nil? && kind.nil? && parameter_class.nil?
74
+ raise ArgumentError, 'give at least one of path:, kind: or parameter_class: to ignore_drift'
75
+ end
76
+
77
+ if kind && !DriftCheck::ERROR_KINDS.include?(kind)
78
+ raise ArgumentError, "unknown kind #{kind.inspect}; expected one of #{DriftCheck::ERROR_KINDS.inspect}"
79
+ end
80
+
81
+ @ignored_issues << { path: path, kind: kind, parameter_class: parameter_class&.to_s }
82
+ end
83
+
84
+ #: (Symbol, String, ?untyped) -> bool
85
+ def ignored?(kind, path, parameter_class = nil)
86
+ @ignored_issues.any? do |rule|
87
+ [nil, path].include?(rule.fetch(:path)) &&
88
+ [nil, kind].include?(rule.fetch(:kind)) &&
89
+ [nil, parameter_class&.to_s].include?(rule.fetch(:parameter_class))
90
+ end
91
+ end
92
+
93
+ #: () -> Array[singleton(StructuredParams::Params)]
94
+ def parameter_classes
95
+ return [] unless @parameter_class_filter
96
+
97
+ ObjectSpace.each_object(Class).select do |parameter_class|
98
+ parameter_class < StructuredParams::Params && parameter_class.name &&
99
+ @parameter_class_filter.call(parameter_class)
100
+ end
101
+ end
102
+
103
+ #: (singleton(StructuredParams::Params), ?String?) -> String
104
+ def pointer_for(parameter_class, schema_name = nil)
105
+ name = schema_name || @schema_name_resolver.call(parameter_class)
106
+ return name if name.start_with?('/', '#/')
107
+
108
+ "/components/schemas/#{name.gsub('~', '~0').gsub('/', '~1')}"
109
+ end
110
+ end
111
+
112
+ class << self
113
+ # @rbs self.@configuration: Configuration?
114
+
115
+ #: () -> Configuration
116
+ def configuration
117
+ @configuration ||= Configuration.new
118
+ end
119
+
120
+ #: () { (Configuration) -> void } -> void
121
+ def configure
122
+ yield configuration
123
+ end
124
+
125
+ #: () -> void
126
+ def reset_configuration!
127
+ @configuration = Configuration.new
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,16 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module StructuredParams
5
+ module OpenAPI
6
+ class DriftCheck
7
+ # A single finding (error, skipped check, or ignored finding) from a drift check.
8
+ Issue = Struct.new(
9
+ :kind, #: Symbol
10
+ :path, #: String
11
+ :message, #: String
12
+ keyword_init: true
13
+ )
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,61 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module StructuredParams
5
+ module OpenAPI
6
+ class DriftCheck
7
+ # Result of a single schema/class comparison.
8
+ class Result
9
+ attr_reader :errors, :skipped, :ignored #: Array[Issue]
10
+ attr_reader :parameter_class #: singleton(::StructuredParams::Params)
11
+
12
+ #: (singleton(::StructuredParams::Params), Array[Issue], Array[Issue], Array[Issue]) -> void
13
+ def initialize(parameter_class, errors, skipped, ignored)
14
+ @parameter_class = parameter_class
15
+ @errors = errors
16
+ @skipped = skipped
17
+ @ignored = ignored
18
+ end
19
+
20
+ #: () -> bool
21
+ def valid?
22
+ errors.empty?
23
+ end
24
+
25
+ #: () -> String
26
+ def to_message
27
+ sections = []
28
+ sections << "OpenAPI drift detected:\n#{format_issues(errors)}" unless valid?
29
+ sections << "OpenAPI checks skipped:\n#{format_issues(skipped)}" unless skipped.empty?
30
+ return '' if sections.empty?
31
+
32
+ "#{class_label}\n#{sections.join("\n")}"
33
+ end
34
+
35
+ # Lists what a passing check still left unverified (skipped) or suppressed (ignored),
36
+ # so those stay visible in CI output instead of only on the result object.
37
+ #: () -> String
38
+ def to_report
39
+ sections = []
40
+ sections << "OpenAPI checks skipped:\n#{format_issues(skipped)}" unless skipped.empty?
41
+ sections << "OpenAPI findings ignored:\n#{format_issues(ignored)}" unless ignored.empty?
42
+ return '' if sections.empty?
43
+
44
+ "#{class_label}\n#{sections.join("\n")}"
45
+ end
46
+
47
+ private
48
+
49
+ #: () -> String
50
+ def class_label
51
+ @parameter_class.name || @parameter_class.inspect
52
+ end
53
+
54
+ #: (Array[Issue]) -> String
55
+ def format_issues(issues)
56
+ issues.map { |issue| "- #{issue.path}: #{issue.message}" }.join("\n")
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end