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 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,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,6 @@
1
+ en:
2
+ activemodel:
3
+ errors:
4
+ messages:
5
+ required: "is required"
6
+ invalid_type: "must be a valid %{expected}"
@@ -0,0 +1,6 @@
1
+ zh:
2
+ activemodel:
3
+ errors:
4
+ messages:
5
+ required: "不能为空"
6
+ invalid_type: "必须是有效的%{expected}"
@@ -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