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 +4 -4
- data/README.md +22 -4
- data/lib/rails_ninja/api.rb +47 -1
- data/lib/rails_ninja/openapi/generator.rb +26 -8
- data/lib/rails_ninja/schema/parameter_decoder.rb +16 -2
- data/lib/rails_ninja/schema/validator.rb +4 -0
- data/lib/rails_ninja/types/file.rb +17 -0
- data/lib/rails_ninja/version.rb +1 -1
- data/lib/rails_ninja.rb +2 -0
- data/rails_ninja.gemspec +3 -3
- metadata +8 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c11362d2af26f7b911e310e2c67e603b5dfd63fd62e5758ce8936da06b1bb36
|
|
4
|
+
data.tar.gz: fb2bd66b8af63055d31a8c1cfc5eb2023883bc46f244c6f77fed0e0668dad4a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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.
|
|
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
|
|
data/lib/rails_ninja/api.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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,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.
|
|
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.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.
|
|
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
|