action_spec 0.1.0 → 0.2.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: bf40844c78d8e8281f129c65e12bf07d9fc8ebabc18bd47c717d779cbc90fa99
4
+ data.tar.gz: 1552ea6a7da3f13b02febfd4eea39b05b437db5efaa93bb99320662b9f96e98c
5
5
  SHA512:
6
- metadata.gz: 8fb29df856db1970afb0b1b24c11316501f5edf6e9962f244d31af0dd13d150eaf02c1082e84c931d2ac77798dc2239b063092b4cda0dbbdc6d6fd28d3240df4
7
- data.tar.gz: 2f0cc430e55895e929ca985723c0f8aa7b1be6549af05184f71f171b122afeda65ec47529c249aea93f7acf0d3ccede50405bcbc841af05ad00c62155a39d093
6
+ metadata.gz: 02e508bf9545f6848c351d96e7b95d4b7f22219322d365bba632f793722899922ee9ef347c147d4c98987cc9bf9b1ef18f70436ba105a7e695d0bb96f92acb47
7
+ data.tar.gz: cb9023dc36b73d1c155854200d726eb3fe3cb1dea03734da8fde6f64cf93cf58a2376eba90e77ccaff66c29ecbda220c6376037608a5d9fb2c0be702192c51f4
data/README.md CHANGED
@@ -8,19 +8,28 @@ Concise and Powerful API Documentation Solution for Rails.
8
8
  - Requires: Ruby 3.1+ and Rails 7.0+
9
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
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
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
+ - [Required Fields](#required-fields)
20
+ - [Supported Runtime Types](#supported-runtime-types)
21
+ - [Type And Boundary Matrix](#type-and-boundary-matrix)
22
+ - [Supported Runtime Options](#supported-runtime-options)
23
+ - [Parameter Validation And Type Coercion](#parameter-validation-and-type-coercion)
24
+ - [Validation Flow](#validation-flow)
25
+ - [Reading Validated Values With `px`](#reading-validated-values-with-px)
26
+ - [Errors](#errors)
27
+ - [Default Rescue Behavior](#default-rescue-behavior)
28
+ - [Configuration And I18n](#configuration-and-i18n)
29
+ - [Configuration](#configuration)
30
+ - [I18n](#i18n)
31
+
32
+ ## Example
24
33
 
25
34
  ```ruby
26
35
  class UsersController < ApplicationController
@@ -32,7 +41,7 @@ class UsersController < ApplicationController
32
41
  query :locale, String, default: "zh-CN"
33
42
  query :page, Integer, default: -> { 1 }
34
43
 
35
- json data: {
44
+ form data: {
36
45
  name!: String,
37
46
  age: Integer,
38
47
  birthday: Date,
@@ -69,25 +78,58 @@ Then run:
69
78
  $ bundle
70
79
  ```
71
80
 
72
- ## Usage
81
+ ## OpenAPI Generation
82
+
83
+ Generate an OpenAPI document from the current Rails routes and ActionSpec controller docs:
84
+
85
+ ```bash
86
+ bin/rails action_spec:gen
87
+ ```
88
+
89
+ By default, this writes to:
90
+
91
+ ```text
92
+ docs/openapi.yml
93
+ ```
94
+
95
+ For one-off runs, environment variables can override the default output path and document metadata:
96
+
97
+ ```bash
98
+ bin/rails action_spec:gen \
99
+ OUTPUT=docs/openapi.yml \
100
+ TITLE="My API" \
101
+ VERSION="2026.03" \
102
+ SERVER_URL="https://api.example.com"
103
+ ```
104
+
105
+ Notes:
106
+
107
+ - only routed controller actions with a matching `doc` declaration are included
108
+ - Rails paths such as `/users/:id(.:format)` are rendered as `/users/{id}`
109
+ - parameters, request bodies, and response descriptions are generated from the current DSL support
110
+ - if config and environment variables do not provide `TITLE` or `VERSION`, ActionSpec falls back to application-derived defaults
111
+
112
+ ## Doc DSL
73
113
 
74
- ### How To Bind `doc`
114
+ ### `doc`
75
115
 
76
- Default form, with action inferred from the next instance method:
116
+ With action inferred from the next instance method:
77
117
 
78
118
  ```ruby
79
119
  doc {
80
- json data: { name!: String }
120
+ form data: { # <= request body DSL
121
+ name!: String # <= schema DSL
122
+ }
81
123
  }
82
124
  def create
83
125
  end
84
126
  ```
85
127
 
86
- You can still provide a summary in the default form:
128
+ Provide a summary:
87
129
 
88
130
  ```ruby
89
131
  doc("Create user") {
90
- json data: { name!: String }
132
+ form data: { name!: String }
91
133
  }
92
134
  def create
93
135
  end
@@ -97,13 +139,13 @@ You can also bind it explicitly when you want the action name declared in place:
97
139
 
98
140
  ```ruby
99
141
  doc(:create, "Create user") {
100
- json data: { name!: String }
142
+ form data: { name!: String }
101
143
  }
102
144
  def create
103
145
  end
104
146
  ```
105
147
 
106
- ### Shared Declarations With `doc_dry`
148
+ ### `doc_dry`
107
149
 
108
150
  ```ruby
109
151
  class ApplicationController < ActionController::API
@@ -122,11 +164,7 @@ All matching dry blocks are applied before the action-specific `doc`.
122
164
 
123
165
  ### DSL Reference
124
166
 
125
- ActionSpec keeps the request DSL close to `zero-rails_openapi`.
126
-
127
- #### Parameter Locations
128
-
129
- Single-parameter forms:
167
+ #### Parameter
130
168
 
131
169
  ```ruby
132
170
  header :Authorization, String
@@ -171,7 +209,7 @@ in_query!(
171
209
  )
172
210
  ```
173
211
 
174
- #### Request Bodies
212
+ #### request body
175
213
 
176
214
  General form:
177
215
 
@@ -199,7 +237,7 @@ data :file, File
199
237
 
200
238
  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
239
 
202
- #### Response Metadata
240
+ #### Response
203
241
 
204
242
  ```ruby
205
243
  response 200, desc: "success"
@@ -210,7 +248,7 @@ error 401, "unauthorized"
210
248
 
211
249
  Response declarations are stored as metadata now. They are not yet used to render responses automatically.
212
250
 
213
- ### Schema Writing
251
+ ## Schemas
214
252
 
215
253
  #### Required Fields
216
254
 
@@ -241,8 +279,7 @@ Scalar types currently supported by validation/coercion:
241
279
  - `Integer`
242
280
  - `Float`
243
281
  - `BigDecimal`
244
- - `:boolean`
245
- - host-defined `Boolean` constant, if the host app already defines one
282
+ - `:boolean` / `Boolean`
246
283
  - `Date`
247
284
  - `DateTime`
248
285
  - `Time`
@@ -298,6 +335,8 @@ These options are currently accepted as metadata, mainly for future OpenAPI work
298
335
  - `allow_nil`
299
336
  - `allow_blank`
300
337
 
338
+ ## Parameter Validation And Type Coercion
339
+
301
340
  ### Validation Flow
302
341
 
303
342
  #### `validate_params!`
@@ -396,12 +435,18 @@ The default JSON response is:
396
435
  }
397
436
  ```
398
437
 
438
+ ## Configuration And I18n
439
+
399
440
  ### Configuration
400
441
 
401
442
  ```ruby
402
443
  ActionSpec.configure do |config|
403
444
  config.rescue_invalid_parameters = true
404
445
  config.invalid_parameters_status = :bad_request
446
+ config.open_api_output = "docs/openapi.yml"
447
+ config.open_api_title = "My API"
448
+ config.open_api_version = "2026.03"
449
+ config.open_api_server_url = "https://api.example.com"
405
450
 
406
451
  config.error_messages[:invalid_type] = ->(_attribute, options) do
407
452
  "should be coercible to #{options.fetch(:expected)}"
@@ -438,6 +483,22 @@ Available config keys:
438
483
  Default: `{}`.
439
484
  Lets you override error messages by error type, or by attribute plus error type.
440
485
 
486
+ - `open_api_output`
487
+ Default: `"docs/openapi.yml"`.
488
+ Controls where `bin/rails action_spec:gen` writes the generated OpenAPI document.
489
+
490
+ - `open_api_title`
491
+ Default: `nil`.
492
+ Sets the default OpenAPI `info.title` used by `bin/rails action_spec:gen`.
493
+
494
+ - `open_api_version`
495
+ Default: `nil`.
496
+ Sets the default OpenAPI `info.version` used by `bin/rails action_spec:gen`.
497
+
498
+ - `open_api_server_url`
499
+ Default: `nil`.
500
+ Sets the default server URL emitted in the generated OpenAPI document.
501
+
441
502
  ### I18n
442
503
 
443
504
  ActionSpec loads its own locale files and uses `ActiveModel::Errors`, so you can override both messages and attribute names:
@@ -466,7 +527,7 @@ ActionSpec.configure do |config|
466
527
  end
467
528
  ```
468
529
 
469
- ### What Is Not Implemented Yet
530
+ ## What Is Not Implemented Yet
470
531
 
471
532
  - OpenAPI document generation
472
533
  - automatic response rendering from `response`
@@ -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,119 @@
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
+ hash[path][route_verb(route)] = Operation.new(endpoint).build
57
+ end
58
+ end
59
+
60
+ def route_definitions
61
+ return routes if routes
62
+
63
+ application.routes.routes
64
+ end
65
+
66
+ def controller_for(route)
67
+ controller_name = route_defaults(route)[:controller].presence
68
+ return unless controller_name
69
+
70
+ "#{controller_name.camelize}Controller".safe_constantize
71
+ end
72
+
73
+ def route_action(route)
74
+ route_defaults(route).fetch(:action).to_sym
75
+ end
76
+
77
+ def route_defaults(route)
78
+ defaults =
79
+ if route.respond_to?(:defaults)
80
+ route.defaults
81
+ elsif route.respond_to?(:requirements)
82
+ route.requirements
83
+ else
84
+ route[:defaults]
85
+ end
86
+
87
+ defaults.to_h.symbolize_keys
88
+ end
89
+
90
+ def route_verb(route)
91
+ raw_verb =
92
+ if route.respond_to?(:verb) && route.verb.respond_to?(:source)
93
+ route.verb.source
94
+ elsif route.respond_to?(:verb)
95
+ route.verb.to_s
96
+ else
97
+ route[:verb].to_s
98
+ end
99
+
100
+ raw_verb.gsub(/[$^]/, "").split("|").find(&:present?).to_s.downcase
101
+ end
102
+
103
+ def normalized_path(route)
104
+ raw_path =
105
+ if route.respond_to?(:path) && route.path.respond_to?(:spec)
106
+ route.path.spec.to_s
107
+ elsif route.respond_to?(:path)
108
+ route.path.to_s
109
+ else
110
+ route[:path].to_s
111
+ end
112
+
113
+ raw_path
114
+ .sub(/\(\.:format\)\z/, "")
115
+ .gsub(/:(\w+)/, '{\1}')
116
+ end
117
+ end
118
+ end
119
+ 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,160 @@
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
+ definition["example"] = schema.example if schema.example.present?
112
+ definition["examples"] = schema.examples if schema.examples.present?
113
+ apply_range(definition, schema.range)
114
+ definition
115
+ end
116
+
117
+ def apply_range(definition, range)
118
+ return if range.blank?
119
+
120
+ rules = range.symbolize_keys
121
+ definition["minimum"] = rules[:ge] if rules.key?(:ge)
122
+ definition["exclusiveMinimum"] = rules[:gt] if rules.key?(:gt)
123
+ definition["maximum"] = rules[:le] if rules.key?(:le)
124
+ definition["exclusiveMaximum"] = rules[:lt] if rules.key?(:lt)
125
+ end
126
+
127
+ def scalar_type(type)
128
+ case ActionSpec::Schema::TypeCaster.normalize(type)
129
+ when :string then "string"
130
+ when :integer then "integer"
131
+ when :float, :decimal then "number"
132
+ when :boolean then "boolean"
133
+ when :date, :datetime, :time then "string"
134
+ when :file then "file"
135
+ when :object then "object"
136
+ else "string"
137
+ end
138
+ end
139
+
140
+ def string_format(type)
141
+ case ActionSpec::Schema::TypeCaster.normalize(type)
142
+ when :date then "date"
143
+ when :datetime then "date-time"
144
+ when :time then "time"
145
+ end
146
+ end
147
+
148
+ def number_format(type)
149
+ case ActionSpec::Schema::TypeCaster.normalize(type)
150
+ when :float then "float"
151
+ when :decimal then "double"
152
+ end
153
+ end
154
+
155
+ def regex_source(pattern)
156
+ pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
157
+ end
158
+ end
159
+ end
160
+ 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"
@@ -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:, 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, :allow_nil, :allow_blank, :description, :example, :examples
7
7
 
8
8
  def initialize(options = {})
9
9
  options = options.symbolize_keys
@@ -13,6 +13,9 @@ module ActionSpec
13
13
  @pattern = options[:pattern]
14
14
  @allow_nil = options[:allow_nil]
15
15
  @allow_blank = options[:allow_blank]
16
+ @description = options[:desc] || options[:description]
17
+ @example = options[:example]
18
+ @examples = options[:examples]
16
19
  end
17
20
 
18
21
  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:, 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:, allow_nil:, allow_blank:, desc: description, example:, examples:)
27
27
  end
28
28
  end
29
29
  end
@@ -1,3 +1,3 @@
1
1
  module ActionSpec
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.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: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhandao
@@ -62,6 +62,11 @@ 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
67
72
  - lib/action_spec/schema/array_of.rb