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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 437ec40dd224b486cc4b24755700f6b8a1bbb7c77e88dd3523760d59d1af5ff5
4
- data.tar.gz: 28600e208c8c37a36d20243614abd2b8b0096a8e3abd1731e728740d466591eb
3
+ metadata.gz: 9ea5b2dadb986f6d8ccde0b94cef6e57a941cd5336a776182e3462afc15dcdd7
4
+ data.tar.gz: 344e6aa870b08bb554fc239cc0f5f583721c8fd73d2d0532a9206d01cbe47cf0
5
5
  SHA512:
6
- metadata.gz: dfa8f772a93844aff65ffdc59bfbb67379a014f26cb0f73569fcdc3e909086b12d49219db008507065a985fc6a05b73ae3ed6588ba9ab234b36619200aafd7d9
7
- data.tar.gz: ea13a4f5d9daee6f4cd80c493d55c7518e2d90e78861266e8e575e21617009fde4e3d26d1620c7f2bf42828d10a8937adb9251c269e4879ab3273a07f7f5991f
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 `Boolean` under `RailsNinja::Types`. A field may also contain a
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.0
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
 
@@ -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 FORM_MEDIA_TYPES.include?(request.media_type)
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
- required: true,
137
- content: {
138
- "application/json" => {
139
- schema: schema_ref(schema),
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.map do |param|
175
- { 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))
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, 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))
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
- @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
@@ -8,9 +8,12 @@ module RailsNinja
8
8
 
9
9
  attr_reader :schema_class, :data
10
10
 
11
- def initialize(schema_class, data)
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
- value.is_a?(Hash) ? self.class.new(type, value).call : value
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsNinja
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
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.0"
30
- spec.add_dependency "activesupport", ">= 7.0"
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.0"
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.2.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.0'
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.0'
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.0'
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.0'
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: '2.0'
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: '2.0'
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.16
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: []