action_spec 0.1.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 888d8ca36314d55e09ba317663b9fdee4e09895480d0c936658ba844268783ef
4
- data.tar.gz: 4e36cb8eea3d23a338e558784ec8c72bb4bdfecc8c981d094c72784b0f88181e
3
+ metadata.gz: 6fccbffa8f14fb73bec7eade75add8f70d0371741999475ef292ed980d985ddc
4
+ data.tar.gz: 20f1b1d3b86826b1cb9d2ed3d72f0259ccd28c397790bbf8bc8fb8766cab0329
5
5
  SHA512:
6
- metadata.gz: 8fb29df856db1970afb0b1b24c11316501f5edf6e9962f244d31af0dd13d150eaf02c1082e84c931d2ac77798dc2239b063092b4cda0dbbdc6d6fd28d3240df4
7
- data.tar.gz: 2f0cc430e55895e929ca985723c0f8aa7b1be6549af05184f71f171b122afeda65ec47529c249aea93f7acf0d3ccede50405bcbc841af05ad00c62155a39d093
6
+ metadata.gz: 552c83ccebe73754cb2b6e160cbfb8774ea818e22e665a1ba6df322c5051e5ff7902b533d82c2c2a93f94fd0f631264e2e344521b67a3582576a27b5f4ce2144
7
+ data.tar.gz: 9585aa85edeb6654d00e06b7a0e2478aed741675d3d90e0b60c46ac99d267e68d13bbe30060f6b163b1869ac508b92c4509b8b0b0f69f3ae7d0c386da875afce
data/README.md CHANGED
@@ -6,21 +6,31 @@ Concise and Powerful API Documentation Solution for Rails.
6
6
 
7
7
  - OpenAPI version: `v3.2.0`
8
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
9
+ - Note: this project was implemented with Codex in about 2 hours, 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
+ ## Table Of Contents
12
+
13
+ - [OpenAPI Generation](#openapi-generation)
14
+ - [Doc DSL](#doc-dsl)
15
+ - [`doc`](#doc)
16
+ - [`doc_dry`](#doc_dry)
17
+ - [DSL Reference](#dsl-reference)
18
+ - [Schemas](#schemas)
19
+ - [Declare A Required Field](#declare-a-required-field)
20
+ - [Field Types](#field-types)
21
+ - [Field Options](#field-options)
22
+ - [Schemas From ActiveRecord](#schemas-from-activerecord)
23
+ - [Type And Boundary Matrix](#type-and-boundary-matrix)
24
+ - [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
25
+ - [Validation Flow](#validation-flow)
26
+ - [Reading Validated Values With `px`](#reading-validated-values-with-px)
27
+ - [Errors](#errors)
28
+ - [Default Rescue Behavior](#default-rescue-behavior)
29
+ - [Configuration And I18n](#configuration-and-i18n)
30
+ - [Configuration](#configuration)
31
+ - [I18n](#i18n)
32
+
33
+ ## Example
24
34
 
25
35
  ```ruby
26
36
  class UsersController < ApplicationController
@@ -32,7 +42,7 @@ class UsersController < ApplicationController
32
42
  query :locale, String, default: "zh-CN"
33
43
  query :page, Integer, default: -> { 1 }
34
44
 
35
- json data: {
45
+ form data: {
36
46
  name!: String,
37
47
  age: Integer,
38
48
  birthday: Date,
@@ -69,25 +79,58 @@ Then run:
69
79
  $ bundle
70
80
  ```
71
81
 
72
- ## Usage
82
+ ## OpenAPI Generation
83
+
84
+ Generate an OpenAPI document from the current Rails routes and ActionSpec controller docs:
85
+
86
+ ```bash
87
+ bin/rails action_spec:gen
88
+ ```
89
+
90
+ By default, this writes to:
91
+
92
+ ```text
93
+ docs/openapi.yml
94
+ ```
95
+
96
+ For one-off runs, environment variables can override the default output path and document metadata:
97
+
98
+ ```bash
99
+ bin/rails action_spec:gen \
100
+ OUTPUT=docs/openapi.yml \
101
+ TITLE="My API" \
102
+ VERSION="2026.03" \
103
+ SERVER_URL="https://api.example.com"
104
+ ```
105
+
106
+ Notes:
107
+
108
+ - only routed controller actions with a matching `doc` declaration are included
109
+ - Rails paths such as `/users/:id(.:format)` are rendered as `/users/{id}`
110
+ - parameters, request bodies, and response descriptions are generated from the current DSL support
111
+ - if config and environment variables do not provide `TITLE` or `VERSION`, ActionSpec falls back to application-derived defaults
112
+
113
+ ## Doc DSL
73
114
 
74
- ### How To Bind `doc`
115
+ ### `doc`
75
116
 
76
- Default form, with action inferred from the next instance method:
117
+ With action inferred from the next instance method:
77
118
 
78
119
  ```ruby
79
120
  doc {
80
- json data: { name!: String }
121
+ form data: { # <= request body DSL
122
+ name!: String # <= schema DSL
123
+ }
81
124
  }
82
125
  def create
83
126
  end
84
127
  ```
85
128
 
86
- You can still provide a summary in the default form:
129
+ Provide a summary:
87
130
 
88
131
  ```ruby
89
132
  doc("Create user") {
90
- json data: { name!: String }
133
+ form data: { name!: String }
91
134
  }
92
135
  def create
93
136
  end
@@ -97,13 +140,13 @@ You can also bind it explicitly when you want the action name declared in place:
97
140
 
98
141
  ```ruby
99
142
  doc(:create, "Create user") {
100
- json data: { name!: String }
143
+ form data: { name!: String }
101
144
  }
102
145
  def create
103
146
  end
104
147
  ```
105
148
 
106
- ### Shared Declarations With `doc_dry`
149
+ ### `doc_dry`
107
150
 
108
151
  ```ruby
109
152
  class ApplicationController < ActionController::API
@@ -122,11 +165,7 @@ All matching dry blocks are applied before the action-specific `doc`.
122
165
 
123
166
  ### DSL Reference
124
167
 
125
- ActionSpec keeps the request DSL close to `zero-rails_openapi`.
126
-
127
- #### Parameter Locations
128
-
129
- Single-parameter forms:
168
+ #### Parameter
130
169
 
131
170
  ```ruby
132
171
  header :Authorization, String
@@ -171,7 +210,7 @@ in_query!(
171
210
  )
172
211
  ```
173
212
 
174
- #### Request Bodies
213
+ #### request body
175
214
 
176
215
  General form:
177
216
 
@@ -199,7 +238,7 @@ data :file, File
199
238
 
200
239
  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
240
 
202
- #### Response Metadata
241
+ #### Response
203
242
 
204
243
  ```ruby
205
244
  response 200, desc: "success"
@@ -210,9 +249,9 @@ error 401, "unauthorized"
210
249
 
211
250
  Response declarations are stored as metadata now. They are not yet used to render responses automatically.
212
251
 
213
- ### Schema Writing
252
+ ## Schemas
214
253
 
215
- #### Required Fields
254
+ #### Declare A Required Field
216
255
 
217
256
  Use `!` in either place:
218
257
 
@@ -233,7 +272,7 @@ Meaning of `!`:
233
272
  - keys such as `name!:` or `nickname!:` mark nested object fields as required
234
273
  - `body!`, `json!`, and `form!` are currently accepted for DSL consistency, but today they behave the same as the non-bang form at runtime
235
274
 
236
- #### Supported Runtime Types
275
+ #### Field Types
237
276
 
238
277
  Scalar types currently supported by validation/coercion:
239
278
 
@@ -241,8 +280,7 @@ Scalar types currently supported by validation/coercion:
241
280
  - `Integer`
242
281
  - `Float`
243
282
  - `BigDecimal`
244
- - `:boolean`
245
- - host-defined `Boolean` constant, if the host app already defines one
283
+ - `:boolean` / `Boolean`
246
284
  - `Date`
247
285
  - `DateTime`
248
286
  - `Time`
@@ -257,28 +295,12 @@ json data: {
257
295
  profile: {
258
296
  nickname!: String
259
297
  },
260
- settings: { type: Object }
298
+ settings: { type: Object },
299
+ users: [{ id: Integer }]
261
300
  }
262
301
  ```
263
302
 
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
303
+ #### Field Options
282
304
 
283
305
  These options are currently used by the validator:
284
306
 
@@ -290,14 +312,87 @@ query :score, Integer, range: { ge: 1, le: 5 }
290
312
  query :slug, String, pattern: /\A[a-z\-]+\z/
291
313
  ```
292
314
 
293
- These options are currently accepted as metadata, mainly for future OpenAPI work, but are not yet used by the runtime validator:
315
+ These options are currently used by OpenAPI generation, but are not yet used by the runtime validator:
294
316
 
295
317
  - `desc`
296
318
  - `example`
297
319
  - `examples`
320
+
321
+ These options are not yet used by either the runtime validator or OpenAPI generation:
322
+
298
323
  - `allow_nil`
299
324
  - `allow_blank`
300
325
 
326
+ #### Schemas From ActiveRecord
327
+
328
+ If your model is an `ActiveRecord::Base`, you can derive an ActionSpec-friendly schema hash directly from the model:
329
+
330
+ ```ruby
331
+ class UsersController < ApplicationController
332
+ doc {
333
+ form data: User.schemas
334
+ }
335
+ def create
336
+ end
337
+ end
338
+ ```
339
+
340
+ `User.schemas` returns a hash that can be passed directly into `form data:`, `json data:`, or `body`.
341
+
342
+ By default it includes all model fields:
343
+
344
+ ```ruby
345
+ User.schemas
346
+ ```
347
+
348
+ You can also limit the exported fields:
349
+
350
+ ```ruby
351
+ User.schemas(only: %i[name phone role])
352
+ ```
353
+
354
+ ActionSpec extracts schema-relevant information from ActiveRecord / ActiveModel when available, including:
355
+
356
+ - field type
357
+ - requiredness, rendered as bang keys such as `"name!"`
358
+ - enum values from `enum`
359
+ - `default`
360
+ - `desc` from column comments
361
+ - `pattern` from format validators
362
+ - `range` from numericality validators
363
+ - `length` from length validators and string column limits
364
+
365
+ Example output:
366
+
367
+ ```ruby
368
+ User.schemas
369
+ # {
370
+ # "name!" => { type: String, desc: "user name", length: { maximum: 20 } },
371
+ # "phone!" => { type: String, length: { maximum: 13 }, pattern: /\A1\d{10}\z/ },
372
+ # "role" => { type: String, enum: %w[admin member visitor] }
373
+ # }
374
+ ```
375
+
376
+ #### Type And Boundary Matrix
377
+
378
+ | Type | Accepted examples | Rejected examples / notes |
379
+ | --- | --- | --- |
380
+ | `String` | `12`, `true`, `""` | Follows `ActiveModel::Type::String`, so `true` becomes `"t"` |
381
+ | `Integer` | `"0"`, `"-12"`, `"+7"`, `12` | Rejects `"12.3"`, `"abc"`, `""` |
382
+ | `Float` | `"0"`, `"-12.5"`, `12`, `12.5` | Rejects `"12.3.4"`, `"abc"` |
383
+ | `BigDecimal` | `"0"`, `"-12.50"`, `12`, `12.5` | Rejects `"abc"` |
384
+ | `:boolean` / `Boolean` | `true`, `false`, `"1"`, `"0"`, `"true"`, `"false"`, `"yes"`, `"no"`, `"on"`, `"off"` | Rejects ambiguous values such as `""`, `"2"`, `"TRUE "`, `"maybe"` |
385
+ | `Date` | `"2025-10-17"` | Rejects invalid dates such as `"2025-02-30"` |
386
+ | `DateTime` | `"2025-10-17T12:30:00Z"` | Rejects invalid datetimes such as `"2025-10-17 25:00:00"` |
387
+ | `Time` | `"2025-10-17T12:30:00Z"` | Follows `ActiveModel::Type::Time`, so the date part becomes `2000-01-01` |
388
+ | `File` | `ActionDispatch::Http::UploadedFile`, `Tempfile`, file-like IO objects | Keeps the object as-is and does not read file contents into memory |
389
+ | `Object` | `Hash`, `ActionController::Parameters`, arbitrary Ruby objects | Passed through for scalar `Object`; nested hashes use object schema resolution |
390
+ | `[Type]` | arrays such as `%w[1 2 3]` for `[Integer]` | Rejects non-array values, and reports item errors like `tags.1` |
391
+ | nested object | `{ profile: { nickname: "neo" } }` | Rejects non-hash values, and reports nested paths like `profile.nickname` |
392
+
393
+
394
+ ## Parameter Validation And Type Coercion
395
+
301
396
  ### Validation Flow
302
397
 
303
398
  #### `validate_params!`
@@ -396,12 +491,18 @@ The default JSON response is:
396
491
  }
397
492
  ```
398
493
 
494
+ ## Configuration And I18n
495
+
399
496
  ### Configuration
400
497
 
401
498
  ```ruby
402
499
  ActionSpec.configure do |config|
403
500
  config.rescue_invalid_parameters = true
404
501
  config.invalid_parameters_status = :bad_request
502
+ config.open_api_output = "docs/openapi.yml"
503
+ config.open_api_title = "My API"
504
+ config.open_api_version = "2026.03"
505
+ config.open_api_server_url = "https://api.example.com"
405
506
 
406
507
  config.error_messages[:invalid_type] = ->(_attribute, options) do
407
508
  "should be coercible to #{options.fetch(:expected)}"
@@ -438,6 +539,22 @@ Available config keys:
438
539
  Default: `{}`.
439
540
  Lets you override error messages by error type, or by attribute plus error type.
440
541
 
542
+ - `open_api_output`
543
+ Default: `"docs/openapi.yml"`.
544
+ Controls where `bin/rails action_spec:gen` writes the generated OpenAPI document.
545
+
546
+ - `open_api_title`
547
+ Default: `nil`.
548
+ Sets the default OpenAPI `info.title` used by `bin/rails action_spec:gen`.
549
+
550
+ - `open_api_version`
551
+ Default: `nil`.
552
+ Sets the default OpenAPI `info.version` used by `bin/rails action_spec:gen`.
553
+
554
+ - `open_api_server_url`
555
+ Default: `nil`.
556
+ Sets the default server URL emitted in the generated OpenAPI document.
557
+
441
558
  ### I18n
442
559
 
443
560
  ActionSpec loads its own locale files and uses `ActiveModel::Errors`, so you can override both messages and attribute names:
@@ -466,12 +583,26 @@ ActionSpec.configure do |config|
466
583
  end
467
584
  ```
468
585
 
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`
586
+ ## What Is Not Implemented Yet
587
+
588
+ - reusable `components` generation
589
+ - `$ref` generation and deduplication
590
+ - `description`, `operationId`, `tags`, `externalDocs`, `deprecated`, and `security` on operations
591
+ - parameter-level `style`, `explode`, `allowReserved`, `examples`, and richer header/cookie serialization controls
592
+ - request body `encoding`
593
+ - multiple request/response media types beyond the current direct DSL mapping
594
+ - response body schema generation; current `response` / `resp` / `error` declarations only generate response descriptions
595
+ - response headers
596
+ - response links
597
+ - callbacks
598
+ - webhooks
599
+ - path-level shared parameters
600
+ - top-level `components.parameters`, `components.requestBodies`, `components.responses`, `components.headers`, `components.examples`, `components.links`, `components.callbacks`, `components.schemas`, `components.securitySchemes`, and `components.pathItems`
601
+ - top-level `security`
602
+ - top-level `tags`
603
+ - top-level `externalDocs`
604
+ - `jsonSchemaDialect`
605
+ - richer schema keywords beyond the current subset, including nullable/blank semantics, object-level constraints, and composition keywords such as `oneOf`, `anyOf`, `allOf`, and `not`
475
606
 
476
607
  ## Contributing
477
608
  .
@@ -3,7 +3,8 @@
3
3
  module ActionSpec
4
4
  class Configuration
5
5
  attr_accessor :invalid_parameters_exception_class, :invalid_parameters_status, :rescue_invalid_parameters,
6
- :invalid_parameters_renderer
6
+ :invalid_parameters_renderer, :open_api_output, :open_api_title, :open_api_version,
7
+ :open_api_server_url
7
8
  attr_reader :error_messages
8
9
 
9
10
  def initialize
@@ -11,6 +12,10 @@ module ActionSpec
11
12
  @invalid_parameters_status = :bad_request
12
13
  @rescue_invalid_parameters = true
13
14
  @invalid_parameters_renderer = nil
15
+ @open_api_output = "docs/openapi.yml"
16
+ @open_api_title = nil
17
+ @open_api_version = nil
18
+ @open_api_server_url = nil
14
19
  @error_messages = ActiveSupport::HashWithIndifferentAccess.new
15
20
  end
16
21
 
@@ -31,6 +36,10 @@ module ActionSpec
31
36
  copy.invalid_parameters_status = invalid_parameters_status
32
37
  copy.rescue_invalid_parameters = rescue_invalid_parameters
33
38
  copy.invalid_parameters_renderer = invalid_parameters_renderer
39
+ copy.open_api_output = open_api_output
40
+ copy.open_api_title = open_api_title
41
+ copy.open_api_version = open_api_version
42
+ copy.open_api_server_url = open_api_server_url
34
43
  copy.error_messages = error_messages.deep_dup
35
44
  end
36
45
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionSpec
4
+ module OpenApi
5
+ class Document
6
+ OPENAPI_VERSION = "3.2.0"
7
+
8
+ def initialize(title:, version:, server_url: nil)
9
+ @title = title
10
+ @version = version
11
+ @server_url = server_url
12
+ end
13
+
14
+ def build(paths:)
15
+ {
16
+ "openapi" => OPENAPI_VERSION,
17
+ "info" => {
18
+ "title" => title,
19
+ "version" => version
20
+ },
21
+ "paths" => paths
22
+ }.tap do |document|
23
+ document["servers"] = [{ "url" => server_url }] if server_url.present?
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :title, :version, :server_url
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionSpec
4
+ module OpenApi
5
+ class Generator
6
+ class << self
7
+ def generate!(application: nil, routes: nil, output:, title: nil, version: nil, server_url: nil)
8
+ document = new(application:, routes:, title:, version:, server_url:).call
9
+
10
+ FileUtils.mkdir_p(File.dirname(output))
11
+ File.write(output, YAML.dump(document))
12
+ end
13
+ end
14
+
15
+ def initialize(application: nil, routes: nil, title: nil, version: nil, server_url: nil)
16
+ @application = application
17
+ @routes = routes
18
+ @title = title
19
+ @version = version
20
+ @server_url = server_url
21
+ end
22
+
23
+ def call
24
+ Document.new(
25
+ title: resolved_title,
26
+ version: resolved_version,
27
+ server_url:
28
+ ).build(paths:)
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :application, :routes, :title, :version, :server_url
34
+
35
+ def resolved_title
36
+ return title if title.present?
37
+
38
+ application_name = application&.class&.name.to_s.sub(/::Application\z/, "").sub(/Application\z/, "")
39
+ application_name.demodulize.titleize.presence || "API"
40
+ end
41
+
42
+ def resolved_version
43
+ version.presence || "1.0.0"
44
+ end
45
+
46
+ def paths
47
+ route_definitions.each_with_object(ActiveSupport::OrderedHash.new) do |route, hash|
48
+ next unless (controller = controller_for(route))
49
+ next unless controller.respond_to?(:action_spec_for)
50
+ next unless (endpoint = controller.action_spec_for(route_action(route)))
51
+
52
+ path = normalized_path(route)
53
+ next if path.blank?
54
+
55
+ hash[path] ||= ActiveSupport::OrderedHash.new
56
+ route_verbs(route).each do |verb|
57
+ hash[path][verb] = Operation.new(endpoint).build
58
+ end
59
+ end
60
+ end
61
+
62
+ def route_definitions
63
+ return routes if routes
64
+
65
+ application.routes.routes
66
+ end
67
+
68
+ def controller_for(route)
69
+ controller_name = route_defaults(route)[:controller].presence
70
+ return unless controller_name
71
+
72
+ "#{controller_name.camelize}Controller".safe_constantize
73
+ end
74
+
75
+ def route_action(route)
76
+ route_defaults(route).fetch(:action).to_sym
77
+ end
78
+
79
+ def route_defaults(route)
80
+ defaults =
81
+ if route.respond_to?(:defaults)
82
+ route.defaults
83
+ elsif route.respond_to?(:requirements)
84
+ route.requirements
85
+ else
86
+ route[:defaults]
87
+ end
88
+
89
+ defaults.to_h.symbolize_keys
90
+ end
91
+
92
+ def route_verbs(route)
93
+ raw_verb =
94
+ if route.respond_to?(:verb) && route.verb.respond_to?(:source)
95
+ route.verb.source
96
+ elsif route.respond_to?(:verb)
97
+ route.verb.to_s
98
+ else
99
+ route[:verb].to_s
100
+ end
101
+
102
+ raw_verb.gsub(/[$^]/, "").split("|").filter_map { |verb| verb.presence&.downcase }
103
+ end
104
+
105
+ def normalized_path(route)
106
+ raw_path =
107
+ if route.respond_to?(:path) && route.path.respond_to?(:spec)
108
+ route.path.spec.to_s
109
+ elsif route.respond_to?(:path)
110
+ route.path.to_s
111
+ else
112
+ route[:path].to_s
113
+ end
114
+
115
+ raw_path
116
+ .sub(/\(\.:format\)\z/, "")
117
+ .gsub(/:(\w+)/, '{\1}')
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionSpec
4
+ module OpenApi
5
+ class Operation
6
+ def initialize(endpoint)
7
+ @endpoint = endpoint
8
+ @schema = Schema.new
9
+ end
10
+
11
+ def build
12
+ {
13
+ "summary" => endpoint.summary.presence,
14
+ "parameters" => parameters.presence,
15
+ "requestBody" => schema.request_body(endpoint.request),
16
+ "responses" => responses
17
+ }.compact
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :endpoint, :schema
23
+
24
+ def parameters
25
+ %i[path query header cookie].flat_map do |location|
26
+ endpoint.request.public_send(location).fields.map do |field|
27
+ schema.parameter(field, location:)
28
+ end
29
+ end
30
+ end
31
+
32
+ def responses
33
+ return { "200" => { "description" => "OK" } } if endpoint.responses.empty?
34
+
35
+ endpoint.responses.each_with_object(ActiveSupport::OrderedHash.new) do |(code, response), hash|
36
+ hash[code] = { "description" => response.description.presence || "OK" }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionSpec
4
+ module OpenApi
5
+ class Schema
6
+ LOCATION_MAP = {
7
+ header: "header",
8
+ path: "path",
9
+ query: "query",
10
+ cookie: "cookie"
11
+ }.freeze
12
+
13
+ MEDIA_TYPE_MAP = {
14
+ json: "application/json",
15
+ form: "multipart/form-data"
16
+ }.freeze
17
+
18
+ def parameter(field, location:)
19
+ {
20
+ "name" => parameter_name(field, location),
21
+ "in" => LOCATION_MAP.fetch(location),
22
+ "required" => location == :path ? true : field.required?,
23
+ "schema" => schema_for(field.schema)
24
+ }.tap do |parameter|
25
+ if (description = field.schema.description).present?
26
+ parameter["description"] = description
27
+ end
28
+ end
29
+ end
30
+
31
+ def request_body(request)
32
+ content = request.body_media_types.each_with_object(ActiveSupport::OrderedHash.new) do |(media_type, fields), hash|
33
+ hash[MEDIA_TYPE_MAP.fetch(media_type, media_type.to_s)] = {
34
+ "schema" => object_schema(fields.fields)
35
+ }
36
+ end
37
+ return if content.empty?
38
+
39
+ { "content" => content }
40
+ end
41
+
42
+ def schema_for(schema)
43
+ case schema
44
+ when ActionSpec::Schema::Scalar then scalar_schema(schema)
45
+ when ActionSpec::Schema::ObjectOf then object_schema(schema.fields.values, schema:)
46
+ when ActionSpec::Schema::ArrayOf then array_schema(schema)
47
+ else { "type" => "string" }
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def parameter_name(field, location)
54
+ return field.name.to_s if location != :header
55
+
56
+ field.name.to_s.split("_").map(&:capitalize).join("-")
57
+ end
58
+
59
+ def scalar_schema(schema)
60
+ type = scalar_type(schema.type)
61
+ definition =
62
+ case type
63
+ when "string"
64
+ { "type" => "string" }
65
+ when "integer"
66
+ { "type" => "integer" }
67
+ when "number"
68
+ { "type" => "number", "format" => number_format(schema.type) }.compact
69
+ when "boolean"
70
+ { "type" => "boolean" }
71
+ when "file"
72
+ { "type" => "string", "format" => "binary" }
73
+ when "object"
74
+ { "type" => "object" }
75
+ else
76
+ { "type" => "string" }
77
+ end
78
+
79
+ definition["format"] = string_format(schema.type) if string_format(schema.type)
80
+ apply_common_options(definition, schema)
81
+ end
82
+
83
+ def object_schema(fields, schema: nil)
84
+ definition = {
85
+ "type" => "object",
86
+ "properties" => fields.each_with_object(ActiveSupport::OrderedHash.new) do |field, properties|
87
+ properties[field.name.to_s] = schema_for(field.schema)
88
+ end
89
+ }
90
+
91
+ required = fields.select(&:required?).map { |field| field.name.to_s }
92
+ definition["required"] = required if required.any?
93
+
94
+ schema ? apply_common_options(definition, schema) : definition
95
+ end
96
+
97
+ def array_schema(schema)
98
+ definition = {
99
+ "type" => "array",
100
+ "items" => schema_for(schema.item)
101
+ }
102
+
103
+ apply_common_options(definition, schema)
104
+ end
105
+
106
+ def apply_common_options(definition, schema)
107
+ definition["description"] = schema.description if schema.description.present?
108
+ definition["default"] = schema.default unless schema.default.respond_to?(:call) || schema.default.nil?
109
+ definition["enum"] = schema.enum if schema.enum.present?
110
+ definition["pattern"] = regex_source(schema.pattern) if schema.pattern.present?
111
+ apply_length(definition, schema.length, definition["type"])
112
+ definition["example"] = schema.example if schema.example.present?
113
+ definition["examples"] = schema.examples if schema.examples.present?
114
+ apply_range(definition, schema.range)
115
+ definition
116
+ end
117
+
118
+ def apply_range(definition, range)
119
+ return if range.blank?
120
+
121
+ rules = range.symbolize_keys
122
+ definition["minimum"] = rules[:ge] if rules.key?(:ge)
123
+ definition["exclusiveMinimum"] = rules[:gt] if rules.key?(:gt)
124
+ definition["maximum"] = rules[:le] if rules.key?(:le)
125
+ definition["exclusiveMaximum"] = rules[:lt] if rules.key?(:lt)
126
+ end
127
+
128
+ def apply_length(definition, length, type)
129
+ return if length.blank?
130
+
131
+ rules = length.symbolize_keys
132
+ return unless type == "string"
133
+
134
+ definition["minLength"] = rules[:minimum] if rules.key?(:minimum)
135
+ definition["maxLength"] = rules[:maximum] if rules.key?(:maximum)
136
+ end
137
+
138
+ def scalar_type(type)
139
+ case ActionSpec::Schema::TypeCaster.normalize(type)
140
+ when :string then "string"
141
+ when :integer then "integer"
142
+ when :float, :decimal then "number"
143
+ when :boolean then "boolean"
144
+ when :date, :datetime, :time then "string"
145
+ when :file then "file"
146
+ when :object then "object"
147
+ else "string"
148
+ end
149
+ end
150
+
151
+ def string_format(type)
152
+ case ActionSpec::Schema::TypeCaster.normalize(type)
153
+ when :date then "date"
154
+ when :datetime then "date-time"
155
+ when :time then "time"
156
+ end
157
+ end
158
+
159
+ def number_format(type)
160
+ case ActionSpec::Schema::TypeCaster.normalize(type)
161
+ when :float then "float"
162
+ when :decimal then "double"
163
+ end
164
+ end
165
+
166
+ def regex_source(pattern)
167
+ pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+ require "action_spec/open_api/document"
6
+ require "action_spec/open_api/operation"
7
+ require "action_spec/open_api/schema"
8
+ require "action_spec/open_api/generator"
@@ -10,5 +10,11 @@ module ActionSpec
10
10
  include ActionSpec::Validator
11
11
  end
12
12
  end
13
+
14
+ initializer "action_spec.active_record" do
15
+ ActiveSupport.on_load(:active_record) do
16
+ include ActionSpec::Schema::ActiveRecord
17
+ end
18
+ end
13
19
  end
14
20
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionSpec
4
+ module Schema
5
+ module ActiveRecord
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def schemas(only: nil)
10
+ names = selected_column_names(only)
11
+ @action_spec_validator_index = build_validator_index
12
+
13
+ names.each_with_object(ActiveSupport::OrderedHash.new) do |name, hash|
14
+ hash[output_name(name)] = schema_definition_for(name)
15
+ end
16
+ ensure
17
+ remove_instance_variable(:@action_spec_validator_index) if instance_variable_defined?(:@action_spec_validator_index)
18
+ end
19
+
20
+ private
21
+
22
+ def selected_column_names(only)
23
+ selected = Array(only).presence&.map { |name| normalize_name(name) } || column_names
24
+
25
+ column_names.select { |name| selected.include?(name) }
26
+ end
27
+
28
+ def output_name(name)
29
+ required_attribute?(name) ? "#{name}!" : name
30
+ end
31
+
32
+ def schema_definition_for(name)
33
+ definition = { type: schema_type_for(name) }
34
+ definition[:default] = column_default_for(name) unless column_default_for(name).nil?
35
+ definition[:desc] = column_comment_for(name) if column_comment_for(name).present?
36
+ definition[:enum] = resolved_enum_for(name) if resolved_enum_for(name).present?
37
+ definition[:pattern] = pattern_for(name) if pattern_for(name)
38
+ definition[:range] = range_for(name) if range_for(name).present?
39
+ definition[:length] = length_for(name) if length_for(name).present?
40
+ definition
41
+ end
42
+
43
+ def schema_type_for(name)
44
+ return String if enum_values_for(name).present?
45
+
46
+ case columns_hash.fetch(name).type
47
+ when :string, :text, :binary then String
48
+ when :integer, :bigint then Integer
49
+ when :float then Float
50
+ when :decimal then BigDecimal
51
+ when :boolean then :boolean
52
+ when :date then Date
53
+ when :datetime, :timestamp then DateTime
54
+ when :time then Time
55
+ when :json, :jsonb then Object
56
+ else String
57
+ end
58
+ end
59
+
60
+ def required_attribute?(name)
61
+ (!column_nullable?(name) && column_default_for(name).nil?) || presence_validated?(name)
62
+ end
63
+
64
+ def column_nullable?(name)
65
+ columns_hash.fetch(name).null
66
+ end
67
+
68
+ def column_default_for(name)
69
+ columns_hash.fetch(name).default
70
+ end
71
+
72
+ def column_comment_for(name)
73
+ columns_hash.fetch(name).comment
74
+ end
75
+
76
+ def enum_values_for(name)
77
+ defined_enums.fetch(name.to_s, nil)&.keys
78
+ end
79
+
80
+ def inclusion_values_for(name)
81
+ validator_for(name, ActiveModel::Validations::InclusionValidator)&.options&.fetch(:in, nil)&.to_a
82
+ end
83
+
84
+ def resolved_enum_for(name)
85
+ enum_values_for(name).presence || inclusion_values_for(name).presence
86
+ end
87
+
88
+ def pattern_for(name)
89
+ validator_for(name, ActiveModel::Validations::FormatValidator)&.options&.fetch(:with, nil)
90
+ end
91
+
92
+ def range_for(name)
93
+ options = validator_for(name, ActiveModel::Validations::NumericalityValidator)&.options
94
+ return if options.blank?
95
+
96
+ {}.tap do |range|
97
+ range[:gt] = options[:greater_than] if options.key?(:greater_than)
98
+ range[:ge] = options[:greater_than_or_equal_to] if options.key?(:greater_than_or_equal_to)
99
+ range[:lt] = options[:less_than] if options.key?(:less_than)
100
+ range[:le] = options[:less_than_or_equal_to] if options.key?(:less_than_or_equal_to)
101
+ end.presence
102
+ end
103
+
104
+ def length_for(name)
105
+ definition = {}.tap do |length|
106
+ limit = string_limit_for(name)
107
+ length[:maximum] = limit if limit
108
+
109
+ options = validator_for(name, ActiveModel::Validations::LengthValidator)&.options || {}
110
+ length[:minimum] = options[:minimum] if options.key?(:minimum)
111
+ length[:maximum] = options[:maximum] if options.key?(:maximum)
112
+
113
+ if options.key?(:is)
114
+ length[:minimum] = options[:is]
115
+ length[:maximum] = options[:is]
116
+ end
117
+ end
118
+
119
+ definition.presence
120
+ end
121
+
122
+ def string_limit_for(name)
123
+ column = columns_hash.fetch(name)
124
+ return unless column.type.in?([:string, :text])
125
+
126
+ column.limit
127
+ end
128
+
129
+ def presence_validated?(name)
130
+ validator_for(name, ActiveModel::Validations::PresenceValidator).present?
131
+ end
132
+
133
+ def validator_for(name, klass)
134
+ validator_index.fetch(name.to_s, []).find { |validator| validator.is_a?(klass) }
135
+ end
136
+
137
+ def normalize_name(name)
138
+ name.to_s.delete_suffix("!")
139
+ end
140
+
141
+ def build_validator_index
142
+ validators.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |validator, index|
143
+ validator.attributes.each do |attribute|
144
+ index[attribute.to_s] << validator
145
+ end
146
+ end
147
+ end
148
+
149
+ def validator_index
150
+ @action_spec_validator_index ||= build_validator_index
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -24,7 +24,7 @@ module ActionSpec
24
24
  end
25
25
 
26
26
  def copy
27
- self.class.new(item.copy, default:, enum:, range:, pattern:, allow_nil:, allow_blank:)
27
+ self.class.new(item.copy, default:, enum:, range:, pattern:, length:, allow_nil:, allow_blank:, desc: description, example:, examples:)
28
28
  end
29
29
  end
30
30
  end
@@ -3,7 +3,7 @@
3
3
  module ActionSpec
4
4
  module Schema
5
5
  class Base
6
- attr_reader :default, :enum, :range, :pattern, :allow_nil, :allow_blank
6
+ attr_reader :default, :enum, :range, :pattern, :length, :allow_nil, :allow_blank, :description, :example, :examples
7
7
 
8
8
  def initialize(options = {})
9
9
  options = options.symbolize_keys
@@ -11,8 +11,12 @@ module ActionSpec
11
11
  @enum = options[:enum]
12
12
  @range = options[:range]
13
13
  @pattern = options[:pattern]
14
+ @length = options[:length]
14
15
  @allow_nil = options[:allow_nil]
15
16
  @allow_blank = options[:allow_blank]
17
+ @description = options[:desc] || options[:description]
18
+ @example = options[:example]
19
+ @examples = options[:examples]
16
20
  end
17
21
 
18
22
  def materialize_missing(_context:, _coerce:, _result:, _path:)
@@ -34,7 +34,7 @@ module ActionSpec
34
34
  end
35
35
 
36
36
  def copy
37
- self.class.new(fields.transform_values(&:copy), default:, enum:, range:, pattern:, allow_nil:, allow_blank:)
37
+ self.class.new(fields.transform_values(&:copy), default:, enum:, range:, pattern:, length:, allow_nil:, allow_blank:, desc: description, example:, examples:)
38
38
  end
39
39
 
40
40
  private
@@ -23,7 +23,7 @@ module ActionSpec
23
23
  end
24
24
 
25
25
  def copy
26
- self.class.new(type, default:, enum:, range:, pattern:, allow_nil:, allow_blank:)
26
+ self.class.new(type, default:, enum:, range:, pattern:, length:, allow_nil:, allow_blank:, desc: description, example:, examples:)
27
27
  end
28
28
  end
29
29
  end
@@ -5,13 +5,14 @@ require "action_spec/schema/field"
5
5
  require "action_spec/schema/scalar"
6
6
  require "action_spec/schema/object_of"
7
7
  require "action_spec/schema/array_of"
8
+ require "action_spec/schema/active_record"
8
9
  require "action_spec/schema/resolver"
9
10
  require "action_spec/schema/type_caster"
10
11
 
11
12
  module ActionSpec
12
13
  module Schema
13
14
  Missing = Object.new.freeze
14
- OPTION_KEYS = %i[default desc enum range pattern allow_nil allow_blank example examples].freeze
15
+ OPTION_KEYS = %i[default desc enum range pattern length allow_nil allow_blank example examples].freeze
15
16
 
16
17
  class << self
17
18
  def build(type = nil, **options)
@@ -1,3 +1,3 @@
1
1
  module ActionSpec
2
- VERSION = "0.1.0"
2
+ VERSION = "1.0.0"
3
3
  end
data/lib/action_spec.rb CHANGED
@@ -8,6 +8,7 @@ require "action_spec/validation_result"
8
8
  require "action_spec/invalid_parameters"
9
9
  require "action_spec/doc"
10
10
  require "action_spec/schema"
11
+ require "action_spec/open_api"
11
12
  require "action_spec/validator"
12
13
  require "action_spec/railtie"
13
14
 
@@ -1,4 +1,14 @@
1
- # desc "Explaining what the task does"
2
- # task :action_spec do
3
- # # Task goes here
4
- # end
1
+ namespace :action_spec do
2
+ desc "Generate an OpenAPI 3.2 document from ActionSpec controller docs"
3
+ task gen: :environment do
4
+ config = ActionSpec.config
5
+
6
+ ActionSpec::OpenApi::Generator.generate!(
7
+ application: Rails.application,
8
+ output: Rails.root.join(ENV.fetch("OUTPUT", config.open_api_output)).to_s,
9
+ title: ENV["TITLE"].presence || config.open_api_title,
10
+ version: ENV["VERSION"].presence || config.open_api_version,
11
+ server_url: ENV["SERVER_URL"].presence || config.open_api_server_url
12
+ )
13
+ end
14
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: action_spec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhandao
@@ -62,8 +62,14 @@ files:
62
62
  - lib/action_spec/doc/endpoint.rb
63
63
  - lib/action_spec/header_hash.rb
64
64
  - lib/action_spec/invalid_parameters.rb
65
+ - lib/action_spec/open_api.rb
66
+ - lib/action_spec/open_api/document.rb
67
+ - lib/action_spec/open_api/generator.rb
68
+ - lib/action_spec/open_api/operation.rb
69
+ - lib/action_spec/open_api/schema.rb
65
70
  - lib/action_spec/railtie.rb
66
71
  - lib/action_spec/schema.rb
72
+ - lib/action_spec/schema/active_record.rb
67
73
  - lib/action_spec/schema/array_of.rb
68
74
  - lib/action_spec/schema/base.rb
69
75
  - lib/action_spec/schema/field.rb