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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d810edb4a339ca65e24bed6cbecf1baf1f83f84f7894589e8db69a09bc9b3f23
4
- data.tar.gz: eaef44afdc12b965d93fd4532efadf41025c30fd59eb87281ae5815c6f48bcf7
3
+ metadata.gz: 36e27c067e70b65d503a3c78b61e9efc05d8995f9927edfaf6f66ff4fae70b25
4
+ data.tar.gz: 131fc3d1bea432320046c4e5c220dff0a3ead87d8a2f3d12310cdd847b2e50c1
5
5
  SHA512:
6
- metadata.gz: f2de8020efb8ede1493d10cb798d69fa92201d2b518177a527d0e2546e771afe8fa436f03dd839c4a75d655cc2eab26fdb35c4d37a830393fda4e37bfd6583c5
7
- data.tar.gz: ca421c1f360b072570f7eab0b17c26ac2109f16a19d96efbe564d22e791f7fa0945d11eb914ff67b29602576499f03aed388fc10cad6f9b7b1f42bea4f905f7d
6
+ metadata.gz: 9d44a2c33be8175c6b59b1426921fd339c4e4125bf7fa076da27f4b9c17bcf1efb69aa0233c8e91f16804b086f250b37fddb0e6e6dce2debbf0349fc96ac832d
7
+ data.tar.gz: 59d1a63e68b5560ee7cf0243ff3ccf6684376249745727e6695576821285e8dc1d64018a5f8e8a962f9d7dcea74c1e6bc9d27dd664662d327c257d81505b6c23
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2024-11-03
3
+ ## [1.1.0] - 2024-11-16
4
+
5
+ - Response validation refined
6
+ - Bugfix for openapi export
7
+
8
+ ## [1.0.0] - 2024-11-14
4
9
 
5
10
  - Initial release
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
- - `'NoContentType'` - renders an empty object, for use with `status: :no_content`
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 into incoming integers into a `Time`
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; end
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] = export_route(route, declaration)
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:, action_name:)
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 { |t| t.field(nesting, **kwargs) }
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:, action_name:)
3
- return unless Taro.config.validate_response
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 self.check_expected_type_was_used(controller, expected)
47
- used = Taro::Types::BaseType.rendering
48
-
49
- if expected.nil?
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
@@ -12,7 +12,6 @@ module Taro::Rails
12
12
  buffered_declarations.clear
13
13
  declarations_map.clear
14
14
  RouteFinder.clear_cache
15
- Taro::Types::BaseType.rendering = nil
16
- Taro::Types::BaseType.used_in_response = nil
15
+ Taro::Types::BaseType.last_render = nil
17
16
  end
18
17
  end
@@ -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
@@ -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 one of #{self.class.values}")
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 one of #{self.class.values}")
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 rendering=(value)
22
- ActiveSupport::IsolatedExecutionState[:taro_type_rendering] = value
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 used_in_response=(value)
30
- ActiveSupport::IsolatedExecutionState[:taro_type_used_in_response] = value
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
- ActiveSupport::IsolatedExecutionState[:taro_type_used_in_response]
20
+ last_render.to_a.first
35
21
  end
36
22
  end
data/lib/taro/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # :nocov:
2
2
  module Taro
3
- VERSION = "1.0.0"
3
+ VERSION = "1.1.0"
4
4
  end
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.0.0
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-15 00:00:00.000000000 Z
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