taro 1.0.0 → 1.1.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/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
|