taro 1.0.0 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -1
- data/README.md +2 -7
- data/lib/taro/errors.rb +7 -1
- data/lib/taro/export/open_api_v3.rb +2 -1
- data/lib/taro/rails/active_declarations.rb +1 -1
- data/lib/taro/rails/declaration.rb +18 -1
- data/lib/taro/rails/response_validation.rb +7 -57
- data/lib/taro/rails/response_validator.rb +109 -0
- data/lib/taro/rails.rb +1 -2
- data/lib/taro/types/coercion.rb +4 -3
- data/lib/taro/types/enum_type.rb +2 -2
- data/lib/taro/types/shared/rendering.rb +7 -21
- data/lib/taro/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 36e27c067e70b65d503a3c78b61e9efc05d8995f9927edfaf6f66ff4fae70b25
|
4
|
+
data.tar.gz: 131fc3d1bea432320046c4e5c220dff0a3ead87d8a2f3d12310cdd847b2e50c1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9d44a2c33be8175c6b59b1426921fd339c4e4125bf7fa076da27f4b9c17bcf1efb69aa0233c8e91f16804b086f250b37fddb0e6e6dce2debbf0349fc96ac832d
|
7
|
+
data.tar.gz: 59d1a63e68b5560ee7cf0243ff3ccf6684376249745727e6695576821285e8dc1d64018a5f8e8a962f9d7dcea74c1e6bc9d27dd664662d327c257d81505b6c23
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -10,11 +10,6 @@ It is inspired by [`apipie-rails`](https://github.com/Apipie/apipie-rails) and [
|
|
10
10
|
- conveniently check request and response data against the declaration
|
11
11
|
- offer an up-to-date OpenAPI export with minimal configuration
|
12
12
|
|
13
|
-
## ⚠️ This is a work in progress - TODO:
|
14
|
-
|
15
|
-
- ISO8601Time, ISO8601Date types
|
16
|
-
- ResponseValidation: allow rendering scalars directly (e.g. `render json: 42`)
|
17
|
-
|
18
13
|
## Installation
|
19
14
|
|
20
15
|
```bash
|
@@ -139,9 +134,9 @@ The following type names are available by default and can be used as `type:`/`ar
|
|
139
134
|
- `'Float'`
|
140
135
|
- `'FreeForm'` - accepts and renders any JSON-serializable object, use with care
|
141
136
|
- `'Integer'`
|
142
|
-
- `'
|
137
|
+
- `'NoContent'` - renders an empty object, for use with `status: :no_content`
|
143
138
|
- `'String'`
|
144
|
-
- `'Timestamp'` - renders a `Time` as unix timestamp integer and turns
|
139
|
+
- `'Timestamp'` - renders a `Time` as unix timestamp integer and turns incoming integers into a `Time`
|
145
140
|
- `'UUID'` - accepts and renders UUIDs
|
146
141
|
- `'Date'` - accepts and renders a date string in ISO8601 format
|
147
142
|
- `'Time'` - accepts and renders a time string in ISO8601 format
|
data/lib/taro/errors.rb
CHANGED
@@ -1,4 +1,10 @@
|
|
1
|
-
class Taro::Error < StandardError
|
1
|
+
class Taro::Error < StandardError
|
2
|
+
def message
|
3
|
+
# clean up newlines introduced when setting the message with a heredoc
|
4
|
+
super.chomp.tr("\n", ' ')
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
2
8
|
class Taro::ArgumentError < Taro::Error; end
|
3
9
|
class Taro::RuntimeError < Taro::Error; end
|
4
10
|
class Taro::ValidationError < Taro::RuntimeError; end # not to be used directly
|
@@ -19,7 +19,8 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
|
|
19
19
|
def export_paths(declarations)
|
20
20
|
declarations.each_with_object({}) do |declaration, paths|
|
21
21
|
declaration.routes.each do |route|
|
22
|
-
paths[route.openapi_path]
|
22
|
+
paths[route.openapi_path] ||= {}
|
23
|
+
paths[route.openapi_path].merge! export_route(route, declaration)
|
23
24
|
end
|
24
25
|
end
|
25
26
|
end
|
@@ -2,7 +2,7 @@ module Taro::Rails::ActiveDeclarations
|
|
2
2
|
def apply(declaration:, controller_class:, action_name:)
|
3
3
|
(declarations_map[controller_class] ||= {})[action_name] = declaration
|
4
4
|
Taro::Rails::ParamParsing.install(controller_class:, action_name:)
|
5
|
-
Taro::Rails::ResponseValidation.install(controller_class
|
5
|
+
Taro::Rails::ResponseValidation.install(controller_class:)
|
6
6
|
end
|
7
7
|
|
8
8
|
def declarations_map
|
@@ -89,12 +89,29 @@ class Taro::Rails::Declaration
|
|
89
89
|
def return_type_from(nesting, **kwargs)
|
90
90
|
if nesting
|
91
91
|
# ad-hoc return type, requiring the actual return type to be nested
|
92
|
-
Class.new(Taro::Types::ObjectType).tap
|
92
|
+
Class.new(Taro::Types::ObjectType).tap do |type|
|
93
|
+
type.field(nesting, null: false, **kwargs)
|
94
|
+
end
|
93
95
|
else
|
96
|
+
check_return_kwargs(kwargs)
|
94
97
|
Taro::Types::Coercion.call(kwargs)
|
95
98
|
end
|
96
99
|
end
|
97
100
|
|
101
|
+
def check_return_kwargs(kwargs)
|
102
|
+
if kwargs.key?(:null)
|
103
|
+
raise Taro::ArgumentError, <<~MSG
|
104
|
+
`null:` is not supported for top-level returns. If you want a nullable return
|
105
|
+
value, nest it, e.g. `returns :str, type: 'String', null: true`.
|
106
|
+
MSG
|
107
|
+
end
|
108
|
+
|
109
|
+
bad_keys = kwargs.keys - (Taro::Types::Coercion::KEYS + %i[code defined_at desc])
|
110
|
+
return if bad_keys.empty?
|
111
|
+
|
112
|
+
raise Taro::ArgumentError, "Invalid `returns` options: #{bad_keys.join(', ')}"
|
113
|
+
end
|
114
|
+
|
98
115
|
def raise_missing_route(controller_class, action_name)
|
99
116
|
raise(Taro::ArgumentError, "No route found for #{controller_class}##{action_name}")
|
100
117
|
end
|
@@ -1,63 +1,13 @@
|
|
1
1
|
module Taro::Rails::ResponseValidation
|
2
|
-
def self.install(controller_class
|
3
|
-
|
4
|
-
|
5
|
-
key = [controller_class, action_name]
|
6
|
-
return if installed[key]
|
7
|
-
|
8
|
-
installed[key] = true
|
9
|
-
|
10
|
-
controller_class.around_action(only: action_name) do |_, block|
|
11
|
-
Taro::Types::BaseType.rendering = nil
|
12
|
-
block.call
|
13
|
-
Taro::Rails::ResponseValidation.call(self)
|
14
|
-
ensure
|
15
|
-
Taro::Types::BaseType.rendering = nil
|
16
|
-
end
|
17
|
-
end
|
18
|
-
|
19
|
-
def self.installed
|
20
|
-
@installed ||= {}
|
21
|
-
end
|
22
|
-
|
23
|
-
def self.call(controller)
|
24
|
-
declaration = Taro::Rails.declaration_for(controller)
|
25
|
-
nesting = declaration.return_nestings[controller.status]
|
26
|
-
expected = declaration.returns[controller.status]
|
27
|
-
if nesting
|
28
|
-
# case: `returns :some_nesting, type: 'SomeType'` (ad-hoc return type)
|
29
|
-
check_nesting(controller.response, nesting)
|
30
|
-
expected = expected.fields[nesting].type
|
31
|
-
end
|
32
|
-
|
33
|
-
check_expected_type_was_used(controller, expected)
|
34
|
-
end
|
35
|
-
|
36
|
-
def self.check_nesting(response, nesting)
|
37
|
-
return unless /json/.match?(response.media_type)
|
38
|
-
|
39
|
-
first_key = response.body.to_s[/\A{\s*"([^"]+)"/, 1]
|
40
|
-
first_key == nesting.to_s || raise(Taro::ResponseError, <<~MSG)
|
41
|
-
Expected response to be nested in "#{nesting}" key, but it was not.
|
42
|
-
(First JSON key in response: "#{first_key}".)
|
43
|
-
MSG
|
2
|
+
def self.install(controller_class:)
|
3
|
+
controller_class.prepend(self) if Taro.config.validate_response
|
44
4
|
end
|
45
5
|
|
46
|
-
def
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
raise(Taro::ResponseError, <<~MSG)
|
51
|
-
No matching return type declared in #{controller.class}##{controller.action_name}\
|
52
|
-
for status #{controller.status}.
|
53
|
-
MSG
|
6
|
+
def render(*, **kwargs, &)
|
7
|
+
result = super
|
8
|
+
if (declaration = Taro::Rails.declaration_for(self))
|
9
|
+
Taro::Rails::ResponseValidator.call(self, declaration, kwargs[:json])
|
54
10
|
end
|
55
|
-
|
56
|
-
used&.<=(expected) || raise(Taro::ResponseError, <<~MSG)
|
57
|
-
Expected #{controller.class}##{controller.action_name} to use #{expected}.render,
|
58
|
-
but #{used ? "#{used}.render" : 'no type render method'} was called.
|
59
|
-
MSG
|
60
|
-
|
61
|
-
Taro::Types::BaseType.used_in_response = used # for comparisons in specs
|
11
|
+
result
|
62
12
|
end
|
63
13
|
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
Taro::Rails::ResponseValidator = Struct.new(:controller, :declaration, :rendered) do
|
2
|
+
def self.call(*args)
|
3
|
+
new(*args).call
|
4
|
+
end
|
5
|
+
|
6
|
+
def call
|
7
|
+
if declared_return_type < Taro::Types::ScalarType
|
8
|
+
check_scalar
|
9
|
+
elsif declared_return_type < Taro::Types::ListType &&
|
10
|
+
declared_return_type.item_type < Taro::Types::ScalarType
|
11
|
+
check_scalar_array
|
12
|
+
elsif declared_return_type < Taro::Types::EnumType
|
13
|
+
check_enum
|
14
|
+
else
|
15
|
+
check_custom_type
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def declared_return_type
|
20
|
+
@declared_return_type ||= begin
|
21
|
+
return_type = declaration.returns[controller.status] ||
|
22
|
+
fail_with('No return type declared for this status.')
|
23
|
+
nesting ? return_type.fields.fetch(nesting).type : return_type
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def fail_with(message)
|
28
|
+
raise Taro::ResponseError, <<~MSG
|
29
|
+
Response validation error for
|
30
|
+
#{controller.class}##{controller.action_name}, code #{controller.status}":
|
31
|
+
#{message}
|
32
|
+
MSG
|
33
|
+
end
|
34
|
+
|
35
|
+
# support `returns :some_nesting, type: 'SomeType'` (ad-hoc return type)
|
36
|
+
def nesting
|
37
|
+
@nesting ||= declaration.return_nestings[controller.status]
|
38
|
+
end
|
39
|
+
|
40
|
+
def denest_rendered
|
41
|
+
assert_rendered_is_a_hash
|
42
|
+
|
43
|
+
if rendered.key?(nesting)
|
44
|
+
rendered[nesting]
|
45
|
+
elsif rendered.key?(nesting.to_s)
|
46
|
+
rendered[nesting.to_s]
|
47
|
+
else
|
48
|
+
fail_with_nesting_error
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def assert_rendered_is_a_hash
|
53
|
+
rendered.is_a?(Hash) || fail_with("Expected Hash, got #{rendered.class}.")
|
54
|
+
end
|
55
|
+
|
56
|
+
def fail_with_nesting_error
|
57
|
+
fail_with "Expected key :#{nesting}, got: #{rendered.keys}."
|
58
|
+
end
|
59
|
+
|
60
|
+
# For scalar and enum types, we want to support e.g. `render json: 42`,
|
61
|
+
# and not require using the type as in `BeautifulNumbersEnum.render(42)`.
|
62
|
+
def check_scalar(type = declared_return_type, value = subject)
|
63
|
+
case type.openapi_type
|
64
|
+
when :integer, :number then value.is_a?(Numeric)
|
65
|
+
when :string then value.is_a?(String) || value.is_a?(Symbol)
|
66
|
+
when :boolean then [true, false].include?(value)
|
67
|
+
end || fail_with("Expected a #{type.openapi_type}, got: #{value.class}.")
|
68
|
+
end
|
69
|
+
|
70
|
+
def subject
|
71
|
+
@subject ||= nesting ? denest_rendered : rendered
|
72
|
+
end
|
73
|
+
|
74
|
+
def check_scalar_array
|
75
|
+
subject.is_a?(Array) || fail_with('Expected an Array.')
|
76
|
+
subject.empty? || check_scalar(declared_return_type.item_type, subject.first)
|
77
|
+
end
|
78
|
+
|
79
|
+
def check_enum
|
80
|
+
# coercion checks non-emptyness + enum match
|
81
|
+
declared_return_type.new(subject).coerce_response
|
82
|
+
rescue Taro::Error => e
|
83
|
+
fail_with(e.message)
|
84
|
+
end
|
85
|
+
|
86
|
+
# For complex/object types, we ensure conformance by checking whether
|
87
|
+
# the type was used for rendering. This has performance benefits compared
|
88
|
+
# to going over the structure a second time.
|
89
|
+
def check_custom_type
|
90
|
+
# Ignore types without a specified structure.
|
91
|
+
return if declared_return_type <= Taro::Types::ObjectTypes::FreeFormType
|
92
|
+
return if declared_return_type <= Taro::Types::ObjectTypes::NoContentType
|
93
|
+
|
94
|
+
strict_check_custom_type
|
95
|
+
end
|
96
|
+
|
97
|
+
def strict_check_custom_type
|
98
|
+
used_type, rendered_object_id = declared_return_type.last_render
|
99
|
+
used_type&.<=(declared_return_type) || fail_with(<<~MSG)
|
100
|
+
Expected to use #{declared_return_type}.render, but the last type rendered
|
101
|
+
was: #{used_type || 'no type'}.
|
102
|
+
MSG
|
103
|
+
|
104
|
+
rendered_object_id == subject.__id__ || fail_with(<<~MSG)
|
105
|
+
#{declared_return_type}.render was called, but the result
|
106
|
+
of this call was not used in the response.
|
107
|
+
MSG
|
108
|
+
end
|
109
|
+
end
|
data/lib/taro/rails.rb
CHANGED
data/lib/taro/types/coercion.rb
CHANGED
@@ -56,15 +56,16 @@ module Taro::Types::Coercion
|
|
56
56
|
@shortcuts ||= {
|
57
57
|
# rubocop:disable Layout/HashAlignment - buggy cop
|
58
58
|
'Boolean' => Taro::Types::Scalar::BooleanType,
|
59
|
+
'Date' => Taro::Types::Scalar::ISO8601DateType,
|
60
|
+
'DateTime' => Taro::Types::Scalar::ISO8601DateTimeType,
|
59
61
|
'Float' => Taro::Types::Scalar::FloatType,
|
60
62
|
'FreeForm' => Taro::Types::ObjectTypes::FreeFormType,
|
61
63
|
'Integer' => Taro::Types::Scalar::IntegerType,
|
64
|
+
'NoContent' => Taro::Types::ObjectTypes::NoContentType,
|
62
65
|
'String' => Taro::Types::Scalar::StringType,
|
66
|
+
'Time' => Taro::Types::Scalar::ISO8601DateTimeType,
|
63
67
|
'Timestamp' => Taro::Types::Scalar::TimestampType,
|
64
68
|
'UUID' => Taro::Types::Scalar::UUIDv4Type,
|
65
|
-
'Date' => Taro::Types::Scalar::ISO8601DateType,
|
66
|
-
'Time' => Taro::Types::Scalar::ISO8601DateTimeType,
|
67
|
-
'DateTime' => Taro::Types::Scalar::ISO8601DateTimeType,
|
68
69
|
# rubocop:enable Layout/HashAlignment - buggy cop
|
69
70
|
}.freeze
|
70
71
|
end
|
data/lib/taro/types/enum_type.rb
CHANGED
@@ -18,7 +18,7 @@ class Taro::Types::EnumType < Taro::Types::BaseType
|
|
18
18
|
if self.class.values.include?(value)
|
19
19
|
value
|
20
20
|
else
|
21
|
-
input_error("must be
|
21
|
+
input_error("must be #{self.class.values.map(&:inspect).join(' or ')}")
|
22
22
|
end
|
23
23
|
end
|
24
24
|
|
@@ -28,7 +28,7 @@ class Taro::Types::EnumType < Taro::Types::BaseType
|
|
28
28
|
if self.class.values.include?(value)
|
29
29
|
value
|
30
30
|
else
|
31
|
-
response_error("must be
|
31
|
+
response_error("must be #{self.class.values.map(&:inspect).join(' or ')}")
|
32
32
|
end
|
33
33
|
end
|
34
34
|
|
@@ -2,35 +2,21 @@
|
|
2
2
|
# Special types (e.g. PageType) may accept kwargs for `#coerce_response`.
|
3
3
|
module Taro::Types::Shared::Rendering
|
4
4
|
def render(object, opts = {})
|
5
|
-
if (prev = rendering)
|
6
|
-
raise Taro::RuntimeError, <<~MSG
|
7
|
-
Type.render should only be called once per request.
|
8
|
-
(First called on #{prev}, then on #{self}.)
|
9
|
-
MSG
|
10
|
-
end
|
11
|
-
|
12
5
|
result = new(object).coerce_response(**opts)
|
13
|
-
|
14
|
-
# Only mark this as the used type if coercion worked so that
|
15
|
-
# rescue_from can be used to render another type.
|
16
|
-
self.rendering = self
|
17
|
-
|
6
|
+
self.last_render = [self, result.__id__]
|
18
7
|
result
|
19
8
|
end
|
20
9
|
|
21
|
-
def
|
22
|
-
ActiveSupport::IsolatedExecutionState[:
|
23
|
-
end
|
24
|
-
|
25
|
-
def rendering
|
26
|
-
ActiveSupport::IsolatedExecutionState[:taro_type_rendering]
|
10
|
+
def last_render=(info)
|
11
|
+
ActiveSupport::IsolatedExecutionState[:taro_last_render] = info
|
27
12
|
end
|
28
13
|
|
29
|
-
def
|
30
|
-
ActiveSupport::IsolatedExecutionState[:
|
14
|
+
def last_render
|
15
|
+
ActiveSupport::IsolatedExecutionState[:taro_last_render]
|
31
16
|
end
|
32
17
|
|
18
|
+
# get the last used type for assertions in tests/specs
|
33
19
|
def used_in_response
|
34
|
-
|
20
|
+
last_render.to_a.first
|
35
21
|
end
|
36
22
|
end
|
data/lib/taro/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: taro
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Janosch Müller
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-11-
|
11
|
+
date: 2024-11-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rack
|
@@ -62,6 +62,7 @@ files:
|
|
62
62
|
- lib/taro/rails/param_parsing.rb
|
63
63
|
- lib/taro/rails/railtie.rb
|
64
64
|
- lib/taro/rails/response_validation.rb
|
65
|
+
- lib/taro/rails/response_validator.rb
|
65
66
|
- lib/taro/rails/route_finder.rb
|
66
67
|
- lib/taro/rails/tasks/export.rake
|
67
68
|
- lib/taro/types.rb
|