rails_ninja 0.2.0 → 0.3.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: 3c11362d2af26f7b911e310e2c67e603b5dfd63fd62e5758ce8936da06b1bb36
4
+ data.tar.gz: fb2bd66b8af63055d31a8c1cfc5eb2023883bc46f244c6f77fed0e0668dad4a8
5
5
  SHA512:
6
- metadata.gz: dfa8f772a93844aff65ffdc59bfbb67379a014f26cb0f73569fcdc3e909086b12d49219db008507065a985fc6a05b73ae3ed6588ba9ab234b36619200aafd7d9
7
- data.tar.gz: ea13a4f5d9daee6f4cd80c493d55c7518e2d90e78861266e8e575e21617009fde4e3d26d1620c7f2bf42828d10a8937adb9251c269e4879ab3273a07f7f5991f
6
+ metadata.gz: e541dbca84d3e26df63229509abc3b5d70bae9da3ce6e8aa31da8c3a97a7df5d0f81ffd7e790820d96a019f0c16b984e2b329fc09fef5d0ea5b55c136c5b844c
7
+ data.tar.gz: '0381a91dd66a8c50be828018978d88d48e95b4f607325e94f1e4fb152d6714c79a79b2e6ef98e6c224c2d6a0409b96a061d748890edf188574e269a8a610b7d8'
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
@@ -270,7 +288,7 @@ bundle install
270
288
  bundle exec rake test
271
289
  ```
272
290
 
273
- CI tests every compatible combination of Action Pack and Active Support 7.0
291
+ CI tests every compatible combination of Action Pack and Active Support 7.1
274
292
  through 8.1 with MRI Ruby 3.0 through 4.0. Each lane resolves the latest patch
275
293
  release in its minor series.
276
294
 
@@ -336,6 +336,8 @@ module RailsNinja
336
336
 
337
337
  if errors.empty?
338
338
  params.merge!(validated)
339
+ # Mounted groups / included endpoints run on their own instance with its own params
340
+ @_ninja_handler_instance&.params&.merge!(validated)
339
341
  return
340
342
  end
341
343
 
@@ -347,7 +349,9 @@ module RailsNinja
347
349
  def request_input(schema_class)
348
350
  path = decode_parameters(schema_class, request.path_parameters)
349
351
  query = decode_parameters(schema_class, request.query_parameters)
350
- body = if FORM_MEDIA_TYPES.include?(request.media_type)
352
+ body = if request.media_type == "multipart/form-data"
353
+ Schema::ParameterDecoder.new(schema_class, multipart_parameters(schema_class), wrap_arrays: true).call
354
+ elsif FORM_MEDIA_TYPES.include?(request.media_type)
351
355
  decode_parameters(schema_class, request.request_parameters)
352
356
  else
353
357
  request.request_parameters.deep_symbolize_keys
@@ -356,6 +360,48 @@ module RailsNinja
356
360
  path.merge(query).merge(body)
357
361
  end
358
362
 
363
+ # Rack keeps only the last part when a multipart name repeats without "[]",
364
+ # but OpenAPI clients send arrays exactly that way ("files", "files").
365
+ # Re-parse the body so repeated bare names accumulate into arrays.
366
+ module RepeatedMultipartNames
367
+ def normalize_params(params, name, value, *rest)
368
+ return super unless params.key?(name) && !name.include?("[")
369
+
370
+ existing = params[name]
371
+ params[name] = existing.is_a?(Array) ? existing << value : [existing, value]
372
+ params
373
+ end
374
+ end
375
+
376
+ def multipart_parameters(schema_class)
377
+ rails_params = request.request_parameters
378
+ io = request.env["rack.input"]
379
+ # Rails' parse is already complete unless a list field could have lost repeated parts
380
+ list_fields = schema_class._fields.values.any? { |f| f.type.is_a?(Array) }
381
+ return rails_params if io.nil? || rails_params.empty? || !list_fields
382
+
383
+ parser = Rack::Utils.default_query_parser.dup.extend(RepeatedMultipartNames)
384
+
385
+ raw = if (pairs = request.env["rack.request.form_pairs"])
386
+ params = parser.make_params
387
+ pairs.each { |name, value| parser.normalize_params(params, name, value) }
388
+ params.to_params_hash
389
+ elsif io.respond_to?(:rewind)
390
+ io.rewind
391
+ # parse_multipart replaces the tempfile cleanup list; keep the ones from Rails' parse
392
+ earlier_tempfiles = Array(request.env["rack.tempfiles"])
393
+ parsed = Rack::Multipart.parse_multipart(request.env, parser) || {}
394
+ request.env["rack.tempfiles"] = earlier_tempfiles | Array(request.env["rack.tempfiles"])
395
+ parsed
396
+ else
397
+ raise Error, "multipart bodies need Rack >= 3.1 or a rewindable rack.input " \
398
+ "(wrap the app in Rack::RewindableInput::Middleware)"
399
+ end
400
+
401
+ # Same step Rails applies to POST params: turns Rack's file hashes into UploadedFile.
402
+ ActionDispatch::Request::Utils.normalize_encode_params(raw)
403
+ end
404
+
359
405
  def decode_parameters(schema_class, parameters)
360
406
  Schema::ParameterDecoder.new(schema_class, parameters).call
361
407
  end
@@ -132,14 +132,32 @@ module RailsNinja
132
132
  end
133
133
 
134
134
  def build_request_body(schema)
135
- {
136
- required: true,
137
- content: {
138
- "application/json" => {
139
- schema: schema_ref(schema),
140
- },
141
- },
142
- }
135
+ multipart = schema._fields.values.any? { |f| file_type?(f.type) }
136
+ reject_nested_files!(schema)
137
+ media_type = multipart ? "multipart/form-data" : "application/json"
138
+
139
+ { required: true, content: { media_type => { schema: schema_ref(schema) } } }
140
+ end
141
+
142
+ def file_type?(type)
143
+ type = type.first if type.is_a?(Array)
144
+ type.is_a?(Class) && type <= Types::File
145
+ end
146
+
147
+ # Nested objects travel as JSON strings inside multipart, which cannot carry a file.
148
+ def reject_nested_files!(schema, path = [], seen = Set.new)
149
+ return unless seen.add?(schema)
150
+
151
+ schema._fields.each do |name, field|
152
+ nested = Array(field.type).first
153
+ next unless nested.is_a?(Class) && nested <= Schema::Base
154
+
155
+ if nested._fields.values.any? { |f| file_type?(f.type) }
156
+ raise Error, "File fields are only supported at the top level of a request schema " \
157
+ "(found under #{(path + [name]).join('.')})"
158
+ end
159
+ reject_nested_files!(nested, path + [name], seen)
160
+ end
143
161
  end
144
162
 
145
163
  def schema_ref(type)
@@ -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
@@ -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.3.0"
5
5
  end
data/lib/rails_ninja.rb CHANGED
@@ -1,6 +1,7 @@
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"
5
6
  require "rack"
6
7
  require "multi_json"
@@ -9,6 +10,7 @@ require_relative "rails_ninja/version"
9
10
  require_relative "rails_ninja/errors"
10
11
  require_relative "rails_ninja/types/base_scalar"
11
12
  require_relative "rails_ninja/types/boolean"
13
+ require_relative "rails_ninja/types/file"
12
14
  require_relative "rails_ninja/types/float"
13
15
  require_relative "rails_ninja/types/int"
14
16
  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.3.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