rails_ninja 0.2.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 +4 -4
- data/README.md +89 -4
- data/lib/rails_ninja/api.rb +63 -1
- data/lib/rails_ninja/endpoint_group.rb +2 -1
- data/lib/rails_ninja/openapi/generator.rb +55 -17
- data/lib/rails_ninja/openapi/schema_ref.rb +2 -2
- data/lib/rails_ninja/operation.rb +29 -4
- data/lib/rails_ninja/schema/field.rb +10 -3
- data/lib/rails_ninja/schema/parameter_decoder.rb +16 -2
- data/lib/rails_ninja/schema/validator.rb +4 -0
- data/lib/rails_ninja/schema.rb +12 -2
- data/lib/rails_ninja/types/file.rb +17 -0
- data/lib/rails_ninja/version.rb +1 -1
- data/lib/rails_ninja.rb +3 -0
- data/rails_ninja.gemspec +3 -3
- metadata +9 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ea5b2dadb986f6d8ccde0b94cef6e57a941cd5336a776182e3462afc15dcdd7
|
|
4
|
+
data.tar.gz: 344e6aa870b08bb554fc239cc0f5f583721c8fd73d2d0532a9206d01cbe47cf0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 22c2dcadc5aba38d8847721739a8562b7266a54ef6ad286b6cd4e4d35508c5127c0731bac09331e9fd9b7e460c25e95e002ba4bd8f42470f4b978f34db06f2bb
|
|
7
|
+
data.tar.gz: 54889989cb0f46f6e0fb2e5745aa02786361a604cc2a5006d44737dbbfe4f677d12f4964d7106a2e748530a67478224e51567b627e6ca90f43ffd1c7e5f92f9c
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Rails Ninja is a small Rails API framework inspired by
|
|
|
5
5
|
request validation and response serialization, and generated OpenAPI
|
|
6
6
|
documentation.
|
|
7
7
|
|
|
8
|
-
Rails Ninja requires Ruby 3 or newer and Rails 7 or newer.
|
|
8
|
+
Rails Ninja requires Ruby 3 or newer and Rails 7.1 or newer.
|
|
9
9
|
|
|
10
10
|
## Installation
|
|
11
11
|
|
|
@@ -103,8 +103,8 @@ end
|
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
Fields are required by default. Available scalar types are `String`, `Int`,
|
|
106
|
-
`Float`, and `
|
|
107
|
-
nested schema or a one-element array of a scalar or schema.
|
|
106
|
+
`Float`, `Boolean`, and `File` under `RailsNinja::Types`. A field may also
|
|
107
|
+
contain a nested schema or a one-element array of a scalar or schema.
|
|
108
108
|
|
|
109
109
|
JSON input is strictly type-checked. Canonical path, query, and form values are
|
|
110
110
|
decoded first, so an integer query value such as `"20"` becomes `20`. Invalid
|
|
@@ -114,6 +114,24 @@ into `params` as symbol keys.
|
|
|
114
114
|
For `GET` and `DELETE`, a request schema is read from and documented as query
|
|
115
115
|
parameters. `POST`, `PUT`, and `PATCH` use a request body.
|
|
116
116
|
|
|
117
|
+
### File uploads
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
schema :DocumentIn do
|
|
121
|
+
field :title, RailsNinja::Types::String
|
|
122
|
+
field :attachments, [RailsNinja::Types::File]
|
|
123
|
+
field :metadata, DocumentMetadata, required: false
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A request schema with a `File` field is documented as `multipart/form-data`
|
|
128
|
+
and its values arrive as `ActionDispatch::Http::UploadedFile`. Arrays of files
|
|
129
|
+
accept both `attachments[]` and repeated bare `attachments` parts, which is what
|
|
130
|
+
OpenAPI generated clients send. Nested schema fields may be sent as JSON
|
|
131
|
+
strings in their own part; they are validated with JSON types, not form
|
|
132
|
+
coercion. `File` fields are only supported at the top level of a request
|
|
133
|
+
schema and cannot appear in responses.
|
|
134
|
+
|
|
117
135
|
Schemas may also be standalone:
|
|
118
136
|
|
|
119
137
|
```ruby
|
|
@@ -246,6 +264,73 @@ Deprecated paths remain routable and are marked as deprecated in OpenAPI.
|
|
|
246
264
|
Set `server "https://api.example.com"` on an API to declare its server URL, or
|
|
247
265
|
`docs false` to disable `/docs` and `/openapi.json`.
|
|
248
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
|
+
|
|
249
334
|
## Static OpenAPI files
|
|
250
335
|
|
|
251
336
|
Generate an OpenAPI 3.2 JSON file for every API:
|
|
@@ -270,7 +355,7 @@ bundle install
|
|
|
270
355
|
bundle exec rake test
|
|
271
356
|
```
|
|
272
357
|
|
|
273
|
-
CI tests every compatible combination of Action Pack and Active Support 7.
|
|
358
|
+
CI tests every compatible combination of Action Pack and Active Support 7.1
|
|
274
359
|
through 8.1 with MRI Ruby 3.0 through 4.0. Each lane resolves the latest patch
|
|
275
360
|
release in its minor series.
|
|
276
361
|
|
data/lib/rails_ninja/api.rb
CHANGED
|
@@ -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
|
|
|
@@ -336,6 +352,8 @@ module RailsNinja
|
|
|
336
352
|
|
|
337
353
|
if errors.empty?
|
|
338
354
|
params.merge!(validated)
|
|
355
|
+
# Mounted groups / included endpoints run on their own instance with its own params
|
|
356
|
+
@_ninja_handler_instance&.params&.merge!(validated)
|
|
339
357
|
return
|
|
340
358
|
end
|
|
341
359
|
|
|
@@ -347,7 +365,9 @@ module RailsNinja
|
|
|
347
365
|
def request_input(schema_class)
|
|
348
366
|
path = decode_parameters(schema_class, request.path_parameters)
|
|
349
367
|
query = decode_parameters(schema_class, request.query_parameters)
|
|
350
|
-
body = if
|
|
368
|
+
body = if request.media_type == "multipart/form-data"
|
|
369
|
+
Schema::ParameterDecoder.new(schema_class, multipart_parameters(schema_class), wrap_arrays: true).call
|
|
370
|
+
elsif FORM_MEDIA_TYPES.include?(request.media_type)
|
|
351
371
|
decode_parameters(schema_class, request.request_parameters)
|
|
352
372
|
else
|
|
353
373
|
request.request_parameters.deep_symbolize_keys
|
|
@@ -356,6 +376,48 @@ module RailsNinja
|
|
|
356
376
|
path.merge(query).merge(body)
|
|
357
377
|
end
|
|
358
378
|
|
|
379
|
+
# Rack keeps only the last part when a multipart name repeats without "[]",
|
|
380
|
+
# but OpenAPI clients send arrays exactly that way ("files", "files").
|
|
381
|
+
# Re-parse the body so repeated bare names accumulate into arrays.
|
|
382
|
+
module RepeatedMultipartNames
|
|
383
|
+
def normalize_params(params, name, value, *rest)
|
|
384
|
+
return super unless params.key?(name) && !name.include?("[")
|
|
385
|
+
|
|
386
|
+
existing = params[name]
|
|
387
|
+
params[name] = existing.is_a?(Array) ? existing << value : [existing, value]
|
|
388
|
+
params
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def multipart_parameters(schema_class)
|
|
393
|
+
rails_params = request.request_parameters
|
|
394
|
+
io = request.env["rack.input"]
|
|
395
|
+
# Rails' parse is already complete unless a list field could have lost repeated parts
|
|
396
|
+
list_fields = schema_class._fields.values.any? { |f| f.type.is_a?(Array) }
|
|
397
|
+
return rails_params if io.nil? || rails_params.empty? || !list_fields
|
|
398
|
+
|
|
399
|
+
parser = Rack::Utils.default_query_parser.dup.extend(RepeatedMultipartNames)
|
|
400
|
+
|
|
401
|
+
raw = if (pairs = request.env["rack.request.form_pairs"])
|
|
402
|
+
params = parser.make_params
|
|
403
|
+
pairs.each { |name, value| parser.normalize_params(params, name, value) }
|
|
404
|
+
params.to_params_hash
|
|
405
|
+
elsif io.respond_to?(:rewind)
|
|
406
|
+
io.rewind
|
|
407
|
+
# parse_multipart replaces the tempfile cleanup list; keep the ones from Rails' parse
|
|
408
|
+
earlier_tempfiles = Array(request.env["rack.tempfiles"])
|
|
409
|
+
parsed = Rack::Multipart.parse_multipart(request.env, parser) || {}
|
|
410
|
+
request.env["rack.tempfiles"] = earlier_tempfiles | Array(request.env["rack.tempfiles"])
|
|
411
|
+
parsed
|
|
412
|
+
else
|
|
413
|
+
raise Error, "multipart bodies need Rack >= 3.1 or a rewindable rack.input " \
|
|
414
|
+
"(wrap the app in Rack::RewindableInput::Middleware)"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Same step Rails applies to POST params: turns Rack's file hashes into UploadedFile.
|
|
418
|
+
ActionDispatch::Request::Utils.normalize_encode_params(raw)
|
|
419
|
+
end
|
|
420
|
+
|
|
359
421
|
def decode_parameters(schema_class, parameters)
|
|
360
422
|
Schema::ParameterDecoder.new(schema_class, parameters).call
|
|
361
423
|
end
|
|
@@ -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
|
|
|
@@ -132,14 +141,32 @@ module RailsNinja
|
|
|
132
141
|
end
|
|
133
142
|
|
|
134
143
|
def build_request_body(schema)
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
multipart = schema._fields.values.any? { |f| file_type?(f.type) }
|
|
145
|
+
reject_nested_files!(schema)
|
|
146
|
+
media_type = multipart ? "multipart/form-data" : "application/json"
|
|
147
|
+
|
|
148
|
+
{ required: true, content: { media_type => { schema: schema_ref(schema) } } }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def file_type?(type)
|
|
152
|
+
type = type.first if type.is_a?(Array)
|
|
153
|
+
type.is_a?(Class) && type <= Types::File
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Nested objects travel as JSON strings inside multipart, which cannot carry a file.
|
|
157
|
+
def reject_nested_files!(schema, path = [], seen = Set.new)
|
|
158
|
+
return unless seen.add?(schema)
|
|
159
|
+
|
|
160
|
+
schema._fields.each do |name, field|
|
|
161
|
+
nested = Array(field.type).first
|
|
162
|
+
next unless nested.is_a?(Class) && nested <= Schema::Base
|
|
163
|
+
|
|
164
|
+
if nested._fields.values.any? { |f| file_type?(f.type) }
|
|
165
|
+
raise Error, "File fields are only supported at the top level of a request schema " \
|
|
166
|
+
"(found under #{(path + [name]).join('.')})"
|
|
167
|
+
end
|
|
168
|
+
reject_nested_files!(nested, path + [name], seen)
|
|
169
|
+
end
|
|
143
170
|
end
|
|
144
171
|
|
|
145
172
|
def schema_ref(type)
|
|
@@ -170,9 +197,16 @@ module RailsNinja
|
|
|
170
197
|
end
|
|
171
198
|
end
|
|
172
199
|
|
|
173
|
-
def extract_path_params(path)
|
|
174
|
-
path.scan(/:(\w+)/).flatten
|
|
175
|
-
|
|
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))
|
|
176
210
|
end
|
|
177
211
|
end
|
|
178
212
|
|
|
@@ -180,7 +214,9 @@ module RailsNinja
|
|
|
180
214
|
return [] unless endpoint.request_schema
|
|
181
215
|
|
|
182
216
|
endpoint.request_schema._fields.map do |name, field|
|
|
183
|
-
{ name: name.to_s, in: "query", required: field.required,
|
|
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))
|
|
184
220
|
end
|
|
185
221
|
end
|
|
186
222
|
|
|
@@ -189,6 +225,7 @@ module RailsNinja
|
|
|
189
225
|
|
|
190
226
|
endpoint.header_params.map do |h|
|
|
191
227
|
{ name: h[:name], in: "header", required: h[:required], schema: h[:schema] }
|
|
228
|
+
.merge(h.slice(:description, :example))
|
|
192
229
|
end
|
|
193
230
|
end
|
|
194
231
|
|
|
@@ -243,16 +280,17 @@ module RailsNinja
|
|
|
243
280
|
required = []
|
|
244
281
|
|
|
245
282
|
schema_class._fields.each do |name, field|
|
|
246
|
-
property = schema_ref(field.type)
|
|
283
|
+
property = schema_ref(field.type).deep_dup
|
|
247
284
|
property[:title] = schema_title(name)
|
|
248
285
|
property[:enum] = field.enum if field.enum
|
|
286
|
+
property.merge!(field.documentation)
|
|
249
287
|
properties[name.to_s] = property
|
|
250
288
|
required << name.to_s if field.required
|
|
251
289
|
end
|
|
252
290
|
|
|
253
291
|
result = { type: "object", title: schema_name(schema_class), properties: properties }
|
|
254
292
|
result[:required] = required if required.any?
|
|
255
|
-
result
|
|
293
|
+
result.merge(schema_class.openapi)
|
|
256
294
|
end
|
|
257
295
|
|
|
258
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
|
-
@
|
|
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),
|
|
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
|
-
|
|
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
|
|
@@ -8,9 +8,12 @@ module RailsNinja
|
|
|
8
8
|
|
|
9
9
|
attr_reader :schema_class, :data
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# wrap_arrays: treat a lone value as a one-element list. Only safe for
|
|
12
|
+
# multipart, the one source where repeated names are preserved.
|
|
13
|
+
def initialize(schema_class, data, wrap_arrays: false)
|
|
12
14
|
@schema_class = schema_class
|
|
13
15
|
@data = data || {}
|
|
16
|
+
@wrap_arrays = wrap_arrays
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def call
|
|
@@ -26,9 +29,13 @@ module RailsNinja
|
|
|
26
29
|
|
|
27
30
|
def decode_value(value, type)
|
|
28
31
|
if type.is_a?(Array)
|
|
32
|
+
value = Array.wrap(value) if @wrap_arrays
|
|
29
33
|
value.is_a?(Array) ? value.map { |item| decode_value(item, type.first) } : value
|
|
30
34
|
elsif type.is_a?(Class) && type <= Schema::Base
|
|
31
|
-
|
|
35
|
+
# JSON already carries native types: validate it as-is, no form coercion.
|
|
36
|
+
return decode_json_object(value) if value.is_a?(::String)
|
|
37
|
+
|
|
38
|
+
value.is_a?(Hash) ? self.class.new(type, value, wrap_arrays: @wrap_arrays).call : value
|
|
32
39
|
elsif type.is_a?(Class) && type <= Types::BaseScalar
|
|
33
40
|
decode_scalar(value, type)
|
|
34
41
|
else
|
|
@@ -47,6 +54,13 @@ module RailsNinja
|
|
|
47
54
|
end
|
|
48
55
|
end
|
|
49
56
|
|
|
57
|
+
# OpenAPI clients send nested objects in multipart/form bodies as JSON strings.
|
|
58
|
+
def decode_json_object(value)
|
|
59
|
+
MultiJson.load(value)
|
|
60
|
+
rescue MultiJson::ParseError
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
|
|
50
64
|
def decode_integer(value)
|
|
51
65
|
INTEGER_PATTERN.match?(value) ? Kernel.Integer(value, 10) : value
|
|
52
66
|
end
|
|
@@ -11,6 +11,8 @@ module RailsNinja
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def call
|
|
14
|
+
return [{}, ["Expected object, got #{data.class}"]] unless data.respond_to?(:key?)
|
|
15
|
+
|
|
14
16
|
errors = []
|
|
15
17
|
validated = {}
|
|
16
18
|
|
|
@@ -77,6 +79,8 @@ module RailsNinja
|
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
def validate_schema(value, type)
|
|
82
|
+
return [nil, [": Expected object, got #{value.class}"]] unless value.respond_to?(:key?)
|
|
83
|
+
|
|
80
84
|
result, errors = type.validate(value)
|
|
81
85
|
[result, errors.map { |err| ".#{err}" }]
|
|
82
86
|
end
|
data/lib/rails_ninja/schema.rb
CHANGED
|
@@ -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)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNinja
|
|
4
|
+
module Types
|
|
5
|
+
class File < BaseScalar
|
|
6
|
+
class << self
|
|
7
|
+
def ruby_classes
|
|
8
|
+
[ActionDispatch::Http::UploadedFile]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def openapi_schema
|
|
12
|
+
{ type: "string", format: "binary" }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/rails_ninja/version.rb
CHANGED
data/lib/rails_ninja.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "action_controller/metal"
|
|
4
|
+
require "active_support/core_ext/array/wrap"
|
|
4
5
|
require "active_support/core_ext/enumerable"
|
|
6
|
+
require "active_support/core_ext/object/deep_dup"
|
|
5
7
|
require "rack"
|
|
6
8
|
require "multi_json"
|
|
7
9
|
|
|
@@ -9,6 +11,7 @@ require_relative "rails_ninja/version"
|
|
|
9
11
|
require_relative "rails_ninja/errors"
|
|
10
12
|
require_relative "rails_ninja/types/base_scalar"
|
|
11
13
|
require_relative "rails_ninja/types/boolean"
|
|
14
|
+
require_relative "rails_ninja/types/file"
|
|
12
15
|
require_relative "rails_ninja/types/float"
|
|
13
16
|
require_relative "rails_ninja/types/int"
|
|
14
17
|
require_relative "rails_ninja/types/string"
|
data/rails_ninja.gemspec
CHANGED
|
@@ -26,10 +26,10 @@ Gem::Specification.new do |spec|
|
|
|
26
26
|
end
|
|
27
27
|
spec.require_paths = ["lib"]
|
|
28
28
|
|
|
29
|
-
spec.add_dependency "actionpack", ">= 7.
|
|
30
|
-
spec.add_dependency "activesupport", ">= 7.
|
|
29
|
+
spec.add_dependency "actionpack", ">= 7.1"
|
|
30
|
+
spec.add_dependency "activesupport", ">= 7.1"
|
|
31
31
|
spec.add_dependency "multi_json", "~> 1.15"
|
|
32
|
-
spec.add_dependency "rack", ">= 2.
|
|
32
|
+
spec.add_dependency "rack", ">= 2.2.4"
|
|
33
33
|
|
|
34
34
|
spec.add_development_dependency "minitest", ">= 5.0"
|
|
35
35
|
spec.add_development_dependency "rack-test", ">= 2.0"
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Benjamin Urrutia
|
|
@@ -15,28 +15,28 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '7.
|
|
18
|
+
version: '7.1'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '7.
|
|
25
|
+
version: '7.1'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: activesupport
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '7.
|
|
32
|
+
version: '7.1'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '7.
|
|
39
|
+
version: '7.1'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: multi_json
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,14 +57,14 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version:
|
|
60
|
+
version: 2.2.4
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version:
|
|
67
|
+
version: 2.2.4
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: minitest
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- lib/rails_ninja/tasks.rake
|
|
138
138
|
- lib/rails_ninja/types/base_scalar.rb
|
|
139
139
|
- lib/rails_ninja/types/boolean.rb
|
|
140
|
+
- lib/rails_ninja/types/file.rb
|
|
140
141
|
- lib/rails_ninja/types/float.rb
|
|
141
142
|
- lib/rails_ninja/types/int.rb
|
|
142
143
|
- lib/rails_ninja/types/string.rb
|
|
@@ -166,7 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
166
167
|
- !ruby/object:Gem::Version
|
|
167
168
|
version: '0'
|
|
168
169
|
requirements: []
|
|
169
|
-
rubygems_version: 4.0.
|
|
170
|
+
rubygems_version: 4.0.20
|
|
170
171
|
specification_version: 4
|
|
171
172
|
summary: Django Ninja-inspired API framework for Ruby/Rails
|
|
172
173
|
test_files: []
|