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 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