action_spec 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/MIT-LICENSE +20 -0
- data/README.md +480 -0
- data/Rakefile +3 -0
- data/config/locales/en.yml +6 -0
- data/config/locales/zh.yml +6 -0
- data/lib/action_spec/configuration.rb +38 -0
- data/lib/action_spec/doc/dsl.rb +109 -0
- data/lib/action_spec/doc/endpoint.rb +140 -0
- data/lib/action_spec/doc.rb +68 -0
- data/lib/action_spec/header_hash.rb +11 -0
- data/lib/action_spec/invalid_parameters.rb +14 -0
- data/lib/action_spec/railtie.rb +14 -0
- data/lib/action_spec/schema/array_of.rb +31 -0
- data/lib/action_spec/schema/base.rb +67 -0
- data/lib/action_spec/schema/field.rb +27 -0
- data/lib/action_spec/schema/object_of.rb +52 -0
- data/lib/action_spec/schema/resolver.rb +56 -0
- data/lib/action_spec/schema/scalar.rb +30 -0
- data/lib/action_spec/schema/type_caster.rb +115 -0
- data/lib/action_spec/schema.rb +72 -0
- data/lib/action_spec/validation_result.rb +71 -0
- data/lib/action_spec/validator/runner.rb +79 -0
- data/lib/action_spec/validator.rb +46 -0
- data/lib/action_spec/version.rb +3 -0
- data/lib/action_spec.rb +24 -0
- data/lib/tasks/action_spec_tasks.rake +4 -0
- metadata +103 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 888d8ca36314d55e09ba317663b9fdee4e09895480d0c936658ba844268783ef
|
|
4
|
+
data.tar.gz: 4e36cb8eea3d23a338e558784ec8c72bb4bdfecc8c981d094c72784b0f88181e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8fb29df856db1970afb0b1b24c11316501f5edf6e9962f244d31af0dd13d150eaf02c1082e84c931d2ac77798dc2239b063092b4cda0dbbdc6d6fd28d3240df4
|
|
7
|
+
data.tar.gz: 2f0cc430e55895e929ca985723c0f8aa7b1be6549af05184f71f171b122afeda65ec47529c249aea93f7acf0d3ccede50405bcbc841af05ad00c62155a39d093
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright zhandao
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# ActionSpec [WIP]
|
|
2
|
+
|
|
3
|
+
Concise and Powerful API Documentation Solution for Rails.
|
|
4
|
+
|
|
5
|
+
<img src=".github/assets/action_spec.jpg" />
|
|
6
|
+
|
|
7
|
+
- OpenAPI version: `v3.2.0`
|
|
8
|
+
- Requires: Ruby 3.1+ and Rails 7.0+
|
|
9
|
+
- Note: this project was implemented with Codex in about one hour, has not yet been manually reviewed, and has not been validated in production. It does, however, come with fairly detailed RSpec tests generated with Codex.
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
ActionSpec keeps API request contracts close to controller actions. It gives you a readable DSL for declaring request and response shapes, and runtime helpers for validation and type coercion.
|
|
14
|
+
|
|
15
|
+
## Current Scope
|
|
16
|
+
|
|
17
|
+
- A controller-friendly DSL for declaring request and response contracts
|
|
18
|
+
- Runtime validation and type coercion based on that DSL
|
|
19
|
+
- `px`, a validated hash built from the declared contract
|
|
20
|
+
|
|
21
|
+
OpenAPI generation is planned, but not implemented yet.
|
|
22
|
+
|
|
23
|
+
### Quick Start
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
class UsersController < ApplicationController
|
|
27
|
+
before_action :validate_and_coerce_params!, only: :create
|
|
28
|
+
|
|
29
|
+
doc {
|
|
30
|
+
header :Authorization, String
|
|
31
|
+
path :account_id, Integer
|
|
32
|
+
query :locale, String, default: "zh-CN"
|
|
33
|
+
query :page, Integer, default: -> { 1 }
|
|
34
|
+
|
|
35
|
+
json data: {
|
|
36
|
+
name!: String,
|
|
37
|
+
age: Integer,
|
|
38
|
+
birthday: Date,
|
|
39
|
+
admin: { type: :boolean, default: false },
|
|
40
|
+
tags: [String],
|
|
41
|
+
profile: {
|
|
42
|
+
nickname!: String
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
response 200, desc: "success"
|
|
47
|
+
}
|
|
48
|
+
def create
|
|
49
|
+
User.create!(
|
|
50
|
+
account_id: px[:account_id],
|
|
51
|
+
name: px[:name],
|
|
52
|
+
birthday: px[:birthday],
|
|
53
|
+
admin: px[:admin]
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# Gemfile
|
|
63
|
+
gem "action_spec"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then run:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
$ bundle
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
### How To Bind `doc`
|
|
75
|
+
|
|
76
|
+
Default form, with action inferred from the next instance method:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
doc {
|
|
80
|
+
json data: { name!: String }
|
|
81
|
+
}
|
|
82
|
+
def create
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
You can still provide a summary in the default form:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
doc("Create user") {
|
|
90
|
+
json data: { name!: String }
|
|
91
|
+
}
|
|
92
|
+
def create
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
You can also bind it explicitly when you want the action name declared in place:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
doc(:create, "Create user") {
|
|
100
|
+
json data: { name!: String }
|
|
101
|
+
}
|
|
102
|
+
def create
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Shared Declarations With `doc_dry`
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
class ApplicationController < ActionController::API
|
|
110
|
+
doc_dry %i[show update destroy] do
|
|
111
|
+
path! :id, Integer
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
doc_dry :index do
|
|
115
|
+
query :page, Integer, default: 1
|
|
116
|
+
query :per, Integer, default: 20
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
All matching dry blocks are applied before the action-specific `doc`.
|
|
122
|
+
|
|
123
|
+
### DSL Reference
|
|
124
|
+
|
|
125
|
+
ActionSpec keeps the request DSL close to `zero-rails_openapi`.
|
|
126
|
+
|
|
127
|
+
#### Parameter Locations
|
|
128
|
+
|
|
129
|
+
Single-parameter forms:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
header :Authorization, String
|
|
133
|
+
header! :Authorization, String
|
|
134
|
+
|
|
135
|
+
path :id, Integer
|
|
136
|
+
path! :id, Integer
|
|
137
|
+
|
|
138
|
+
query :page, Integer
|
|
139
|
+
query! :page, Integer
|
|
140
|
+
|
|
141
|
+
cookie :remember_token, String
|
|
142
|
+
cookie! :remember_token, String
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Bang methods mark the field as required. For example, `query! :page, Integer` means the request must include `page`.
|
|
146
|
+
|
|
147
|
+
Batch declaration forms:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
in_header(
|
|
151
|
+
Authorization: String
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
in_path!(
|
|
155
|
+
id: Integer
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
in_query(
|
|
159
|
+
page: Integer,
|
|
160
|
+
per: { type: Integer, default: 20 },
|
|
161
|
+
locale: String
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
in_cookie(
|
|
165
|
+
remember_token: String
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
in_query!(
|
|
169
|
+
user_id: Integer,
|
|
170
|
+
token: String
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### Request Bodies
|
|
175
|
+
|
|
176
|
+
General form:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
body :json, data: { name!: String, age: Integer }
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Convenience helpers:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
json data: { name!: String }
|
|
186
|
+
|
|
187
|
+
json! data: { name!: String }
|
|
188
|
+
|
|
189
|
+
form data: { file!: File, position: String }
|
|
190
|
+
|
|
191
|
+
form! data: { file!: File }
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Single multipart field helper:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
data :file, File
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
For `body/body!`, `json/json!`, and `form/form!`, the bang form is currently kept for DSL compatibility. At runtime they all contribute to the same body contract, and root-body requiredness is not yet enforced as a separate rule.
|
|
201
|
+
|
|
202
|
+
#### Response Metadata
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
response 200, desc: "success"
|
|
206
|
+
response 422, "validation failed"
|
|
207
|
+
resp 400, "bad request"
|
|
208
|
+
error 401, "unauthorized"
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Response declarations are stored as metadata now. They are not yet used to render responses automatically.
|
|
212
|
+
|
|
213
|
+
### Schema Writing
|
|
214
|
+
|
|
215
|
+
#### Required Fields
|
|
216
|
+
|
|
217
|
+
Use `!` in either place:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
query! :page, Integer
|
|
221
|
+
|
|
222
|
+
json data: {
|
|
223
|
+
name!: String,
|
|
224
|
+
profile: {
|
|
225
|
+
nickname!: String
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Meaning of `!`:
|
|
231
|
+
|
|
232
|
+
- `query!`, `path!`, `header!`, `cookie!` mark the parameter itself as required
|
|
233
|
+
- keys such as `name!:` or `nickname!:` mark nested object fields as required
|
|
234
|
+
- `body!`, `json!`, and `form!` are currently accepted for DSL consistency, but today they behave the same as the non-bang form at runtime
|
|
235
|
+
|
|
236
|
+
#### Supported Runtime Types
|
|
237
|
+
|
|
238
|
+
Scalar types currently supported by validation/coercion:
|
|
239
|
+
|
|
240
|
+
- `String`
|
|
241
|
+
- `Integer`
|
|
242
|
+
- `Float`
|
|
243
|
+
- `BigDecimal`
|
|
244
|
+
- `:boolean`
|
|
245
|
+
- host-defined `Boolean` constant, if the host app already defines one
|
|
246
|
+
- `Date`
|
|
247
|
+
- `DateTime`
|
|
248
|
+
- `Time`
|
|
249
|
+
- `File`
|
|
250
|
+
- `Object`
|
|
251
|
+
|
|
252
|
+
Nested forms:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
json data: {
|
|
256
|
+
tags: [String],
|
|
257
|
+
profile: {
|
|
258
|
+
nickname!: String
|
|
259
|
+
},
|
|
260
|
+
settings: { type: Object }
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
#### Type And Boundary Matrix
|
|
265
|
+
|
|
266
|
+
| Type | Accepted examples | Rejected examples / notes |
|
|
267
|
+
| --- | --- | --- |
|
|
268
|
+
| `String` | `12`, `true`, `""` | Follows `ActiveModel::Type::String`, so `true` becomes `"t"` |
|
|
269
|
+
| `Integer` | `"0"`, `"-12"`, `"+7"`, `12` | Rejects `"12.3"`, `"abc"`, `""` |
|
|
270
|
+
| `Float` | `"0"`, `"-12.5"`, `12`, `12.5` | Rejects `"12.3.4"`, `"abc"` |
|
|
271
|
+
| `BigDecimal` | `"0"`, `"-12.50"`, `12`, `12.5` | Rejects `"abc"` |
|
|
272
|
+
| `:boolean` / host-defined `Boolean` | `true`, `false`, `"1"`, `"0"`, `"true"`, `"false"`, `"yes"`, `"no"`, `"on"`, `"off"` | Rejects ambiguous values such as `""`, `"2"`, `"TRUE "`, `"maybe"` |
|
|
273
|
+
| `Date` | `"2025-10-17"` | Rejects invalid dates such as `"2025-02-30"` |
|
|
274
|
+
| `DateTime` | `"2025-10-17T12:30:00Z"` | Rejects invalid datetimes such as `"2025-10-17 25:00:00"` |
|
|
275
|
+
| `Time` | `"2025-10-17T12:30:00Z"` | Follows `ActiveModel::Type::Time`, so the date part becomes `2000-01-01` |
|
|
276
|
+
| `File` | `ActionDispatch::Http::UploadedFile`, `Tempfile`, file-like IO objects | Keeps the object as-is and does not read file contents into memory |
|
|
277
|
+
| `Object` | `Hash`, `ActionController::Parameters`, arbitrary Ruby objects | Passed through for scalar `Object`; nested hashes use object schema resolution |
|
|
278
|
+
| `[Type]` | arrays such as `%w[1 2 3]` for `[Integer]` | Rejects non-array values, and reports item errors like `tags.1` |
|
|
279
|
+
| nested object | `{ profile: { nickname: "neo" } }` | Rejects non-hash values, and reports nested paths like `profile.nickname` |
|
|
280
|
+
|
|
281
|
+
#### Supported Runtime Options
|
|
282
|
+
|
|
283
|
+
These options are currently used by the validator:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
query :page, Integer, default: 1
|
|
287
|
+
query :today, Date, default: -> { Time.current.to_date }
|
|
288
|
+
query :status, String, enum: %w[draft published]
|
|
289
|
+
query :score, Integer, range: { ge: 1, le: 5 }
|
|
290
|
+
query :slug, String, pattern: /\A[a-z\-]+\z/
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
These options are currently accepted as metadata, mainly for future OpenAPI work, but are not yet used by the runtime validator:
|
|
294
|
+
|
|
295
|
+
- `desc`
|
|
296
|
+
- `example`
|
|
297
|
+
- `examples`
|
|
298
|
+
- `allow_nil`
|
|
299
|
+
- `allow_blank`
|
|
300
|
+
|
|
301
|
+
### Validation Flow
|
|
302
|
+
|
|
303
|
+
#### `validate_params!`
|
|
304
|
+
|
|
305
|
+
Validates using the DSL, but keeps raw values in `px`.
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
before_action :validate_params!
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Example:
|
|
312
|
+
|
|
313
|
+
- request query param `"page" => "2"`
|
|
314
|
+
- DSL says `query :page, Integer`
|
|
315
|
+
- result: `px[:page] == "2"`
|
|
316
|
+
|
|
317
|
+
#### `validate_and_coerce_params!`
|
|
318
|
+
|
|
319
|
+
Validates and coerces values before exposing them on `px`.
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
before_action :validate_and_coerce_params!
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Example:
|
|
326
|
+
|
|
327
|
+
- request query param `"page" => "2"`
|
|
328
|
+
- DSL says `query :page, Integer`
|
|
329
|
+
- result: `px[:page] == 2`
|
|
330
|
+
|
|
331
|
+
### Reading Validated Values With `px`
|
|
332
|
+
|
|
333
|
+
`px` is a hash.
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
px[:id]
|
|
337
|
+
px[:page]
|
|
338
|
+
px[:profile][:nickname]
|
|
339
|
+
px.to_h
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
It also includes grouped buckets:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
px[:path]
|
|
346
|
+
px[:query]
|
|
347
|
+
px[:body]
|
|
348
|
+
px[:headers]
|
|
349
|
+
px[:cookies]
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Notes:
|
|
353
|
+
|
|
354
|
+
- root values from path/query/body are also flattened into `px[:name]`
|
|
355
|
+
- header keys are stored in lowercase dashed form, but reading remains compatible with original forms such as `Authorization` and `HTTP_AUTHORIZATION`, for example:
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
px[:headers][:authorization]
|
|
359
|
+
px[:headers]["Authorization"]
|
|
360
|
+
px[:headers]["HTTP_AUTHORIZATION"]
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
- original `params` are not mutated
|
|
364
|
+
|
|
365
|
+
### Errors
|
|
366
|
+
|
|
367
|
+
Validation errors are stored in `ActiveModel::Errors`.
|
|
368
|
+
|
|
369
|
+
If invalid parameters are not rescued, ActionSpec raises `ActionSpec::InvalidParameters`:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
begin
|
|
373
|
+
validate_and_coerce_params!
|
|
374
|
+
rescue ActionSpec::InvalidParameters => error
|
|
375
|
+
error.errors.full_messages
|
|
376
|
+
end
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
The exception also keeps the full validation result on `error.result` and `error.parameters`.
|
|
380
|
+
|
|
381
|
+
### Default Rescue Behavior
|
|
382
|
+
|
|
383
|
+
By default, when a controller raises `ActionSpec::InvalidParameters`, ActionSpec catches it automatically and returns a JSON error response:
|
|
384
|
+
|
|
385
|
+
```ruby
|
|
386
|
+
rescue_from ActionSpec::InvalidParameters
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The default JSON response is:
|
|
390
|
+
|
|
391
|
+
```json
|
|
392
|
+
{
|
|
393
|
+
"errors": {
|
|
394
|
+
"page": ["Page is required"]
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Configuration
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
ActionSpec.configure do |config|
|
|
403
|
+
config.rescue_invalid_parameters = true
|
|
404
|
+
config.invalid_parameters_status = :bad_request
|
|
405
|
+
|
|
406
|
+
config.error_messages[:invalid_type] = ->(_attribute, options) do
|
|
407
|
+
"should be coercible to #{options.fetch(:expected)}"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
config.invalid_parameters_renderer = ->(controller, error) do
|
|
411
|
+
controller.render json: {
|
|
412
|
+
code: "invalid_parameters",
|
|
413
|
+
errors: error.errors.to_hash(full_messages: true)
|
|
414
|
+
}, status: :unprocessable_entity
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Available config keys:
|
|
420
|
+
|
|
421
|
+
- `invalid_parameters_exception_class`
|
|
422
|
+
Default: `ActionSpec::InvalidParameters`.
|
|
423
|
+
Controls which exception class is raised when validation fails.
|
|
424
|
+
|
|
425
|
+
- `invalid_parameters_status`
|
|
426
|
+
Default: `:bad_request`.
|
|
427
|
+
Controls the HTTP status used by the built-in `rescue_from` renderer.
|
|
428
|
+
|
|
429
|
+
- `rescue_invalid_parameters`
|
|
430
|
+
Default: `true`.
|
|
431
|
+
When this option is enabled, controllers use the default `rescue_from ActionSpec::InvalidParameters`.
|
|
432
|
+
|
|
433
|
+
- `invalid_parameters_renderer`
|
|
434
|
+
Default: `nil`.
|
|
435
|
+
Lets you replace the built-in JSON error response. It can be a proc receiving `(controller, error)`, or a block executed in controller context.
|
|
436
|
+
|
|
437
|
+
- `error_messages`
|
|
438
|
+
Default: `{}`.
|
|
439
|
+
Lets you override error messages by error type, or by attribute plus error type.
|
|
440
|
+
|
|
441
|
+
### I18n
|
|
442
|
+
|
|
443
|
+
ActionSpec loads its own locale files and uses `ActiveModel::Errors`, so you can override both messages and attribute names:
|
|
444
|
+
|
|
445
|
+
```yml
|
|
446
|
+
en:
|
|
447
|
+
activemodel:
|
|
448
|
+
attributes:
|
|
449
|
+
action_spec/parameters:
|
|
450
|
+
"profile.nickname": "Profile nickname"
|
|
451
|
+
errors:
|
|
452
|
+
messages:
|
|
453
|
+
required: "is required"
|
|
454
|
+
invalid_type: "must be a valid %{expected}"
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
You can also override messages per error type or per attribute in Ruby:
|
|
458
|
+
|
|
459
|
+
```ruby
|
|
460
|
+
ActionSpec.configure do |config|
|
|
461
|
+
config.error_messages[:required] = "must be present"
|
|
462
|
+
config.error_messages[:invalid_type] = ->(_attribute, options) { "must be a valid #{options.fetch(:expected)}" }
|
|
463
|
+
config.error_messages[:page] = {
|
|
464
|
+
required: "page is mandatory"
|
|
465
|
+
}
|
|
466
|
+
end
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### What Is Not Implemented Yet
|
|
470
|
+
|
|
471
|
+
- OpenAPI document generation
|
|
472
|
+
- automatic response rendering from `response`
|
|
473
|
+
- reusable schema/components system from `zero-rails_openapi`
|
|
474
|
+
- runtime behavior for `allow_nil` / `allow_blank`
|
|
475
|
+
|
|
476
|
+
## Contributing
|
|
477
|
+
.
|
|
478
|
+
|
|
479
|
+
## License
|
|
480
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :invalid_parameters_exception_class, :invalid_parameters_status, :rescue_invalid_parameters,
|
|
6
|
+
:invalid_parameters_renderer
|
|
7
|
+
attr_reader :error_messages
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@invalid_parameters_exception_class = ActionSpec::InvalidParameters
|
|
11
|
+
@invalid_parameters_status = :bad_request
|
|
12
|
+
@rescue_invalid_parameters = true
|
|
13
|
+
@invalid_parameters_renderer = nil
|
|
14
|
+
@error_messages = ActiveSupport::HashWithIndifferentAccess.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def error_messages=(value)
|
|
18
|
+
@error_messages = ActiveSupport::HashWithIndifferentAccess.new(value)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def message_for(attribute, type, options = {})
|
|
22
|
+
configured = error_messages.dig(attribute.to_sym, type.to_sym) || error_messages[type.to_sym]
|
|
23
|
+
return if configured.blank?
|
|
24
|
+
|
|
25
|
+
configured.respond_to?(:call) ? configured.call(attribute.to_sym, options) : configured
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dup
|
|
29
|
+
self.class.new.tap do |copy|
|
|
30
|
+
copy.invalid_parameters_exception_class = invalid_parameters_exception_class
|
|
31
|
+
copy.invalid_parameters_status = invalid_parameters_status
|
|
32
|
+
copy.rescue_invalid_parameters = rescue_invalid_parameters
|
|
33
|
+
copy.invalid_parameters_renderer = invalid_parameters_renderer
|
|
34
|
+
copy.error_messages = error_messages.deep_dup
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionSpec
|
|
4
|
+
module Doc
|
|
5
|
+
class Dsl
|
|
6
|
+
PARAM_LOCATIONS = %i[header path query cookie].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(endpoint)
|
|
9
|
+
@endpoint = endpoint
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
PARAM_LOCATIONS.each do |location_name|
|
|
13
|
+
define_method(location_name) do |name, type = String, **options|
|
|
14
|
+
add_param(location_name, name, type, required: false, **options)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
define_method("#{location_name}!") do |name, type = String, **options|
|
|
18
|
+
add_param(location_name, name, type, required: true, **options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
define_method("in_#{location_name}") do |params|
|
|
22
|
+
add_many(location_name, params, required: false)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
define_method("in_#{location_name}!") do |params|
|
|
26
|
+
add_many(location_name, params, required: true)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def body(media_type, data: {}, **)
|
|
31
|
+
add_body(media_type, data)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def body!(media_type, data: {}, **)
|
|
35
|
+
add_body(media_type, data)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def json(data:, **options)
|
|
39
|
+
body(:json, data:, **options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def json!(data:, **options)
|
|
43
|
+
body!(:json, data:, **options)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def form(data:, **options)
|
|
47
|
+
body(:form, data:, **options)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def form!(data:, **options)
|
|
51
|
+
body!(:form, data:, **options)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def data(name, type = String, **options)
|
|
55
|
+
add_body(:form, { name => options.merge(type:) })
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def response(code, description = nil, media_type = nil, desc: nil, **options)
|
|
59
|
+
endpoint.add_response(
|
|
60
|
+
code,
|
|
61
|
+
Response.new(
|
|
62
|
+
code:,
|
|
63
|
+
description: description || desc.to_s,
|
|
64
|
+
media_type:,
|
|
65
|
+
options:
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
alias resp response
|
|
71
|
+
alias error response
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
attr_reader :endpoint
|
|
76
|
+
|
|
77
|
+
def add_param(location_name, name, type, required:, **options)
|
|
78
|
+
schema = ActionSpec::Schema.build(type, **options)
|
|
79
|
+
endpoint.request.add_param(location_name, ActionSpec::Schema::Field.new(name:, required:, schema:))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def add_many(location_name, params, required:)
|
|
83
|
+
params.each_pair do |name, definition|
|
|
84
|
+
if definition.is_a?(Hash) && !definition.key?(:type) && !definition.key?("type")
|
|
85
|
+
schema_options = definition.symbolize_keys
|
|
86
|
+
if (schema_options.keys - ActionSpec::Schema::OPTION_KEYS).present?
|
|
87
|
+
endpoint.request.add_param(
|
|
88
|
+
location_name,
|
|
89
|
+
ActionSpec::Schema::Field.new(name:, required:, schema: ActionSpec::Schema.from_definition(definition))
|
|
90
|
+
)
|
|
91
|
+
else
|
|
92
|
+
add_param(location_name, name, String, required:, **definition)
|
|
93
|
+
end
|
|
94
|
+
elsif definition.is_a?(Hash)
|
|
95
|
+
add_param(location_name, name, definition[:type] || definition["type"] || String, required:, **definition.symbolize_keys.except(:type))
|
|
96
|
+
else
|
|
97
|
+
add_param(location_name, name, definition, required:)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def add_body(media_type, definition)
|
|
103
|
+
ActionSpec::Schema.build_fields(definition).each_value do |field|
|
|
104
|
+
endpoint.request.add_body(media_type, field)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|