taro 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +75 -7
  4. data/lib/taro/errors.rb +1 -1
  5. data/lib/taro/export/open_api_v3.rb +52 -22
  6. data/lib/taro/rails/declaration.rb +44 -20
  7. data/lib/taro/rails/generators/install_generator.rb +1 -1
  8. data/lib/taro/rails/generators/templates/errors_type.erb +15 -10
  9. data/lib/taro/rails/normalized_route.rb +8 -0
  10. data/lib/taro/rails/tasks/export.rake +5 -1
  11. data/lib/taro/types/base_type.rb +2 -0
  12. data/lib/taro/types/coercion.rb +24 -14
  13. data/lib/taro/types/field.rb +8 -16
  14. data/lib/taro/types/field_validation.rb +1 -1
  15. data/lib/taro/types/list_type.rb +4 -6
  16. data/lib/taro/types/object_types/free_form_type.rb +1 -0
  17. data/lib/taro/types/object_types/no_content_type.rb +1 -0
  18. data/lib/taro/types/object_types/page_info_type.rb +2 -0
  19. data/lib/taro/types/object_types/page_type.rb +15 -25
  20. data/lib/taro/types/scalar/iso8601_date_type.rb +1 -0
  21. data/lib/taro/types/scalar/iso8601_datetime_type.rb +1 -0
  22. data/lib/taro/types/scalar/timestamp_type.rb +1 -0
  23. data/lib/taro/types/scalar/uuid_v4_type.rb +1 -0
  24. data/lib/taro/types/shared/deprecation.rb +3 -0
  25. data/lib/taro/types/shared/derived_types.rb +27 -0
  26. data/lib/taro/types/shared/errors.rb +3 -1
  27. data/lib/taro/types/shared/fields.rb +6 -5
  28. data/lib/taro/types/shared/item_type.rb +1 -0
  29. data/lib/taro/types/shared/object_coercion.rb +13 -0
  30. data/lib/taro/types/shared/openapi_name.rb +8 -6
  31. data/lib/taro/types/shared/rendering.rb +4 -4
  32. data/lib/taro/version.rb +1 -1
  33. data/tasks/benchmark.rake +1 -1
  34. metadata +6 -5
  35. 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: 36e27c067e70b65d503a3c78b61e9efc05d8995f9927edfaf6f66ff4fae70b25
4
- data.tar.gz: 131fc3d1bea432320046c4e5c220dff0a3ead87d8a2f3d12310cdd847b2e50c1
3
+ metadata.gz: 5007f07dcb3230a1a45011138c19cc0f233e0a0e0fa81d64d554ee3d5cc4a82e
4
+ data.tar.gz: 99dde57ec71bb2724a29005e644a0bb23d642df11fe4555bd9203340169814df
5
5
  SHA512:
6
- metadata.gz: 9d44a2c33be8175c6b59b1426921fd339c4e4125bf7fa076da27f4b9c17bcf1efb69aa0233c8e91f16804b086f250b37fddb0e6e6dce2debbf0349fc96ac832d
7
- data.tar.gz: 59d1a63e68b5560ee7cf0243ff3ccf6684376249745727e6695576821285e8dc1d64018a5f8e8a962f9d7dcea74c1e6bc9d27dd664662d327c257d81505b6c23
6
+ metadata.gz: abb5fb481da01a4a50b19e891c3373b42372c97993788ed24c51b07146e087e25d040bc4ea077606a14013633b30f6254f6ced2e939ba9b039b341d95f918211
7
+ data.tar.gz: b5dd6c2222598ad3d8ee64c64fd0fc6c40299c3f071b08317d17aa29c3f786924ae4b064f587b1e548ba102e0cc802b8a5adf7ccbadf033795974bfdaae5329a
data/CHANGELOG.md CHANGED
@@ -1,8 +1,37 @@
1
1
  ## [Unreleased]
2
2
 
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
+
3
27
  ## [1.1.0] - 2024-11-16
4
28
 
29
+ ### Added
30
+
5
31
  - Response validation refined
32
+
33
+ ### Fixed
34
+
6
35
  - Bugfix for openapi export
7
36
 
8
37
  ## [1.0.0] - 2024-11-14
data/README.md CHANGED
@@ -131,16 +131,18 @@ Taro.config.validate_responses = false
131
131
  The following type names are available by default and can be used as `type:`/`array_of:`/`page_of:` arguments:
132
132
 
133
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'`
134
136
  - `'Float'`
135
137
  - `'FreeForm'` - accepts and renders any JSON-serializable object, use with care
136
138
  - `'Integer'`
137
139
  - `'NoContent'` - renders an empty object, for use with `status: :no_content`
138
140
  - `'String'`
141
+ - `'Time'` - accepts and renders a time string in ISO8601 format
139
142
  - `'Timestamp'` - renders a `Time` as unix timestamp integer and turns incoming integers into a `Time`
140
143
  - `'UUID'` - accepts and renders UUIDs
141
- - `'Date'` - accepts and renders a date string in ISO8601 format
142
- - `'Time'` - accepts and renders a time string in ISO8601 format
143
- - `'DateTime'` - an alias for `'Time'`
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.
144
146
 
145
147
  ### Enums
146
148
 
@@ -168,7 +170,7 @@ end
168
170
 
169
171
  ### FAQ
170
172
 
171
- #### How to avoid repeating common error declarations?
173
+ #### How do I avoid repeating common error declarations?
172
174
 
173
175
  Hook into the DSL in your base controller(s):
174
176
 
@@ -197,7 +199,7 @@ class AuthenticatedApiController < ApiBaseController
197
199
  end
198
200
  ```
199
201
 
200
- #### How to use context in my types?
202
+ #### How do I use context in my types?
201
203
 
202
204
  Use [ActiveSupport::CurrentAttributes](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html).
203
205
 
@@ -211,13 +213,80 @@ class BikeType < ObjectType
211
213
  end
212
214
  ```
213
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
+
214
255
  #### Why do I have to use type name strings instead of the type constants?
215
256
 
216
257
  Why e.g. `field :id, type: 'UUID'` instead of `field :id, type: UUID`?
217
258
 
218
259
  The purpose of this is to reduce unnecessary autoloading of the whole type dependency tree in dev and test environments.
219
260
 
220
- 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
+ ```
221
290
 
222
291
  ## Possible future features
223
292
 
@@ -227,7 +296,6 @@ This already works fo type classes – they don't trigger loading of referenced
227
296
  - sum types
228
297
  - api doc rendering based on export (e.g. rails engine with web ui)
229
298
  - [query logs metadata](https://github.com/rmosolgo/graphql-ruby/blob/dcaaed1cea47394fad61fceadf291ff3cb5f2932/lib/generators/graphql/install_generator.rb#L48-L52)
230
- - deprecation feature
231
299
  - maybe make `type:` optional for path params as they're always strings anyway
232
300
  - various openapi features
233
301
  - non-JSON content types (e.g. for file uploads)
data/lib/taro/errors.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  class Taro::Error < StandardError
2
2
  def message
3
3
  # clean up newlines introduced when setting the message with a heredoc
4
- super.chomp.tr("\n", ' ')
4
+ super.chomp.sub(/\n(?=\S)/, ' ')
5
5
  end
6
6
  end
7
7
 
@@ -11,13 +11,13 @@ 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
22
  paths[route.openapi_path] ||= {}
23
23
  paths[route.openapi_path].merge! export_route(route, declaration)
@@ -31,30 +31,51 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
31
31
  description: declaration.desc,
32
32
  summary: declaration.summary,
33
33
  tags: declaration.tags,
34
- parameters: path_parameters(declaration, route),
34
+ parameters: route_parameters(declaration, route),
35
35
  requestBody: request_body(declaration, route),
36
36
  responses: responses(declaration),
37
37
  }.compact,
38
38
  }
39
39
  end
40
40
 
41
+ def route_parameters(declaration, route)
42
+ path_parameters(declaration, route) + query_parameters(declaration, route)
43
+ end
44
+
41
45
  def path_parameters(declaration, route)
42
46
  route.path_params.map do |param_name|
43
47
  param_field = declaration.params.fields[param_name] || raise(<<~MSG)
44
48
  Declaration missing for path param #{param_name} of route #{route.endpoint}
45
49
  MSG
46
50
 
47
- {
48
- name: param_field.name,
49
- in: 'path',
50
- description: param_field.desc,
51
- required: true, # path params are always required in rails
52
- schema: { type: param_field.openapi_type },
53
- }.compact
51
+ # path params are always required in rails
52
+ export_parameter(param_field).merge(in: 'path', required: true)
54
53
  end
55
54
  end
56
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
+
57
76
  def request_body(declaration, route)
77
+ return unless route.can_have_request_body?
78
+
58
79
  params = declaration.params
59
80
  body_param_fields = params.fields.reject do |name, _field|
60
81
  route.path_params.include?(name)
@@ -82,7 +103,7 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
82
103
  end
83
104
 
84
105
  def responses(declaration)
85
- declaration.returns.to_h do |code, type|
106
+ declaration.returns.sort.to_h do |code, type|
86
107
  [
87
108
  code.to_s,
88
109
  {
@@ -115,23 +136,29 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
115
136
  # as it puts props like format together with the main type.
116
137
  # https://github.com/OAI/OpenAPI-Specification/issues/3148
117
138
  base = { oneOf: [base, { type: 'null' }] } if field.null
118
- base[:description] = field.desc if field.desc
119
- base[:default] = field.default if field.default_specified?
120
- base[:enum] = field.enum if field.enum
121
- base
139
+ base.merge(field_metadata(field))
122
140
  end
123
141
 
124
142
  def export_complex_field_ref(field)
125
143
  ref = extract_component_ref(field.type)
144
+ return ref if field_metadata(field).empty? && !field.null
145
+
126
146
  if field.null
127
147
  # RE nullable: https://stackoverflow.com/a/70658334
128
- { description: field.desc, oneOf: [ref, { type: 'null' }] }.compact
129
- elsif field.desc
148
+ { oneOf: [ref, { type: 'null' }] }
149
+ else # i.e. with metadata such as description or deprecated
130
150
  # https://github.com/OAI/OpenAPI-Specification/issues/2033
131
- { description: field.desc, allOf: [ref] }
132
- else
133
- ref
134
- 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
135
162
  end
136
163
 
137
164
  def extract_component_ref(type)
@@ -156,6 +183,7 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
156
183
  required = type.fields.values.reject(&:null).map(&:name)
157
184
  {
158
185
  type: type.openapi_type,
186
+ deprecated: type.deprecated,
159
187
  description: type.desc,
160
188
  required: (required if required.any?),
161
189
  properties: type.fields.to_h { |name, f| [name, export_field(f)] },
@@ -166,6 +194,7 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
166
194
  def enum_type_details(enum)
167
195
  {
168
196
  type: enum.item_type.openapi_type,
197
+ deprecated: enum.deprecated,
169
198
  description: enum.desc,
170
199
  enum: enum.values,
171
200
  }.compact
@@ -174,6 +203,7 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
174
203
  def list_type_details(list)
175
204
  {
176
205
  type: 'array',
206
+ deprecated: list.deprecated,
177
207
  description: list.desc,
178
208
  items: export_type(list.item_type),
179
209
  }.compact
@@ -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,19 +99,11 @@ class Taro::Rails::Declaration
86
99
 
87
100
  private
88
101
 
89
- def return_type_from(nesting, **kwargs)
90
- if nesting
91
- # ad-hoc return type, requiring the actual return type to be nested
92
- Class.new(Taro::Types::ObjectType).tap do |type|
93
- type.field(nesting, null: false, **kwargs)
94
- end
95
- else
96
- check_return_kwargs(kwargs)
97
- Taro::Types::Coercion.call(kwargs)
98
- end
99
- end
100
-
101
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
+
102
107
  if kwargs.key?(:null)
103
108
  raise Taro::ArgumentError, <<~MSG
104
109
  `null:` is not supported for top-level returns. If you want a nullable return
@@ -106,13 +111,32 @@ class Taro::Rails::Declaration
106
111
  MSG
107
112
  end
108
113
 
109
- bad_keys = kwargs.keys - (Taro::Types::Coercion::KEYS + %i[code defined_at desc])
114
+ bad_keys = kwargs.keys - (Taro::Types::Coercion.keys + %i[code desc nesting])
110
115
  return if bad_keys.empty?
111
116
 
112
117
  raise Taro::ArgumentError, "Invalid `returns` options: #{bad_keys.join(', ')}"
113
118
  end
114
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)
125
+ if nesting
126
+ # ad-hoc return type, requiring the actual return type to be nested
127
+ Class.new(Taro::Types::ObjectType).tap do |type|
128
+ type.field(nesting, null: false, **kwargs)
129
+ end
130
+ else
131
+ Taro::Types::Coercion.call(kwargs)
132
+ end
133
+ end
134
+
115
135
  def raise_missing_route(controller_class, action_name)
116
136
  raise(Taro::ArgumentError, "No route found for #{controller_class}##{action_name}")
117
137
  end
138
+
139
+ def <=>(other)
140
+ params.openapi_name <=> other.params.openapi_name
141
+ end
118
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
@@ -10,6 +10,10 @@ task 'taro:export' => :environment do
10
10
  version: Taro.config.api_version,
11
11
  )
12
12
 
13
- data = export.result.send("to_#{Taro.config.export_format}")
13
+ data = export.send("to_#{Taro.config.export_format}")
14
+
15
+ FileUtils.mkdir_p(File.dirname(Taro.config.export_path))
14
16
  File.write(Taro.config.export_path, data)
17
+
18
+ puts "Exported #{Taro.config.api_name} to #{Taro.config.export_path}"
15
19
  end
@@ -9,6 +9,8 @@
9
9
  Taro::Types::BaseType = Data.define(:object) do
10
10
  require_relative "shared"
11
11
  extend Taro::Types::Shared::AdditionalProperties
12
+ extend Taro::Types::Shared::Deprecation
13
+ extend Taro::Types::Shared::DerivedTypes
12
14
  extend Taro::Types::Shared::Description
13
15
  extend Taro::Types::Shared::OpenAPIName
14
16
  extend Taro::Types::Shared::OpenAPIType
@@ -1,42 +1,52 @@
1
1
  module Taro::Types::Coercion
2
- KEYS = %i[type array_of page_of].freeze
3
-
4
2
  class << self
5
3
  def call(arg)
6
4
  validate_hash(arg)
7
5
  from_hash(arg)
8
6
  end
9
7
 
8
+ # Coercion keys can be expanded by the DerivedTypes module.
9
+ def keys
10
+ @keys ||= %i[type]
11
+ end
12
+
13
+ def derived_suffix
14
+ '_of'
15
+ end
16
+
10
17
  private
11
18
 
12
19
  def validate_hash(arg)
13
20
  arg.is_a?(Hash) || raise(Taro::ArgumentError, <<~MSG)
14
- Type coercion argument must be a Hash, got: #{arg.inspect} (#{arg.class})
21
+ Type coercion argument must be a Hash, got: #{arg.class}
15
22
  MSG
16
23
 
17
- types = arg.slice(*KEYS)
24
+ types = arg.slice(*keys)
18
25
  types.size == 1 || raise(Taro::ArgumentError, <<~MSG)
19
- Exactly one of type, array_of, or page_of must be given, got: #{types}
26
+ Exactly one of #{keys.join(', ')} must be given, got: #{types}
20
27
  MSG
21
28
  end
22
29
 
23
30
  def from_hash(hash)
24
- if hash[:type]
25
- from_string(hash[:type])
26
- elsif (inner_type = hash[:array_of])
27
- from_string(inner_type).array
28
- elsif (inner_type = hash[:page_of])
29
- from_string(inner_type).page
30
- else
31
- raise NotImplementedError, 'Unsupported type coercion'
31
+ keys.each do |key|
32
+ next unless (value = hash[key])
33
+
34
+ # e.g. `returns type: 'MyType'` -> MyType
35
+ return from_string(value) if key == :type
36
+
37
+ # DerivedTypes
38
+ # e.g. `returns array_of: 'MyType'` -> MyType.array
39
+ return from_string(value).send(key.to_s.chomp(derived_suffix))
32
40
  end
41
+
42
+ raise NotImplementedError, "Unsupported type coercion #{hash}"
33
43
  end
34
44
 
35
45
  def from_string(arg)
36
46
  shortcuts[arg] || from_class(Object.const_get(arg.to_s))
37
47
  rescue NameError
38
48
  raise Taro::ArgumentError, <<~MSG
39
- Unsupported type: #{arg}. It should be a type-class name
49
+ No such type: #{arg}. It should be a type-class name
40
50
  or one of #{shortcuts.keys.map(&:inspect).join(', ')}.
41
51
  MSG
42
52
  end
@@ -1,11 +1,11 @@
1
1
  require_relative 'field_validation'
2
2
 
3
- Taro::Types::Field = Data.define(:name, :type, :null, :method, :default, :enum, :defined_at, :desc) do
3
+ Taro::Types::Field = Data.define(:name, :type, :null, :method, :default, :enum, :defined_at, :desc, :deprecated) do
4
4
  include Taro::Types::FieldValidation
5
5
 
6
- def initialize(name:, type:, null:, method: name, default: :none, enum: nil, defined_at: nil, desc: nil)
6
+ def initialize(name:, type:, null:, method: name, default: :none, enum: nil, defined_at: nil, desc: nil, deprecated: nil)
7
7
  enum = coerce_to_enum(enum)
8
- super(name:, type:, null:, method:, default:, enum:, defined_at:, desc:)
8
+ super(name:, type:, null:, method:, default:, enum:, defined_at:, desc:, deprecated:)
9
9
  end
10
10
 
11
11
  def value_for_input(object)
@@ -40,14 +40,15 @@ Taro::Types::Field = Data.define(:name, :type, :null, :method, :default, :enum,
40
40
  end
41
41
 
42
42
  def retrieve_response_value(object, context, object_is_hash)
43
- if object_is_hash
44
- retrieve_hash_value(object)
45
- elsif context&.resolve?(method)
43
+ if context&.resolve?(method)
46
44
  context.public_send(method)
45
+ elsif object_is_hash
46
+ retrieve_hash_value(object)
47
47
  elsif object.respond_to?(method, true)
48
48
  object.public_send(method)
49
49
  else
50
- raise_response_coercion_error(object)
50
+ # Note that the ObjectCoercion module rescues this and adds context.
51
+ raise Taro::ResponseError, "No such method or resolver `:#{method}`."
51
52
  end
52
53
  end
53
54
 
@@ -65,14 +66,5 @@ Taro::Types::Field = Data.define(:name, :type, :null, :method, :default, :enum,
65
66
 
66
67
  type_obj = type.new(value)
67
68
  from_input ? type_obj.coerce_input : type_obj.coerce_response
68
- rescue Taro::Error => e
69
- raise e.class, "#{e.message}, after using method/key `:#{method}` to resolve field `#{name}`"
70
- end
71
-
72
- def raise_response_coercion_error(object)
73
- raise Taro::ResponseError, <<~MSG
74
- Failed to coerce value #{object.inspect} for field `#{name}` using method/key `:#{method}`.
75
- It is not a valid #{type} value.
76
- MSG
77
69
  end
78
70
  end
@@ -18,7 +18,7 @@ module Taro::Types::FieldValidation
18
18
  end
19
19
 
20
20
  def validate_enum_inclusion(value, for_input)
21
- return if enum.nil? || enum.include?(value)
21
+ return if enum.nil? || null && value.nil? || enum.include?(value)
22
22
 
23
23
  raise for_input ? Taro::InputError : Taro::ResponseError, <<~MSG
24
24
  Field #{name} has an invalid value #{value.inspect} (expected one of #{enum.inspect})
@@ -2,7 +2,6 @@
2
2
  # Unlike other types, this one should not be manually inherited from,
3
3
  # but is used indirectly via `array_of: SomeType`.
4
4
  class Taro::Types::ListType < Taro::Types::BaseType
5
- extend Taro::Types::Shared::DerivableType
6
5
  extend Taro::Types::Shared::ItemType
7
6
 
8
7
  self.openapi_type = :array
@@ -20,11 +19,10 @@ class Taro::Types::ListType < Taro::Types::BaseType
20
19
  item_type = self.class.item_type
21
20
  object.map { |el| item_type.new(el).coerce_response }
22
21
  end
23
- end
24
22
 
25
- # add shortcut to other types
26
- class Taro::Types::BaseType
27
- def self.array
28
- Taro::Types::ListType.for(self)
23
+ def self.default_openapi_name
24
+ "#{item_type.openapi_name}_List"
29
25
  end
26
+
27
+ define_derived_type :array, 'Taro::Types::ListType'
30
28
  end
@@ -1,6 +1,7 @@
1
1
  class Taro::Types::ObjectTypes::FreeFormType < Taro::Types::ObjectType
2
2
  self.desc = 'An arbitrary, unvalidated Hash or JSON object. Use with care.'
3
3
  self.additional_properties = true
4
+ self.openapi_name = 'FreeForm'
4
5
 
5
6
  def coerce_input
6
7
  object.is_a?(Hash) && object || input_error('must be a Hash')
@@ -1,5 +1,6 @@
1
1
  class Taro::Types::ObjectTypes::NoContentType < Taro::Types::ObjectType
2
2
  self.desc = 'An empty response'
3
+ self.openapi_name = 'NoContent'
3
4
 
4
5
  # render takes no arguments in this case
5
6
  def self.render
@@ -1,4 +1,6 @@
1
1
  class Taro::Types::ObjectTypes::PageInfoType < Taro::Types::ObjectType
2
+ self.openapi_name = 'PageInfo'
3
+
2
4
  field :has_previous_page, type: 'Boolean', null: false, desc: 'Whether there is a previous page of results'
3
5
  field :has_next_page, type: 'Boolean', null: false, desc: 'Whether there is another page of results'
4
6
  field :start_cursor, type: 'String', null: true, desc: 'The first cursor in the current page of results (null if zero results)'
@@ -4,42 +4,32 @@
4
4
  #
5
5
  # The gem rails_cursor_pagination must be installed to use this.
6
6
  #
7
- class Taro::Types::ObjectTypes::PageType < Taro::Types::BaseType
8
- extend Taro::Types::Shared::DerivableType
7
+ class Taro::Types::ObjectTypes::PageType < Taro::Types::ObjectType
9
8
  extend Taro::Types::Shared::ItemType
10
9
 
10
+ def self.derive_from(from_type)
11
+ super
12
+ field(:page, array_of: from_type.name, null: false)
13
+ field(:page_info, type: 'Taro::Types::ObjectTypes::PageInfoType', null: false)
14
+ end
15
+
11
16
  def coerce_input
12
17
  input_error 'PageTypes cannot be used as input types'
13
18
  end
14
19
 
15
- def coerce_response(after:, limit: 20, order_by: nil, order: nil)
16
- list = RailsCursorPagination::Paginator.new(
17
- object, limit:, order_by:, order:, after:
20
+ def self.render(relation, after:, limit: 20, order_by: nil, order: nil)
21
+ result = RailsCursorPagination::Paginator.new(
22
+ relation, limit:, order_by:, order:, after:
18
23
  ).fetch
19
- coerce_paginated_list(list)
20
- end
21
24
 
22
- def coerce_paginated_list(list)
23
- item_type = self.class.item_type
24
- items = list[:page].map do |item|
25
- item_type.new(item[:data]).coerce_response
26
- end
25
+ result[:page].map! { |el| el.fetch(:data) }
27
26
 
28
- {
29
- self.class.items_key => items,
30
- page_info: Taro::Types::ObjectTypes::PageInfoType.new(list[:page_info]).coerce_response,
31
- }
27
+ super(result)
32
28
  end
33
29
 
34
- # support overrides, e.g. based on item_type
35
- def self.items_key
36
- :page
30
+ def self.default_openapi_name
31
+ "#{item_type.openapi_name}_Page"
37
32
  end
38
- end
39
33
 
40
- # add shortcut to other types
41
- class Taro::Types::BaseType
42
- def self.page
43
- Taro::Types::ObjectTypes::PageType.for(self)
44
- end
34
+ define_derived_type :page, 'Taro::Types::ObjectTypes::PageType'
45
35
  end
@@ -1,5 +1,6 @@
1
1
  class Taro::Types::Scalar::ISO8601DateType < Taro::Types::ScalarType
2
2
  self.desc = 'Represents a time as Date in ISO8601 format.'
3
+ self.openapi_name = 'ISO8601Date'
3
4
  self.openapi_type = :string
4
5
 
5
6
  PATTERN = /\A\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\z/
@@ -1,5 +1,6 @@
1
1
  class Taro::Types::Scalar::ISO8601DateTimeType < Taro::Types::ScalarType
2
2
  self.desc = 'Represents a time as DateTime in ISO8601 format.'
3
+ self.openapi_name = 'ISO8601DateTime'
3
4
  self.openapi_type = :string
4
5
 
5
6
  PATTERN = /\A\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(Z|[+-](0[0-9]|1[0-4]):[0-5]\d)\z/
@@ -1,5 +1,6 @@
1
1
  class Taro::Types::Scalar::TimestampType < Taro::Types::ScalarType
2
2
  self.desc = 'Represents a time as Time on the server side and UNIX timestamp (integer) on the client side.'
3
+ self.openapi_name = 'Timestamp'
3
4
  self.openapi_type = :integer
4
5
 
5
6
  def coerce_input
@@ -1,5 +1,6 @@
1
1
  class Taro::Types::Scalar::UUIDv4Type < Taro::Types::ScalarType
2
2
  self.desc = "A UUID v4 string"
3
+ self.openapi_name = 'UUIDv4'
3
4
  self.openapi_type = :string
4
5
 
5
6
  PATTERN = /\A\h{8}-?(?:\h{4}-?){3}\h{12}\z/
@@ -0,0 +1,3 @@
1
+ module Taro::Types::Shared::Deprecation
2
+ attr_accessor :deprecated
3
+ end
@@ -0,0 +1,27 @@
1
+ module Taro::Types::Shared::DerivedTypes
2
+ # Adds `name` as a method to all type classes and adds
3
+ # `name`_of as a supported key to the Coercion module.
4
+ # When `name` is called on a type class T, it returns a new subclass
5
+ # S inheriting from `type` and passes T to S::derive_from.
6
+ def define_derived_type(name, type)
7
+ root = Taro::Types::BaseType
8
+ raise ArgumentError, "#{name} is already in use" if root.respond_to?(name)
9
+
10
+ ckey = :"#{name}#{Taro::Types::Coercion.derived_suffix}"
11
+ ckeys = Taro::Types::Coercion.keys
12
+ raise ArgumentError, "#{ckey} is already in use" if ckeys.include?(ckey)
13
+
14
+ root.define_singleton_method(name) do
15
+ derived_types[type] ||= begin
16
+ type_class = Taro::Types::Coercion.call(type:)
17
+ Class.new(type_class).tap { |t| t.derive_from(self) }
18
+ end
19
+ end
20
+
21
+ ckeys << ckey
22
+ end
23
+
24
+ def derived_types
25
+ @derived_types ||= {}
26
+ end
27
+ end
@@ -8,6 +8,8 @@ module Taro::Types::Shared::Errors
8
8
  end
9
9
 
10
10
  def coerce_error_message(msg)
11
- "#{object.inspect} (#{object.class}) is not valid as #{self.class}: #{msg}"
11
+ type_desc = (self.class.name || self.class.superclass.name)
12
+ .sub(/^Taro::Types::.*?([^:]+)Type$/, '\1')
13
+ "#{object.class} is not valid as #{type_desc}: #{msg}"
12
14
  end
13
15
  end
@@ -2,7 +2,7 @@
2
2
  module Taro::Types::Shared::Fields
3
3
  # Field types are set using class name Strings. The respective type classes
4
4
  # are evaluated lazily to allow for circular or recursive type references,
5
- # and to avoid unnecessary eager loading of all types in dev/test envs.
5
+ # and to avoid unnecessary autoloading of all types in dev/test envs.
6
6
  def field(name, **kwargs)
7
7
  defined_at = kwargs[:defined_at] || caller_locations(1..1)[0]
8
8
  validate_name(name, defined_at:)
@@ -27,11 +27,12 @@ module Taro::Types::Shared::Fields
27
27
  [true, false].include?(kwargs[:null]) ||
28
28
  raise(Taro::ArgumentError, "null has to be specified as true or false for field #{name} at #{defined_at}")
29
29
 
30
- (type_keys = (kwargs.keys & Taro::Types::Coercion::KEYS)).size == 1 ||
31
- raise(Taro::ArgumentError, "exactly one of type, array_of, or page_of must be given for field #{name} at #{defined_at}")
30
+ c_keys = Taro::Types::Coercion.keys
31
+ (type_keys = (kwargs.keys & c_keys)).size == 1 ||
32
+ raise(Taro::ArgumentError, "exactly one of #{c_keys.join(', ')} must be given for field #{name} at #{defined_at}")
32
33
 
33
34
  kwargs[type_keys.first].class == String ||
34
- raise(Taro::ArgumentError, "#{type_key} must be a String for field #{name} at #{defined_at}")
35
+ raise(Taro::ArgumentError, "#{type_keys.first} must be a String for field #{name} at #{defined_at}")
35
36
  end
36
37
 
37
38
  def validate_no_override(name, defined_at:)
@@ -46,7 +47,7 @@ module Taro::Types::Shared::Fields
46
47
  def evaluate_field_defs
47
48
  field_defs.transform_values do |field_def|
48
49
  type = Taro::Types::Coercion.call(field_def)
49
- Taro::Types::Field.new(**field_def.except(*Taro::Types::Coercion::KEYS), type:)
50
+ Taro::Types::Field.new(**field_def.except(*Taro::Types::Coercion.keys), type:)
50
51
  end
51
52
  end
52
53
 
@@ -6,6 +6,7 @@ module Taro::Types::Shared::ItemType
6
6
  item_type.nil? || new_type == item_type || raise_mixed_types(new_type)
7
7
  @item_type = new_type
8
8
  end
9
+ alias_method :derive_from, :item_type=
9
10
 
10
11
  def raise_mixed_types(new_type)
11
12
  raise Taro::ArgumentError, <<~MSG
@@ -3,6 +3,8 @@ module Taro::Types::Shared::ObjectCoercion
3
3
  def coerce_input
4
4
  self.class.fields.transform_values do |field|
5
5
  field.value_for_input(object)
6
+ rescue Taro::Error => e
7
+ raise_enriched_coercion_error(e, field)
6
8
  end
7
9
  end
8
10
 
@@ -11,6 +13,17 @@ module Taro::Types::Shared::ObjectCoercion
11
13
  object_is_hash = object.is_a?(Hash)
12
14
  self.class.fields.transform_values do |field|
13
15
  field.value_for_response(object, context: self, object_is_hash:)
16
+ rescue Taro::Error => e
17
+ raise_enriched_coercion_error(e, field)
14
18
  end
15
19
  end
20
+
21
+ def raise_enriched_coercion_error(error, field)
22
+ # The indentation is on purpose. These errors can be recursively rescued
23
+ # and re-raised by a tree of object types, which should be made apparent.
24
+ raise error.class, <<~MSG
25
+ Failed to read #{self.class.name} field `#{field.name}` from #{object.class}:
26
+ #{error.message.lines.map { |line| " #{line}" }.join}
27
+ MSG
28
+ end
16
29
  end
@@ -5,13 +5,19 @@ module Taro::Types::Shared::OpenAPIName
5
5
  @openapi_name ||= default_openapi_name
6
6
  end
7
7
 
8
+ def openapi_name?
9
+ !!openapi_name
10
+ rescue Taro::Error
11
+ false
12
+ end
13
+
8
14
  def openapi_name=(arg)
9
15
  arg.nil? || arg.is_a?(String) ||
10
16
  raise(Taro::ArgumentError, 'openapi_name must be a String')
11
17
  @openapi_name = arg
12
18
  end
13
19
 
14
- def default_openapi_name # rubocop:disable Metrics
20
+ def default_openapi_name
15
21
  if self < Taro::Types::EnumType ||
16
22
  self < Taro::Types::InputType ||
17
23
  self < Taro::Types::ObjectType
@@ -19,12 +25,8 @@ module Taro::Types::Shared::OpenAPIName
19
25
  raise(Taro::Error, 'openapi_name must be set for anonymous type classes')
20
26
  elsif self < Taro::Types::ScalarType
21
27
  openapi_type
22
- elsif self < Taro::Types::ListType
23
- "#{item_type.openapi_name}_List"
24
- elsif self < Taro::Types::ObjectTypes::PageType
25
- "#{item_type.openapi_name}_Page"
26
28
  else
27
- raise NotImplementedError, 'no default_openapi_name for this type'
29
+ raise Taro::Error, 'no default_openapi_name implemented for this type'
28
30
  end
29
31
  end
30
32
  end
@@ -1,8 +1,8 @@
1
- # The `::render` method is intended for use in controllers.
2
- # Special types (e.g. PageType) may accept kwargs for `#coerce_response`.
3
1
  module Taro::Types::Shared::Rendering
4
- def render(object, opts = {})
5
- result = new(object).coerce_response(**opts)
2
+ # The `::render` method is intended for use in controllers.
3
+ # Overrides of this method must call super.
4
+ def render(object)
5
+ result = new(object).coerce_response
6
6
  self.last_render = [self, result.__id__]
7
7
  result
8
8
  end
data/lib/taro/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # :nocov:
2
2
  module Taro
3
- VERSION = "1.1.0"
3
+ VERSION = "1.2.0"
4
4
  end
data/tasks/benchmark.rake CHANGED
@@ -13,7 +13,7 @@ task :benchmark do
13
13
  field :version, type: 'Float', null: false
14
14
  end
15
15
 
16
- type = Taro::Types::ListType.for(item_type)
16
+ type = item_type.array
17
17
 
18
18
  # 143.889k (± 2.7%) i/s - 723.816k in 5.034247s
19
19
  Benchmark.ips do |x|
metadata CHANGED
@@ -1,14 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: taro
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Janosch Müller
8
+ - Johannes Opper
8
9
  autorequire:
9
10
  bindir: exe
10
11
  cert_chain: []
11
- date: 2024-11-16 00:00:00.000000000 Z
12
+ date: 2024-11-18 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
15
  name: rack
@@ -27,7 +28,6 @@ dependencies:
27
28
  description: This library provides an object-based type system for RESTful Ruby APIs,
28
29
  with built-in parameter parsing, response rendering, and OpenAPI schema export.
29
30
  email:
30
- - janosch84@gmail.com
31
31
  executables: []
32
32
  extensions: []
33
33
  extra_rdoc_files: []
@@ -90,7 +90,8 @@ files:
90
90
  - lib/taro/types/shared.rb
91
91
  - lib/taro/types/shared/additional_properties.rb
92
92
  - lib/taro/types/shared/custom_field_resolvers.rb
93
- - lib/taro/types/shared/derivable_types.rb
93
+ - lib/taro/types/shared/deprecation.rb
94
+ - lib/taro/types/shared/derived_types.rb
94
95
  - lib/taro/types/shared/description.rb
95
96
  - lib/taro/types/shared/errors.rb
96
97
  - lib/taro/types/shared/fields.rb
@@ -125,7 +126,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
125
126
  - !ruby/object:Gem::Version
126
127
  version: '0'
127
128
  requirements: []
128
- rubygems_version: 3.5.16
129
+ rubygems_version: 3.5.22
129
130
  signing_key:
130
131
  specification_version: 4
131
132
  summary: Typed Api using Ruby Objects.
@@ -1,9 +0,0 @@
1
- module Taro::Types::Shared::DerivableType
2
- def for(type)
3
- derived_types[type] ||= Class.new(self).tap { |t| t.item_type = type }
4
- end
5
-
6
- def derived_types
7
- @derived_types ||= {}
8
- end
9
- end