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