rails_ninja 0.3.0 → 0.4.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: 3c11362d2af26f7b911e310e2c67e603b5dfd63fd62e5758ce8936da06b1bb36
4
- data.tar.gz: fb2bd66b8af63055d31a8c1cfc5eb2023883bc46f244c6f77fed0e0668dad4a8
3
+ metadata.gz: 9ea5b2dadb986f6d8ccde0b94cef6e57a941cd5336a776182e3462afc15dcdd7
4
+ data.tar.gz: 344e6aa870b08bb554fc239cc0f5f583721c8fd73d2d0532a9206d01cbe47cf0
5
5
  SHA512:
6
- metadata.gz: e541dbca84d3e26df63229509abc3b5d70bae9da3ce6e8aa31da8c3a97a7df5d0f81ffd7e790820d96a019f0c16b984e2b329fc09fef5d0ea5b55c136c5b844c
7
- data.tar.gz: '0381a91dd66a8c50be828018978d88d48e95b4f607325e94f1e4fb152d6714c79a79b2e6ef98e6c224c2d6a0409b96a061d748890edf188574e269a8a610b7d8'
6
+ metadata.gz: 22c2dcadc5aba38d8847721739a8562b7266a54ef6ad286b6cd4e4d35508c5127c0731bac09331e9fd9b7e460c25e95e002ba4bd8f42470f4b978f34db06f2bb
7
+ data.tar.gz: 54889989cb0f46f6e0fb2e5745aa02786361a604cc2a5006d44737dbbfe4f677d12f4964d7106a2e748530a67478224e51567b627e6ca90f43ffd1c7e5f92f9c
data/README.md CHANGED
@@ -264,6 +264,73 @@ Deprecated paths remain routable and are marked as deprecated in OpenAPI.
264
264
  Set `server "https://api.example.com"` on an API to declare its server URL, or
265
265
  `docs false` to disable `/docs` and `/openapi.json`.
266
266
 
267
+ ## OpenAPI documentation in Ruby
268
+
269
+ Declare descriptions, examples and parameter constraints next to the API schemas.
270
+ The generator includes them in the JSON document without a separate overlay file.
271
+
272
+ ```ruby
273
+ class InventoryApi < RailsNinja::API
274
+ title "Inventory API"
275
+ description "Read inventory records."
276
+ openapi_tag "Records", description: "Inventory records and their availability."
277
+
278
+ schema :Record, description: "One inventory record", example: { name: "Widget" } do
279
+ field :name, RailsNinja::Types::String,
280
+ description: "Display name", example: "Widget", openapi: { minLength: 1 }
281
+ end
282
+
283
+ schema :ReadError do
284
+ field :error, RailsNinja::Types::String, description: "Reason the read failed"
285
+ end
286
+
287
+ get "/records/:id",
288
+ operation_id: "getRecord",
289
+ summary: "Read an inventory record",
290
+ description: "Returns the record identified by the supplied ID.",
291
+ tags: ["Records"],
292
+ path_params: { id: { description: "Record ID", example: "123", schema: { pattern: "^[0-9]+$" } } },
293
+ responses: {
294
+ 200 => Record,
295
+ 429 => {
296
+ schema: ReadError,
297
+ description: "Wait before retrying the request.",
298
+ example: { error: "Too many requests" },
299
+ headers: { "Retry-After" => { description: "Seconds to wait", schema: { type: "integer" }, example: 1 } }
300
+ }
301
+ }
302
+ def read
303
+ # Fetch the record or render an error response.
304
+ end
305
+ end
306
+ ```
307
+
308
+ Schema classes can also declare `openapi description: ..., example: ...` inside
309
+ their class body. Field `openapi:` accepts `format`, `pattern`, `minLength`,
310
+ `maxLength`, `minimum`, `maximum`, `default`, `enum` and `oneOf`. These options
311
+ describe constraints enforced by the application; they do not add runtime
312
+ validation or change serialization. Field types and requiredness still come from
313
+ the existing `field` declaration.
314
+
315
+ GET, DELETE and HEAD request schemas supply query parameters. Their field
316
+ descriptions and examples appear on the parameter, and their constraints appear
317
+ in its schema. Header declarations accept `description:`, `example:` and
318
+ `schema:` alongside `name:` and `required:`. Path annotations refer to the full
319
+ mounted route, including parameters from a mount prefix. Path parameters remain
320
+ required and retain their route names.
321
+
322
+ For a request body, use `request_description:` on the endpoint. For each response,
323
+ either keep the existing schema shorthand or supply a hash containing `schema:`
324
+ and optional `description:`, `headers:` and `example:` or `examples:`. The two
325
+ example forms are mutually exclusive. Named examples use OpenAPI Example Objects,
326
+ for example `examples: { missing: { value: { error: "Not found" } } }`.
327
+ Schemas can still be arrays or `one_of(...)` declarations.
328
+
329
+ An explicit `operation_id:` keeps generated client method names stable when Ruby
330
+ handler names change. Authors must choose unique IDs within each API document;
331
+ deprecated route aliases omit the ID. API security descriptions belong in the
332
+ existing `openapi_security_scheme` declaration.
333
+
267
334
  ## Static OpenAPI files
268
335
 
269
336
  Generate an OpenAPI 3.2 JSON file for every API:
@@ -23,6 +23,20 @@ module RailsNinja
23
23
  @_title
24
24
  end
25
25
 
26
+ def description(value = nil)
27
+ @_description = value unless value.nil?
28
+ @_description
29
+ end
30
+
31
+ def openapi_tag(name, description:)
32
+ _openapi_tags.reject! { |tag| tag[:name] == name.to_s }
33
+ _openapi_tags << { name: name.to_s, description: description }
34
+ end
35
+
36
+ def _openapi_tags
37
+ @_openapi_tags ||= []
38
+ end
39
+
26
40
  def version(value = nil)
27
41
  if value
28
42
  @_version = value
@@ -85,6 +99,8 @@ module RailsNinja
85
99
  super
86
100
  subclass.instance_variable_set(:@_openapi_security_schemes, _openapi_security_schemes.transform_values(&:dup))
87
101
  subclass.instance_variable_set(:@_openapi_security, _openapi_security.dup)
102
+ subclass.instance_variable_set(:@_openapi_tags, _openapi_tags.deep_dup)
103
+ subclass.instance_variable_set(:@_description, description&.dup)
88
104
  RailsNinja.registered_apis << subclass
89
105
  end
90
106
 
@@ -22,8 +22,9 @@ module RailsNinja
22
22
  end
23
23
 
24
24
  # Schema definition DSL
25
- def schema(name, &block)
25
+ def schema(name, **documentation, &block)
26
26
  klass = Class.new(Schema::Base)
27
+ klass.openapi(**documentation)
27
28
  klass.class_eval(&block)
28
29
 
29
30
  # Register as a constant on this class so it's accessible by name
@@ -33,10 +33,12 @@ module RailsNinja
33
33
  components: components,
34
34
  }
35
35
  spec[:servers] = [{ url: @api_class._server }] if @api_class._server
36
+ spec[:info][:description] = @api_class.description if @api_class.description
37
+ spec[:tags] = @api_class._openapi_tags if @api_class._openapi_tags.any?
36
38
  if @api_class._openapi_security.any?
37
39
  spec[:security] = @api_class._openapi_security.map { |name| { name => [] } }
38
40
  end
39
- spec
41
+ spec.deep_dup
40
42
  end
41
43
 
42
44
  def to_json(*_args)
@@ -79,18 +81,22 @@ module RailsNinja
79
81
  body_verb = %w[get delete head].exclude?(verb)
80
82
 
81
83
  operation = { summary: endpoint.summary }
84
+ operation[:description] = endpoint.description if endpoint.description
82
85
  operation[:tags] = endpoint.tags if endpoint.tags&.any?
83
86
  operation[:responses] = build_responses(endpoint)
84
87
  # Deprecated aliases share a handler with the primary endpoint; only the
85
88
  # current path carries the operationId to keep it unique across the spec.
86
- operation[:operationId] = build_operation_id(endpoint) unless endpoint.deprecated?
89
+ operation[:operationId] = endpoint.operation_id || build_operation_id(endpoint) unless endpoint.deprecated?
87
90
  operation[:deprecated] = true if endpoint.deprecated?
88
91
 
89
92
  if endpoint.request_schema && body_verb
90
93
  operation[:requestBody] = build_request_body(endpoint.request_schema)
94
+ if endpoint.request_description
95
+ operation[:requestBody][:description] = endpoint.request_description
96
+ end
91
97
  end
92
98
 
93
- params = extract_path_params(path) + extract_header_params(endpoint)
99
+ params = extract_path_params(path, endpoint.path_params) + extract_header_params(endpoint)
94
100
  params += extract_query_params(endpoint) unless body_verb
95
101
  operation[:parameters] = params if params.any?
96
102
 
@@ -113,6 +119,9 @@ module RailsNinja
113
119
  description: response_description_for(status),
114
120
  content: { "application/json" => { schema: schema_node } },
115
121
  }
122
+ documentation = endpoint.response_documentation.fetch(status, {})
123
+ out[status.to_s].merge!(documentation.slice(:description, :headers))
124
+ out[status.to_s][:content]["application/json"].merge!(documentation.slice(:example, :examples))
116
125
  end
117
126
  end
118
127
 
@@ -188,9 +197,16 @@ module RailsNinja
188
197
  end
189
198
  end
190
199
 
191
- def extract_path_params(path)
192
- path.scan(/:(\w+)/).flatten.map do |param|
193
- { name: param, in: "path", required: true, schema: { type: "string" } }
200
+ def extract_path_params(path, documentation)
201
+ names = path.scan(/:(\w+)/).flatten
202
+ unknown = documentation.keys - names
203
+ raise ArgumentError, "Unknown path parameter annotations: #{unknown.join(', ')}" if unknown.any?
204
+
205
+ names.map do |param|
206
+ metadata = documentation.fetch(param, {})
207
+ { name: param, in: "path", required: true,
208
+ schema: { type: "string" }.merge(metadata.fetch(:schema, {})) }
209
+ .merge(metadata.slice(:description, :example))
194
210
  end
195
211
  end
196
212
 
@@ -198,7 +214,9 @@ module RailsNinja
198
214
  return [] unless endpoint.request_schema
199
215
 
200
216
  endpoint.request_schema._fields.map do |name, field|
201
- { name: name.to_s, in: "query", required: field.required, schema: schema_ref(field.type) }
217
+ { name: name.to_s, in: "query", required: field.required,
218
+ schema: schema_ref(field.type).merge(field.documentation.except(:description, :example)) }
219
+ .merge(field.documentation.slice(:description, :example))
202
220
  end
203
221
  end
204
222
 
@@ -207,6 +225,7 @@ module RailsNinja
207
225
 
208
226
  endpoint.header_params.map do |h|
209
227
  { name: h[:name], in: "header", required: h[:required], schema: h[:schema] }
228
+ .merge(h.slice(:description, :example))
210
229
  end
211
230
  end
212
231
 
@@ -261,16 +280,17 @@ module RailsNinja
261
280
  required = []
262
281
 
263
282
  schema_class._fields.each do |name, field|
264
- property = schema_ref(field.type)
283
+ property = schema_ref(field.type).deep_dup
265
284
  property[:title] = schema_title(name)
266
285
  property[:enum] = field.enum if field.enum
286
+ property.merge!(field.documentation)
267
287
  properties[name.to_s] = property
268
288
  required << name.to_s if field.required
269
289
  end
270
290
 
271
291
  result = { type: "object", title: schema_name(schema_class), properties: properties }
272
292
  result[:required] = required if required.any?
273
- result
293
+ result.merge(schema_class.openapi)
274
294
  end
275
295
 
276
296
  def schema_title(name)
@@ -24,13 +24,13 @@ module RailsNinja
24
24
  required = []
25
25
 
26
26
  schema_class._fields.each do |name, field|
27
- properties[name.to_s] = to_json_schema(field.type)
27
+ properties[name.to_s] = to_json_schema(field.type).merge(field.documentation)
28
28
  required << name.to_s if field.required
29
29
  end
30
30
 
31
31
  result = { type: "object", properties: properties }
32
32
  result[:required] = required if required.any?
33
- result
33
+ result.merge(schema_class.openapi).deep_dup
34
34
  end
35
35
 
36
36
  def primitive_type(type)
@@ -3,21 +3,44 @@
3
3
  module RailsNinja
4
4
  class Operation
5
5
  attr_reader :verb, :path, :handler, :display_handler, :api_class,
6
- :request_schema, :responses_map, :tags, :summary, :header_params
6
+ :request_schema, :responses_map, :tags, :summary, :header_params,
7
+ :description, :operation_id, :path_params, :request_description, :response_documentation
7
8
 
8
9
  def initialize(verb:, path:, handler:, api_class:, request: nil, response: nil, responses: nil,
9
- tags: nil, summary: nil, headers: nil, deprecated: false)
10
+ tags: nil, summary: nil, headers: nil, deprecated: false, description: nil,
11
+ operation_id: nil, path_params: {}, request_description: nil)
10
12
  @verb = verb
11
13
  @path = path
12
14
  @handler = handler
13
15
  @display_handler = handler
14
16
  @api_class = api_class
15
17
  @request_schema = request
16
- @responses_map = responses || (response ? { 200 => response } : {})
18
+ @response_documentation = {}
19
+ @responses_map = (responses || (response ? { 200 => response } : {})).to_h do |status, declaration|
20
+ if declaration.is_a?(Hash)
21
+ unknown = declaration.keys - %i[schema description example examples headers]
22
+ raise ArgumentError, "Unsupported response #{status} keys: #{unknown.join(', ')}" if unknown.any?
23
+ if declaration.key?(:example) && declaration.key?(:examples)
24
+ raise ArgumentError, "Response #{status} cannot declare both example and examples"
25
+ end
26
+ @response_documentation[status] = declaration.except(:schema).deep_dup
27
+ [status, declaration.fetch(:schema)]
28
+ else
29
+ [status, declaration]
30
+ end
31
+ end
17
32
  @tags = tags || api_class._tags || []
18
33
  @summary = summary || handler.to_s.tr("_", " ").capitalize
19
34
  @header_params = merge_headers(api_class._headers, headers)
20
35
  @deprecated = deprecated
36
+ @description = description
37
+ @operation_id = operation_id
38
+ @path_params = path_params.transform_keys(&:to_s).deep_dup
39
+ @path_params.each_value do |metadata|
40
+ unknown = metadata.keys - %i[description example schema]
41
+ raise ArgumentError, "Unsupported path parameter keys: #{unknown.join(', ')}" if unknown.any?
42
+ end
43
+ @request_description = request_description
21
44
  end
22
45
 
23
46
  def deprecated?
@@ -64,7 +87,9 @@ module RailsNinja
64
87
  if h.is_a?(String)
65
88
  { name: h, required: true, schema: { type: "string" } }
66
89
  elsif h.is_a?(Hash)
67
- { name: h[:name], required: h.fetch(:required, true), schema: { type: h.fetch(:type, "string") } }
90
+ { name: h[:name], required: h.fetch(:required, true),
91
+ schema: h.fetch(:schema, { type: h.fetch(:type, "string") }) }
92
+ .merge(h.slice(:description, :example)).deep_dup
68
93
  end
69
94
  end
70
95
  end
@@ -2,9 +2,16 @@
2
2
 
3
3
  module RailsNinja
4
4
  module Schema
5
- Field = Struct.new(:name, :type, :required, :default, :enum, keyword_init: true) do
6
- def initialize(name:, type:, required: true, default: nil, enum: nil)
7
- super
5
+ Field = Struct.new(:name, :type, :required, :default, :enum, :documentation, keyword_init: true) do
6
+ def initialize(name:, type:, required: true, default: nil, enum: nil, openapi: {}, **documentation)
7
+ unknown = openapi.keys - %i[format pattern minLength maxLength minimum maximum default enum oneOf]
8
+ raise ArgumentError, "Unsupported field OpenAPI keys: #{unknown.join(', ')}" if unknown.any?
9
+
10
+ unknown = documentation.keys - %i[description example]
11
+ raise ArgumentError, "Unsupported field documentation keys: #{unknown.join(', ')}" if unknown.any?
12
+
13
+ super(name: name, type: type, required: required, default: default, enum: enum,
14
+ documentation: openapi.merge(documentation).deep_dup)
8
15
  end
9
16
  end
10
17
  end
@@ -4,14 +4,15 @@ module RailsNinja
4
4
  module Schema
5
5
  class Base
6
6
  class << self
7
- def field(name, type, required: true, default: nil, enum: nil)
7
+ def field(name, type, required: true, default: nil, enum: nil, **documentation)
8
8
  validate_type!(type)
9
9
  _fields[name.to_sym] = Field.new(
10
10
  name: name.to_sym,
11
11
  type: type,
12
12
  required: required,
13
13
  default: default,
14
- enum: enum
14
+ enum: enum,
15
+ **documentation
15
16
  )
16
17
  end
17
18
 
@@ -23,9 +24,18 @@ module RailsNinja
23
24
  @_fields ||= {}
24
25
  end
25
26
 
27
+ def openapi(**documentation)
28
+ unknown = documentation.keys - %i[description example]
29
+ raise ArgumentError, "Unsupported schema documentation keys: #{unknown.join(', ')}" if unknown.any?
30
+
31
+ @_openapi ||= {}
32
+ @_openapi.merge!(documentation.deep_dup)
33
+ end
34
+
26
35
  def inherited(subclass)
27
36
  super
28
37
  subclass.instance_variable_set(:@_fields, _fields.dup)
38
+ subclass.instance_variable_set(:@_openapi, openapi.deep_dup)
29
39
  end
30
40
 
31
41
  def validate(data)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsNinja
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/rails_ninja.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "action_controller/metal"
4
4
  require "active_support/core_ext/array/wrap"
5
5
  require "active_support/core_ext/enumerable"
6
+ require "active_support/core_ext/object/deep_dup"
6
7
  require "rack"
7
8
  require "multi_json"
8
9
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_ninja
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Urrutia
@@ -167,7 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
167
167
  - !ruby/object:Gem::Version
168
168
  version: '0'
169
169
  requirements: []
170
- rubygems_version: 4.0.16
170
+ rubygems_version: 4.0.20
171
171
  specification_version: 4
172
172
  summary: Django Ninja-inspired API framework for Ruby/Rails
173
173
  test_files: []