taro 1.0.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/README.md +77 -14
  4. data/lib/taro/errors.rb +7 -1
  5. data/lib/taro/export/open_api_v3.rb +54 -23
  6. data/lib/taro/rails/active_declarations.rb +1 -1
  7. data/lib/taro/rails/declaration.rb +50 -9
  8. data/lib/taro/rails/generators/install_generator.rb +1 -1
  9. data/lib/taro/rails/generators/templates/errors_type.erb +15 -10
  10. data/lib/taro/rails/normalized_route.rb +8 -0
  11. data/lib/taro/rails/response_validation.rb +7 -57
  12. data/lib/taro/rails/response_validator.rb +109 -0
  13. data/lib/taro/rails/tasks/export.rake +5 -1
  14. data/lib/taro/rails.rb +1 -2
  15. data/lib/taro/types/base_type.rb +2 -0
  16. data/lib/taro/types/coercion.rb +28 -17
  17. data/lib/taro/types/enum_type.rb +2 -2
  18. data/lib/taro/types/field.rb +8 -16
  19. data/lib/taro/types/field_validation.rb +1 -1
  20. data/lib/taro/types/list_type.rb +4 -6
  21. data/lib/taro/types/object_types/free_form_type.rb +1 -0
  22. data/lib/taro/types/object_types/no_content_type.rb +1 -0
  23. data/lib/taro/types/object_types/page_info_type.rb +2 -0
  24. data/lib/taro/types/object_types/page_type.rb +15 -25
  25. data/lib/taro/types/scalar/iso8601_date_type.rb +1 -0
  26. data/lib/taro/types/scalar/iso8601_datetime_type.rb +1 -0
  27. data/lib/taro/types/scalar/timestamp_type.rb +1 -0
  28. data/lib/taro/types/scalar/uuid_v4_type.rb +1 -0
  29. data/lib/taro/types/shared/deprecation.rb +3 -0
  30. data/lib/taro/types/shared/derived_types.rb +27 -0
  31. data/lib/taro/types/shared/errors.rb +3 -1
  32. data/lib/taro/types/shared/fields.rb +6 -5
  33. data/lib/taro/types/shared/item_type.rb +1 -0
  34. data/lib/taro/types/shared/object_coercion.rb +13 -0
  35. data/lib/taro/types/shared/openapi_name.rb +8 -6
  36. data/lib/taro/types/shared/rendering.rb +11 -25
  37. data/lib/taro/version.rb +1 -1
  38. data/tasks/benchmark.rake +1 -1
  39. metadata +7 -5
  40. data/lib/taro/types/shared/derivable_types.rb +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d810edb4a339ca65e24bed6cbecf1baf1f83f84f7894589e8db69a09bc9b3f23
4
- data.tar.gz: eaef44afdc12b965d93fd4532efadf41025c30fd59eb87281ae5815c6f48bcf7
3
+ metadata.gz: 5007f07dcb3230a1a45011138c19cc0f233e0a0e0fa81d64d554ee3d5cc4a82e
4
+ data.tar.gz: 99dde57ec71bb2724a29005e644a0bb23d642df11fe4555bd9203340169814df
5
5
  SHA512:
6
- metadata.gz: f2de8020efb8ede1493d10cb798d69fa92201d2b518177a527d0e2546e771afe8fa436f03dd839c4a75d655cc2eab26fdb35c4d37a830393fda4e37bfd6583c5
7
- data.tar.gz: ca421c1f360b072570f7eab0b17c26ac2109f16a19d96efbe564d22e791f7fa0945d11eb914ff67b29602576499f03aed388fc10cad6f9b7b1f42bea4f905f7d
6
+ metadata.gz: abb5fb481da01a4a50b19e891c3373b42372c97993788ed24c51b07146e087e25d040bc4ea077606a14013633b30f6254f6ced2e939ba9b039b341d95f918211
7
+ data.tar.gz: b5dd6c2222598ad3d8ee64c64fd0fc6c40299c3f071b08317d17aa29c3f786924ae4b064f587b1e548ba102e0cc802b8a5adf7ccbadf033795974bfdaae5329a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2024-11-03
3
+ ## [1.2.0] - 2024-11-18
4
+
5
+ ### Added
6
+
7
+ - Improved error messages
8
+ - Option to define custom derived types
9
+ - Option to use custom keys in paginated content
10
+ - Option to deprecate individual fields, params, and types
11
+
12
+ ### Fixed
13
+
14
+ - Fixed nullable enum fields raising for null input
15
+ - Fixed auto-loading of return types
16
+ - Fixed console spam when inspecting declarations
17
+ - Fixed resolver method not being used when rendering a Hash
18
+ - Fixed the ErrorsType template
19
+ - Many fixes for OpenAPI export
20
+ - Fixed export of parameters for http methods without body
21
+ - Fixed export for PageType
22
+ - Fixed export for arrays of UUIDs, Dates, and Times
23
+ - Fixed export YML keys for namespaced controllers
24
+ - Reference plain types for repeated flat return types
25
+ - Made order of paths, verbs, responses and schemas deterministic
26
+
27
+ ## [1.1.0] - 2024-11-16
28
+
29
+ ### Added
30
+
31
+ - Response validation refined
32
+
33
+ ### Fixed
34
+
35
+ - Bugfix for openapi export
36
+
37
+ ## [1.0.0] - 2024-11-14
4
38
 
5
39
  - 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
@@ -136,16 +131,18 @@ Taro.config.validate_responses = false
136
131
  The following type names are available by default and can be used as `type:`/`array_of:`/`page_of:` arguments:
137
132
 
138
133
  - `'Boolean'` - accepts and renders `true` or `false`
134
+ - `'Date'` - accepts and renders a date string in ISO8601 format
135
+ - `'DateTime'` - an alias for `'Time'`
139
136
  - `'Float'`
140
137
  - `'FreeForm'` - accepts and renders any JSON-serializable object, use with care
141
138
  - `'Integer'`
142
- - `'NoContentType'` - renders an empty object, for use with `status: :no_content`
139
+ - `'NoContent'` - renders an empty object, for use with `status: :no_content`
143
140
  - `'String'`
144
- - `'Timestamp'` - renders a `Time` as unix timestamp integer and turns into incoming integers into a `Time`
145
- - `'UUID'` - accepts and renders UUIDs
146
- - `'Date'` - accepts and renders a date string in ISO8601 format
147
141
  - `'Time'` - accepts and renders a time string in ISO8601 format
148
- - `'DateTime'` - an alias for `'Time'`
142
+ - `'Timestamp'` - renders a `Time` as unix timestamp integer and turns incoming integers into a `Time`
143
+ - `'UUID'` - accepts and renders UUIDs
144
+
145
+ Also, when using the generator, `ErrorsType` and `ErrorDetailsType` are generated as a starting point for unified error presentation. `ErrorsType` can render invalid `ActiveRecord` instances, `ActiveModel::Errors` and other data structures.
149
146
 
150
147
  ### Enums
151
148
 
@@ -173,7 +170,7 @@ end
173
170
 
174
171
  ### FAQ
175
172
 
176
- #### How to avoid repeating common error declarations?
173
+ #### How do I avoid repeating common error declarations?
177
174
 
178
175
  Hook into the DSL in your base controller(s):
179
176
 
@@ -202,7 +199,7 @@ class AuthenticatedApiController < ApiBaseController
202
199
  end
203
200
  ```
204
201
 
205
- #### How to use context in my types?
202
+ #### How do I use context in my types?
206
203
 
207
204
  Use [ActiveSupport::CurrentAttributes](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html).
208
205
 
@@ -216,13 +213,80 @@ class BikeType < ObjectType
216
213
  end
217
214
  ```
218
215
 
216
+ #### How do I migrate from apipie-rails?
217
+
218
+ First of all, if you don't need a better OpenAPI export, or better support for hashes and arrays, it might not be worth it.
219
+
220
+ If you do:
221
+
222
+ - note that `taro` currently only supports the latest OpenAPI standard (instead of v2 like `apipie-rails`)
223
+ - extract complex param declarations into InputTypes
224
+ - extract complex response declarations into ObjectTypes
225
+ - replace `required: true` with `null: false` and `required: false` with `null: true`
226
+
227
+ For a step-by-step migration, you might want to make `taro` use a different DSL then `apipie`:
228
+
229
+ ```ruby
230
+ # config/initializers/taro.rb
231
+ %i[api param returns].each do |m|
232
+ Taro::Rails::DSL.alias_method("taro_#{m}", m) # `taro_api` etc.
233
+ Taro::Rails::DSL.define_method(m) { |*a, **k, &b| super(*a, **k, &b) }
234
+ end
235
+ ```
236
+
237
+ #### How do I keep lengthy API descriptions out of my controller?
238
+
239
+ ```ruby
240
+ module BikeUpdateDesc
241
+ extend ActiveSupport::Concern
242
+
243
+ included do
244
+ api 'Update a bike', description: 'Long description', tags: ['Bikes']
245
+ # lots of params and returns ...
246
+ end
247
+ end
248
+
249
+ class BikesController < ApplicationController
250
+ include BikeUpdateDesc
251
+ def update # ...
252
+ end
253
+ ```
254
+
219
255
  #### Why do I have to use type name strings instead of the type constants?
220
256
 
221
257
  Why e.g. `field :id, type: 'UUID'` instead of `field :id, type: UUID`?
222
258
 
223
259
  The purpose of this is to reduce unnecessary autoloading of the whole type dependency tree in dev and test environments.
224
260
 
225
- This already works fo type classes they don't trigger loading of referenced types unless used. The API declarations in controller classes still trigger auto-loading for now, but we aim to improve this in the future.
261
+ #### Can I define my own derived types like `page_of` or `array_of`?
262
+
263
+ Yes.
264
+
265
+ ```ruby
266
+ # Implement ::derive_from in your custom type.
267
+ class PreviewType < Taro::Types::Scalar::StringType
268
+ singleton_class.attr_reader :type_to_preview
269
+
270
+ def self.derive_from(other_type)
271
+ self.type_to_preview = other_type
272
+ end
273
+
274
+ def coerce_response
275
+ type_to_preview.new(object).coerce_response.to_s.truncate(100)
276
+ end
277
+ end
278
+
279
+ # Make it available in the DSL, e.g. in an initializer.
280
+ Taro::Types::BaseType.define_derived_type :preview, 'PreviewType'
281
+
282
+ # Usage:
283
+ class MyController < ApplicationController
284
+ returns code: :ok, preview_of: 'BikeType'
285
+ def show
286
+ render json: BikeType.preview.render(Bike.find(params[:id]))
287
+ end
288
+ end
289
+ ```
226
290
 
227
291
  ## Possible future features
228
292
 
@@ -232,7 +296,6 @@ This already works fo type classes – they don't trigger loading of referenced
232
296
  - sum types
233
297
  - api doc rendering based on export (e.g. rails engine with web ui)
234
298
  - [query logs metadata](https://github.com/rmosolgo/graphql-ruby/blob/dcaaed1cea47394fad61fceadf291ff3cb5f2932/lib/generators/graphql/install_generator.rb#L48-L52)
235
- - deprecation feature
236
299
  - maybe make `type:` optional for path params as they're always strings anyway
237
300
  - various openapi features
238
301
  - non-JSON content types (e.g. for file uploads)
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.sub(/\n(?=\S)/, ' ')
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
@@ -11,15 +11,16 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
11
11
  def call(declarations:, title:, version:)
12
12
  @result = { openapi: '3.1.0', info: { title:, version: } }
13
13
  paths = export_paths(declarations)
14
- @result[:paths] = paths if paths.any?
15
- @result[:components] = { schemas: } if schemas.any?
14
+ @result[:paths] = paths.sort.to_h if paths.any?
15
+ @result[:components] = { schemas: schemas.sort.to_h } if schemas.any?
16
16
  self
17
17
  end
18
18
 
19
19
  def export_paths(declarations)
20
- declarations.each_with_object({}) do |declaration, paths|
20
+ declarations.sort.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
@@ -30,30 +31,51 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
30
31
  description: declaration.desc,
31
32
  summary: declaration.summary,
32
33
  tags: declaration.tags,
33
- parameters: path_parameters(declaration, route),
34
+ parameters: route_parameters(declaration, route),
34
35
  requestBody: request_body(declaration, route),
35
36
  responses: responses(declaration),
36
37
  }.compact,
37
38
  }
38
39
  end
39
40
 
41
+ def route_parameters(declaration, route)
42
+ path_parameters(declaration, route) + query_parameters(declaration, route)
43
+ end
44
+
40
45
  def path_parameters(declaration, route)
41
46
  route.path_params.map do |param_name|
42
47
  param_field = declaration.params.fields[param_name] || raise(<<~MSG)
43
48
  Declaration missing for path param #{param_name} of route #{route.endpoint}
44
49
  MSG
45
50
 
46
- {
47
- name: param_field.name,
48
- in: 'path',
49
- description: param_field.desc,
50
- required: true, # path params are always required in rails
51
- schema: { type: param_field.openapi_type },
52
- }.compact
51
+ # path params are always required in rails
52
+ export_parameter(param_field).merge(in: 'path', required: true)
53
53
  end
54
54
  end
55
55
 
56
+ def query_parameters(declaration, route)
57
+ return [] if route.can_have_request_body?
58
+
59
+ declaration.params.fields.filter_map do |name, param_field|
60
+ next if route.path_params.include?(name)
61
+
62
+ export_parameter(param_field).merge(in: 'query')
63
+ end
64
+ end
65
+
66
+ def export_parameter(field)
67
+ {
68
+ name: field.name,
69
+ deprecated: field.deprecated,
70
+ description: field.desc,
71
+ required: !field.null,
72
+ schema: { type: field.openapi_type },
73
+ }.compact
74
+ end
75
+
56
76
  def request_body(declaration, route)
77
+ return unless route.can_have_request_body?
78
+
57
79
  params = declaration.params
58
80
  body_param_fields = params.fields.reject do |name, _field|
59
81
  route.path_params.include?(name)
@@ -81,7 +103,7 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
81
103
  end
82
104
 
83
105
  def responses(declaration)
84
- declaration.returns.to_h do |code, type|
106
+ declaration.returns.sort.to_h do |code, type|
85
107
  [
86
108
  code.to_s,
87
109
  {
@@ -114,23 +136,29 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
114
136
  # as it puts props like format together with the main type.
115
137
  # https://github.com/OAI/OpenAPI-Specification/issues/3148
116
138
  base = { oneOf: [base, { type: 'null' }] } if field.null
117
- base[:description] = field.desc if field.desc
118
- base[:default] = field.default if field.default_specified?
119
- base[:enum] = field.enum if field.enum
120
- base
139
+ base.merge(field_metadata(field))
121
140
  end
122
141
 
123
142
  def export_complex_field_ref(field)
124
143
  ref = extract_component_ref(field.type)
144
+ return ref if field_metadata(field).empty? && !field.null
145
+
125
146
  if field.null
126
147
  # RE nullable: https://stackoverflow.com/a/70658334
127
- { description: field.desc, oneOf: [ref, { type: 'null' }] }.compact
128
- elsif field.desc
148
+ { oneOf: [ref, { type: 'null' }] }
149
+ else # i.e. with metadata such as description or deprecated
129
150
  # https://github.com/OAI/OpenAPI-Specification/issues/2033
130
- { description: field.desc, allOf: [ref] }
131
- else
132
- ref
133
- end
151
+ { allOf: [ref] }
152
+ end.merge(field_metadata(field))
153
+ end
154
+
155
+ def field_metadata(field)
156
+ meta = {}
157
+ meta[:description] = field.desc if field.desc
158
+ meta[:deprecated] = field.deprecated unless field.deprecated.nil?
159
+ meta[:default] = field.default if field.default_specified?
160
+ meta[:enum] = field.enum if field.enum
161
+ meta
134
162
  end
135
163
 
136
164
  def extract_component_ref(type)
@@ -155,6 +183,7 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
155
183
  required = type.fields.values.reject(&:null).map(&:name)
156
184
  {
157
185
  type: type.openapi_type,
186
+ deprecated: type.deprecated,
158
187
  description: type.desc,
159
188
  required: (required if required.any?),
160
189
  properties: type.fields.to_h { |name, f| [name, export_field(f)] },
@@ -165,6 +194,7 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
165
194
  def enum_type_details(enum)
166
195
  {
167
196
  type: enum.item_type.openapi_type,
197
+ deprecated: enum.deprecated,
168
198
  description: enum.desc,
169
199
  enum: enum.values,
170
200
  }.compact
@@ -173,6 +203,7 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
173
203
  def list_type_details(list)
174
204
  {
175
205
  type: 'array',
206
+ deprecated: list.deprecated,
176
207
  description: list.desc,
177
208
  items: export_type(list.item_type),
178
209
  }.compact
@@ -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
@@ -1,9 +1,9 @@
1
1
  class Taro::Rails::Declaration
2
- attr_reader :desc, :summary, :params, :returns, :return_descriptions, :return_nestings, :routes, :tags
2
+ attr_reader :desc, :summary, :params, :return_defs, :return_descriptions, :return_nestings, :routes, :tags
3
3
 
4
4
  def initialize
5
5
  @params = Class.new(Taro::Types::InputType)
6
- @returns = {}
6
+ @return_defs = {}
7
7
  @return_descriptions = {}
8
8
  @return_nestings = {}
9
9
  end
@@ -24,8 +24,11 @@ class Taro::Rails::Declaration
24
24
  status = self.class.coerce_status_to_int(code)
25
25
  raise_if_already_declared(status)
26
26
 
27
+ kwargs[:nesting] = nesting
28
+ check_return_kwargs(kwargs)
29
+
27
30
  kwargs[:defined_at] = caller_locations(1..2)[1]
28
- returns[status] = return_type_from(nesting, **kwargs)
31
+ return_defs[status] = kwargs
29
32
 
30
33
  # response desc is required in openapi 3 – fall back to status code
31
34
  return_descriptions[status] = desc || code.to_s
@@ -34,14 +37,19 @@ class Taro::Rails::Declaration
34
37
  return_nestings[status] = nesting if nesting
35
38
  end
36
39
 
40
+ # Return types are evaluated lazily to avoid unnecessary autoloading
41
+ # of all types in dev/test envs.
42
+ def returns
43
+ @returns ||= evaluate_return_defs
44
+ end
45
+
37
46
  def raise_if_already_declared(status)
38
- returns[status] &&
47
+ return_defs[status] &&
39
48
  raise(Taro::ArgumentError, "response for status #{status} already declared")
40
49
  end
41
50
 
42
51
  def parse_params(rails_params)
43
- hash = params.new(rails_params.to_unsafe_h).coerce_input
44
- hash
52
+ params.new(rails_params.to_unsafe_h).coerce_input
45
53
  end
46
54
 
47
55
  def finalize(controller_class:, action_name:)
@@ -67,10 +75,15 @@ class Taro::Rails::Declaration
67
75
  # TODO: these change when the controller class is renamed.
68
76
  # We might need a way to set `base`. Perhaps as a kwarg to `::api`?
69
77
  def add_openapi_names(controller_class:, action_name:)
70
- base = "#{controller_class.name.chomp('Controller').sub('::', '_')}_#{action_name}"
78
+ base = "#{controller_class.name.chomp('Controller').gsub('::', '_')}_#{action_name}"
71
79
  params.openapi_name = "#{base}_Input"
80
+ params.define_singleton_method(:name) { openapi_name }
81
+
72
82
  returns.each do |status, return_type|
83
+ next if return_type.openapi_name? # only set for ad-hoc / nested return types
84
+
73
85
  return_type.openapi_name = "#{base}_#{status}_Response"
86
+ return_type.define_singleton_method(:name) { openapi_name }
74
87
  end
75
88
  end
76
89
 
@@ -86,10 +99,34 @@ class Taro::Rails::Declaration
86
99
 
87
100
  private
88
101
 
89
- def return_type_from(nesting, **kwargs)
102
+ def check_return_kwargs(kwargs)
103
+ # For nested returns, evaluate_return_def calls ::field, which validates
104
+ # field options, but does not trigger type autoloading.
105
+ return evaluate_return_def(**kwargs) if kwargs[:nesting]
106
+
107
+ if kwargs.key?(:null)
108
+ raise Taro::ArgumentError, <<~MSG
109
+ `null:` is not supported for top-level returns. If you want a nullable return
110
+ value, nest it, e.g. `returns :str, type: 'String', null: true`.
111
+ MSG
112
+ end
113
+
114
+ bad_keys = kwargs.keys - (Taro::Types::Coercion.keys + %i[code desc nesting])
115
+ return if bad_keys.empty?
116
+
117
+ raise Taro::ArgumentError, "Invalid `returns` options: #{bad_keys.join(', ')}"
118
+ end
119
+
120
+ def evaluate_return_defs
121
+ return_defs.transform_values { |defi| evaluate_return_def(**defi) }
122
+ end
123
+
124
+ def evaluate_return_def(nesting:, **kwargs)
90
125
  if nesting
91
126
  # ad-hoc return type, requiring the actual return type to be nested
92
- Class.new(Taro::Types::ObjectType).tap { |t| t.field(nesting, **kwargs) }
127
+ Class.new(Taro::Types::ObjectType).tap do |type|
128
+ type.field(nesting, null: false, **kwargs)
129
+ end
93
130
  else
94
131
  Taro::Types::Coercion.call(kwargs)
95
132
  end
@@ -98,4 +135,8 @@ class Taro::Rails::Declaration
98
135
  def raise_missing_route(controller_class, action_name)
99
136
  raise(Taro::ArgumentError, "No route found for #{controller_class}##{action_name}")
100
137
  end
138
+
139
+ def <=>(other)
140
+ params.openapi_name <=> other.params.openapi_name
141
+ end
101
142
  end
@@ -12,7 +12,7 @@ class Taro::Rails::Generators::InstallGenerator < ::Rails::Generators::Base
12
12
  def create_type_files
13
13
  Dir["#{self.class.source_root}/**/*.erb"].each do |tmpl|
14
14
  dest_dir = options[:dir].chomp('/')
15
- copy_file tmpl, "#{dest_dir}/#{File.basename(tmpl).sub('erb', 'rb')}"
15
+ template tmpl, "#{dest_dir}/#{File.basename(tmpl).sub('erb', 'rb')}"
16
16
  end
17
17
  end
18
18
  # :nocov:
@@ -11,15 +11,20 @@ class ErrorsType < Taro::Types::ListType
11
11
  end
12
12
 
13
13
  def coerce_response
14
- case object.class.name
15
- when 'ActiveRecord::Base'
16
- super(object.errors.errors)
17
- when 'ActiveModel::Errors'
18
- super(object.errors)
19
- when 'Hash', 'Interactor::Context'
20
- super(object[:errors])
21
- else # e.g. Array
22
- super(object)
23
- end
14
+ list =
15
+ case object<%- if defined?(ActiveRecord) %>
16
+ when ActiveModel::Errors
17
+ object.errors
18
+ when ActiveRecord::Base
19
+ object.errors.errors<%- end %>
20
+ when Array
21
+ object
22
+ when Hash<%- if defined?(Interactor::Context) %>, Interactor::Context<%- end %>
23
+ object.to_h.fetch(:errors)
24
+ else
25
+ response_error("must be an Enumerable or an object with errors")
26
+ end
27
+
28
+ list.map { |el| self.class.item_type.new(el).coerce_response }
24
29
  end
25
30
  end
@@ -26,4 +26,12 @@ Taro::Rails::NormalizedRoute = Data.define(:rails_route) do
26
26
  controller, action = rails_route.requirements.values_at(:controller, :action)
27
27
  "#{controller}##{action}"
28
28
  end
29
+
30
+ def can_have_request_body?
31
+ %w[patch post put].include?(verb)
32
+ end
33
+
34
+ def inspect
35
+ %(#<#{self.class} "#{verb} #{openapi_path}">)
36
+ end
29
37
  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