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 +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +34 -0
- data/README_ja.md +34 -0
- data/docs/drift-check.md +379 -0
- data/docs/drift-check_ja.md +379 -0
- data/lib/structured_params/openapi/configuration.rb +131 -0
- data/lib/structured_params/openapi/drift_check/issue.rb +16 -0
- data/lib/structured_params/openapi/drift_check/result.rb +61 -0
- data/lib/structured_params/openapi/drift_check.rb +531 -0
- data/lib/structured_params/openapi/registry.rb +92 -0
- data/lib/structured_params/openapi/version.rb +8 -0
- data/lib/structured_params/openapi.rb +28 -0
- data/lib/tasks/structured_params_openapi.rake +15 -0
- data/sig/structured_params/openapi/configuration.rbs +74 -0
- data/sig/structured_params/openapi/drift_check/issue.rbs +19 -0
- data/sig/structured_params/openapi/drift_check/result.rbs +40 -0
- data/sig/structured_params/openapi/drift_check.rbs +201 -0
- data/sig/structured_params/openapi/registry.rbs +46 -0
- data/sig/structured_params/openapi/version.rbs +7 -0
- data/sig/structured_params/openapi.rbs +14 -0
- metadata +82 -0
|
@@ -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
|