taro 1.4.0 → 2.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +138 -60
  4. data/lib/taro/cache.rb +14 -0
  5. data/lib/taro/common_returns.rb +31 -0
  6. data/lib/taro/declaration.rb +82 -0
  7. data/lib/taro/declarations.rb +34 -0
  8. data/lib/taro/errors.rb +15 -2
  9. data/lib/taro/export/base.rb +1 -1
  10. data/lib/taro/export/open_api_v3.rb +20 -23
  11. data/lib/taro/export.rb +1 -1
  12. data/lib/taro/none.rb +2 -0
  13. data/lib/taro/rails/active_declarations.rb +2 -10
  14. data/lib/taro/rails/declaration.rb +9 -114
  15. data/lib/taro/rails/declaration_buffer.rb +2 -1
  16. data/lib/taro/rails/dsl.rb +13 -6
  17. data/lib/taro/rails/generators/install_generator.rb +1 -1
  18. data/lib/taro/rails/generators/templates/errors_type.erb +1 -1
  19. data/lib/taro/rails/generators/templates/response_type.erb +4 -0
  20. data/lib/taro/rails/generators.rb +1 -1
  21. data/lib/taro/rails/normalized_route.rb +20 -38
  22. data/lib/taro/rails/param_parsing.rb +5 -3
  23. data/lib/taro/rails/railtie.rb +4 -0
  24. data/lib/taro/rails/response_validator.rb +53 -52
  25. data/lib/taro/rails/route_finder.rb +5 -7
  26. data/lib/taro/rails/tasks/export.rake +10 -9
  27. data/lib/taro/rails.rb +2 -3
  28. data/lib/taro/return_def.rb +43 -0
  29. data/lib/taro/route.rb +32 -0
  30. data/lib/taro/status_code.rb +16 -0
  31. data/lib/taro/types/base_type.rb +7 -1
  32. data/lib/taro/types/coercion.rb +2 -2
  33. data/lib/taro/types/enum_type.rb +1 -1
  34. data/lib/taro/types/field.rb +17 -5
  35. data/lib/taro/types/field_def.rb +62 -0
  36. data/lib/taro/types/field_validation.rb +4 -6
  37. data/lib/taro/types/input_type.rb +4 -9
  38. data/lib/taro/types/list_type.rb +1 -1
  39. data/lib/taro/types/nested_response_type.rb +16 -0
  40. data/lib/taro/types/object_type.rb +2 -7
  41. data/lib/taro/types/object_types/no_content_type.rb +1 -5
  42. data/lib/taro/types/object_types/page_info_type.rb +1 -1
  43. data/lib/taro/types/object_types/page_type.rb +1 -5
  44. data/lib/taro/types/response_type.rb +8 -0
  45. data/lib/taro/types/scalar/integer_param_type.rb +15 -0
  46. data/lib/taro/types/scalar_type.rb +1 -1
  47. data/lib/taro/types/shared/caching.rb +30 -0
  48. data/lib/taro/types/shared/custom_field_resolvers.rb +2 -2
  49. data/lib/taro/types/shared/derived_types.rb +34 -15
  50. data/lib/taro/types/shared/equivalence.rb +14 -0
  51. data/lib/taro/types/shared/errors.rb +8 -8
  52. data/lib/taro/types/shared/fields.rb +10 -36
  53. data/lib/taro/types/shared/name.rb +14 -0
  54. data/lib/taro/types/shared/object_coercion.rb +0 -13
  55. data/lib/taro/types/shared/openapi_name.rb +0 -6
  56. data/lib/taro/types/shared/rendering.rb +5 -3
  57. data/lib/taro/types/shared.rb +1 -1
  58. data/lib/taro/types.rb +1 -1
  59. data/lib/taro/version.rb +1 -1
  60. data/lib/taro.rb +6 -1
  61. metadata +19 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d30f413c950e02e52f99ed96d234acdba122c92ec4e39dd2d35e87c899ed8a13
4
- data.tar.gz: 5ed455623397ce6fbcb19412710bf98bc7ca2b6898db193a2cf858104a149327
3
+ metadata.gz: c4f35df13466f65caaf120945539ca21fcd2562dc3e79145817c66f75f58c016
4
+ data.tar.gz: c3f2af6555b7a3c9f6e81a6ba69f5fceea0663ed6b93c22cfe584f478a2a4b24
5
5
  SHA512:
6
- metadata.gz: ba99529dd914238371708a80b5f3f6497a4884a042a0e11ec7e9b557107379d0bfa2232b6a71137623658651e5d920f8ad47ab683119ae5ce105261fa627b980
7
- data.tar.gz: 26b2652184c485aa7970707373cfe4ebfdecd3141fc7e995066060b5a0a5452b1e749d6e16fdc3d99e34a199434ed9edc7400d89fe8f8ed32df442d764d06175
6
+ metadata.gz: c3c7de4bb79a0f78f1060ea32bf65e1da9bf597f09790d95f5c0e09a0fdc175de973aebdc28c39a1aa994dbd0fd41e6be3097508ddfad57474ad774603aac933
7
+ data.tar.gz: aad75b1177ff07d1e95415e720076d85734de7ebd185e2503e4fa80fdf8ffba4e2f2cf4df735582ed6e69599cfba413ce901abcd043f178efcf1ec6ef9072054
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [2.1.0] - 2025-02-21
4
+
5
+ ### Added
6
+
7
+ - added support for caching on type level and when calling render
8
+ - default cache will be set to Rails.cache in the railtie
9
+ - cache_key can be a string, an array, a hash or a proc
10
+
11
+ ## [2.0.0] - 2024-12-15
12
+
13
+ ### Changed
14
+
15
+ - rendering undeclared http error codes (except 422) is now allowed
16
+ - this previously raised errors when done in endpoints with other declarations
17
+ - as a result, some errors rendered from `rescue_from` blocks became 500s
18
+ - deduplicated response schemas for ad-hoc nested returns in OpenAPI export
19
+ - this only affects nested returns e.g. `returns :x, code: :ok, type: 'YType'`
20
+ - old name: `get_show_ys_200_Response`, new_name: `Y_in_x_Response`
21
+ - removed option to render nested returns with string keys
22
+ - e.g. for `returns :foo, [...]`, `render json: { 'foo' => [...] }` fails now
23
+ - removed `Taro::Rails.declarations`, replaced it with `Taro.declarations`
24
+
25
+ ### Added
26
+
27
+ - added `::common_return` to define common return types
28
+ - added support for declaring path & query params as Integer
29
+ - e.g. `param :id, type: 'Integer', required: true` for `/users/1`
30
+ - e.g. `param :page, type: 'Integer', required: true` for `?page=1`
31
+ - added parsed/rendered object to validation errors for debugging
32
+ - improved validation error messages
33
+
34
+ ### Fixed
35
+
36
+ - fixed unnecessary `$LOAD_PATH` searches at require time
37
+
3
38
  ## [1.4.0] - 2024-11-27
4
39
 
5
40
  ### Added
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Taro - Typed Api using Ruby Objects
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/taro.svg)](https://rubygems.org/gems/taro)
4
+ [![Build Status](https://github.com/taro-rb/taro/actions/workflows/main.yml/badge.svg)](https://github.com/taro-rb/taro/actions)
5
+
3
6
  This library provides an object-based type system for RESTful Ruby APIs, with built-in parameter parsing, response rendering, and OpenAPI schema export.
4
7
 
5
8
  It is inspired by [`apipie-rails`](https://github.com/Apipie/apipie-rails) and [`graphql-ruby`](https://github.com/rmosolgo/graphql-ruby).
@@ -32,25 +35,31 @@ This is how type classes can be used in a Rails controller:
32
35
  class BikesController < ApplicationController
33
36
  # This adds an endpoint summary, description, and tags to the docs (all optional)
34
37
  api 'Update a bike', desc: 'My longer text', tags: ['Bikes']
35
- # Params can come from the path, e.g. /bike/:id)
38
+
39
+ # Params can come from the path, e.g. /bike/:id.
40
+ # Some types, like UUID in this case, are predefined. See below for more types.
36
41
  param :id, type: 'UUID', null: false, desc: 'ID of the bike to update'
37
- # They can also come from the query string or request body
42
+
43
+ # Params can also come from the query string or request body.
44
+ # This describes a Hash param:
38
45
  param :bike, type: 'BikeInputType', null: false
39
- # Return types can differ by status code and can be nested as in this case:
40
- returns :bike, code: :ok, type: 'BikeType', desc: 'update success'
41
- # This one is not nested:
42
- returns code: :unprocessable_content, type: 'MyErrorType', desc: 'failure'
46
+
47
+ # Return types can differ by status code:
48
+ returns code: :ok, type: 'BikeType', desc: 'update success'
49
+
50
+ # Return types can also be nested (in this case in an "error" key):
51
+ returns :error, code: :unprocessable_content, type: 'MyErrorType'
52
+
43
53
  def update
44
- # defined params are available as @api_params
54
+ # Declared params are available as @api_params
45
55
  bike = Bike.find(@api_params[:id])
46
56
  success = bike.update(@api_params[:bike])
47
57
 
48
- # Types can be used to render responses.
49
- # The object
58
+ # Types are also used to render responses.
50
59
  if success
51
- render json: { bike: BikeType.render(bike) }, status: :ok
60
+ render json: BikeType.render(bike), status: :ok
52
61
  else
53
- render json: MyErrorType.render(bike.errors.first), status: :unprocessable_entity
62
+ render json: { error: MyErrorType.render(bike.errors.first) }, status: :unprocessable_entity
54
63
  end
55
64
  end
56
65
 
@@ -63,18 +72,18 @@ class BikesController < ApplicationController
63
72
  end
64
73
  ```
65
74
 
66
- Notice the multiple roles of types: They are used to define the structure of API requests and responses, and render the response. Both the input and output of the API can be validated against the schema if desired (see below).
75
+ Notice the multiple roles of types: They are used to describe the structure of API requests and responses, and render the response. Both the input and output of the API are validated against the schema by default (see below).
67
76
 
68
- Here is an example of the `BikeType` from that controller:
77
+ Here is an example of the `BikeType` from the controller above:
69
78
 
70
79
  ```ruby
71
80
  class BikeType < ObjectType
72
- # Optional description of BikeType (for API docs and the OpenAPI export)
81
+ # Optional description of BikeType (for the OpenAPI export)
73
82
  self.desc = 'A bike and all relevant information about it'
74
83
 
75
84
  # Object types have fields. Each field has a name, its own type,
76
85
  # and a `null:` setting to indicate if it can be nil.
77
- # Providing a desc is optional.
86
+ # Providing a description is optional.
78
87
  field :brand, type: 'String', null: true, desc: 'The brand name'
79
88
 
80
89
  # Fields can reference other types and arrays of values
@@ -95,7 +104,9 @@ class BikeType < ObjectType
95
104
  end
96
105
  ```
97
106
 
98
- ### Input types
107
+ ### Input and response types
108
+
109
+ You can use object types for input and output. However, if you want added control, you can also define dedicated input and response types.
99
110
 
100
111
  Note the use of `BikeInputType` in the `param` declaration above? It could look like so:
101
112
 
@@ -106,7 +117,18 @@ class BikeInputType < InputType
106
117
  end
107
118
  ```
108
119
 
109
- The usage of such dedicated InputTypes is optional. Object types can also be used to define accepted parameters, or parts of them, depending on what you want to allow API clients to send.
120
+ A type inheriting from InputType behaves just like an ObjectType when parsing parameters. The only difference is that it can't be used in response declarations.
121
+
122
+ Likewise, there is a special type for responses, which can't be used in input declarations:
123
+
124
+ ```ruby
125
+ class BikeSearchResponseType < ResponseType
126
+ field :bike, type: 'BikeType', null: true, desc: 'The found bike'
127
+ field :search_duration, type: 'Integer', null: false
128
+ end
129
+ ```
130
+
131
+ In cases where users may edit every attribute that you render, using a single ObjectType for both input and output is more convenient. E.g. `BikeType` could be used for both input and output in the `update` action above – if all its fields should be editable.
110
132
 
111
133
  ### Validation
112
134
 
@@ -120,15 +142,69 @@ Taro.config.parse_params = false
120
142
 
121
143
  #### Response validation
122
144
 
123
- Responses are automatically validated to use the correct type for rendering, which guarantees that they match the declaration. This can be disabled:
145
+ Responses are automatically validated to have used the correct type for rendering, which guarantees that they match the declaration. This means you have to use the types to render complex responses, and manually building a Hash that conforms to the schema will raise an error. Primitive/scalar types are an exception, e.g. you *can* do:
146
+
147
+ ```ruby
148
+ returns code: :ok, array_of: 'String', desc: 'Bike names'
149
+ def index
150
+ render json: ['Super bike', 'Slow bike']
151
+ end
152
+ ```
153
+
154
+ An error is also raised if a documented endpoint renders an undocumented status code, e.g.:
155
+
156
+ ```ruby
157
+ returns code: :ok, type: 'BikeType'
158
+ def create
159
+ render json: BikeType.render(bike), status: :created
160
+ # => undeclared 201 response, raises response validation error
161
+ end
162
+ ```
163
+
164
+ HTTP error codes, except 422, are an exception. They can be rendered without prior declaration. E.g.:
165
+
166
+ ```ruby
167
+ returns code: :ok, type: 'BikeType'
168
+ def show
169
+ render json: something, status: :not_found # works
170
+ end
171
+ ```
172
+
173
+ The reason for this is that Rack and Rails commonly render codes like 400, 404, 500 and various others, but you might not want to have that fact documented on every single endpoint.
174
+
175
+ However, if you do declare an error code, responses with this code *are* validated:
176
+
177
+ ```ruby
178
+ returns code: :not_found, type: 'ExpectedErrorType'
179
+ def show
180
+ render json: WrongErrorType.render(foo), status: :not_found
181
+ # => type mismatch, raises response validation error
182
+ end
183
+ ```
184
+
185
+ Response validation can be disabled:
124
186
 
125
187
  ```ruby
126
188
  Taro.config.validate_responses = false
127
189
  ```
128
190
 
191
+ ### Common error declarations
192
+
193
+ `::common_return` can be used to add a return declaration to all actions in a controller and its subclasses, and all related OpenAPI exports.
194
+
195
+ ```ruby
196
+ class AuthenticatedApiBaseController < ApiBaseController
197
+ common_return code: :unauthorized, type: 'MyErrorType', desc: 'Log in first'
198
+
199
+ rescue_from 'MyAuthError' do
200
+ render json: MyErrorType.render(something), status: :unauthorized
201
+ end
202
+ end
203
+ ```
204
+
129
205
  ### Included type options
130
206
 
131
- The following type names are available by default and can be used as `type:`/`array_of:`/`page_of:` arguments:
207
+ The following type names are available by default and can be used as `type:`, `array_of:`, or `page_of:` arguments:
132
208
 
133
209
  - `'Boolean'` - accepts and renders `true` or `false`
134
210
  - `'Date'` - accepts and renders a date string in ISO8601 format
@@ -142,7 +218,7 @@ The following type names are available by default and can be used as `type:`/`ar
142
218
  - `'Timestamp'` - renders a `Time` as unix timestamp integer and turns incoming integers into a `Time`
143
219
  - `'UUID'` - accepts and renders UUIDs
144
220
 
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.
221
+ Also, when using the rails 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.
146
222
 
147
223
  ### Enums
148
224
 
@@ -168,38 +244,13 @@ class ErrorType < ObjectType
168
244
  end
169
245
  ```
170
246
 
171
- ### FAQ
172
-
173
- #### How do I avoid repeating common error declarations?
174
-
175
- Hook into the DSL in your base controller(s):
176
-
177
- ```ruby
178
- class ApiBaseController < ApplicationController
179
- def self.api(...)
180
- super
181
- returns code: :not_found, type: 'MyErrorType', desc: 'The record was not found'
182
- end
247
+ ## FAQ
183
248
 
184
- rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
249
+ ### How do I render API docs?
185
250
 
186
- def render_not_found
187
- render json: MyErrorType.render(something), status: :not_found
188
- end
189
- end
190
- ```
251
+ Rendering docs is outside of the scope of this project. You can use the OpenAPI export to generate docs with tools such as RapiDoc, ReDoc, Swagger UI, and [many others](https://tools.openapis.org/categories/documentation.html).
191
252
 
192
- ```ruby
193
- class AuthenticatedApiController < ApiBaseController
194
- def self.api(...)
195
- super
196
- returns code: :unauthorized, type: 'MyErrorType'
197
- end
198
- # ... rescue_from ... render ...
199
- end
200
- ```
201
-
202
- #### How do I use context in my types?
253
+ ### How do I use context in my types?
203
254
 
204
255
  Use [ActiveSupport::CurrentAttributes](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html).
205
256
 
@@ -213,18 +264,23 @@ class BikeType < ObjectType
213
264
  end
214
265
  ```
215
266
 
216
- #### How do I migrate from apipie-rails?
267
+ ### How do I migrate from apipie-rails?
268
+
269
+ First of all, if you don't need a better OpenAPI export, or better support for hashes and arrays, or less repetitive definitions, it might not be worth it.
270
+
271
+ Please also note the following:
217
272
 
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.
273
+ - `taro` currently only supports the latest OpenAPI standard (instead of v2 like `apipie-rails`)
274
+ - `taro` does not support arbitrary validations - only those that can be expressed in the OpenAPI schema
275
+ - `taro` does not render API docs, as mentioned above
219
276
 
220
- If you do:
277
+ If you want to migrate anyway:
221
278
 
222
- - note that `taro` currently only supports the latest OpenAPI standard (instead of v2 like `apipie-rails`)
223
279
  - extract complex param declarations into InputTypes
224
- - extract complex response declarations into ObjectTypes
280
+ - extract complex response declarations into ObjectTypes or ResponseTypes
225
281
  - replace `required: true` with `null: false` and `required: false` with `null: true`
226
282
 
227
- For a step-by-step migration, you might want to make `taro` use a different DSL then `apipie`:
283
+ Taro uses some of the same DSL as `apipie`, so for a step-by-step migration, you might want to make `taro` use a different one. This initializer will change the `taro` DSL to `taro_api`, `taro_param`, and `taro_returns` and leave `api`, `param`, and `returns` to `apipie`:
228
284
 
229
285
  ```ruby
230
286
  # config/initializers/taro.rb
@@ -234,7 +290,7 @@ For a step-by-step migration, you might want to make `taro` use a different DSL
234
290
  end
235
291
  ```
236
292
 
237
- #### How do I keep lengthy API descriptions out of my controller?
293
+ ### How do I keep lengthy API descriptions out of my controller?
238
294
 
239
295
  ```ruby
240
296
  module BikeUpdateDesc
@@ -252,13 +308,13 @@ class BikesController < ApplicationController
252
308
  end
253
309
  ```
254
310
 
255
- #### Why do I have to use type name strings instead of the type constants?
311
+ ### Why do I have to use type name strings instead of the type constants?
256
312
 
257
313
  Why e.g. `field :id, type: 'UUID'` instead of `field :id, type: UUID`?
258
314
 
259
315
  The purpose of this is to reduce unnecessary autoloading of the whole type dependency tree in dev and test environments.
260
316
 
261
- #### Can I define my own derived types like `page_of` or `array_of`?
317
+ ### Can I define my own derived types like `page_of` or `array_of`?
262
318
 
263
319
  Yes.
264
320
 
@@ -288,15 +344,37 @@ class MyController < ApplicationController
288
344
  end
289
345
  ```
290
346
 
347
+ ## Caching
348
+
349
+ Taro provides support for caching. The cache instance can be configured by setting `Taro::Cache.cache_instance`. By default, the railtie will set it to `Rails.cache`.
350
+
351
+ It supports configuring an add hoc cache when using a render call, e.g.
352
+
353
+ ```ruby
354
+ bike = Bike.find(params[:id])
355
+ BikeType.with_cache(cache_key: bike.cache_key, expires_in: 3.minutes).render(bike)
356
+ ```
357
+
358
+ Or by configuring the cache rule on a per type bases, e.g.
359
+
360
+ ```ruby
361
+ class BikeType < ObjectType
362
+ # Optional description of BikeType (for the OpenAPI export)
363
+ self.desc = 'A bike and all relevant information about it'
364
+ self.cache_key = ->(bike) { bike.cache_key_with_version }
365
+ self.expires_in = 1.hour
366
+
367
+ ...
368
+ end
369
+ ```
370
+
291
371
  ## Possible future features
292
372
 
293
373
  - warning/raising for undeclared input params (currently they are ignored)
294
374
  - usage without rails is possible but not convenient yet
295
375
  - rspec matchers for testing
296
376
  - sum types
297
- - api doc rendering based on export (e.g. rails engine with web ui)
298
377
  - [query logs metadata](https://github.com/rmosolgo/graphql-ruby/blob/dcaaed1cea47394fad61fceadf291ff3cb5f2932/lib/generators/graphql/install_generator.rb#L48-L52)
299
- - maybe make `type:` optional for path params as they're always strings anyway
300
378
  - various openapi features
301
379
  - non-JSON content types (e.g. for file uploads)
302
380
  - [examples](https://swagger.io/specification/#example-object)
data/lib/taro/cache.rb ADDED
@@ -0,0 +1,14 @@
1
+ module Taro::Cache
2
+ singleton_class.attr_accessor :cache_instance
3
+
4
+ def self.call(object, cache_key: nil, expires_in: nil)
5
+ case cache_key
6
+ when nil
7
+ yield
8
+ when Hash, Proc
9
+ call(object, cache_key: cache_key[object], expires_in: expires_in) { yield }
10
+ else
11
+ cache_instance.fetch(cache_key, expires_in: expires_in) { yield }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,31 @@
1
+ # Holds common return definitions for a set of declarations,
2
+ # e.g. shared error responses, within a class and its subclasses.
3
+ module Taro::CommonReturns
4
+ class << self
5
+ def define(klass, nesting = nil, **)
6
+ (map[klass] ||= []) << Taro::ReturnDef.new(nesting:, **)
7
+ klass.extend(InheritedCallback)
8
+ end
9
+
10
+ def inherit(from_class, to_class)
11
+ map[to_class] = map[from_class].dup
12
+ end
13
+
14
+ def for(klass)
15
+ map[klass] || []
16
+ end
17
+
18
+ private
19
+
20
+ def map
21
+ @map ||= {}
22
+ end
23
+ end
24
+
25
+ module InheritedCallback
26
+ def inherited(new_class)
27
+ Taro::CommonReturns.inherit(self, new_class)
28
+ super
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,82 @@
1
+ # Framework-agnostic, abstract class.
2
+ # Descendants must implement #endpoint and (only for openapi export) #routes.
3
+ # See Taro::Rails::Declaration for an example.
4
+ class Taro::Declaration
5
+ attr_reader :desc, :summary, :params, :return_defs, :return_descriptions, :tags
6
+
7
+ def initialize(for_klass = nil)
8
+ @params = Class.new(Taro::Types::InputType)
9
+ @return_defs = {}
10
+ @return_descriptions = {}
11
+
12
+ Taro::CommonReturns.for(for_klass).each { |rd| add_return_def(rd) }
13
+ end
14
+
15
+ def add_info(summary, desc: nil, tags: nil)
16
+ summary.is_a?(String) || raise(Taro::ArgumentError, 'api summary must be a String')
17
+ @summary = summary
18
+ @desc = desc
19
+ @tags = Array(tags) if tags
20
+ end
21
+
22
+ def add_param(param_name, **attributes)
23
+ if attributes[:type] == 'Integer'
24
+ attributes[:type] = 'Taro::Types::Scalar::IntegerParamType'
25
+ end
26
+ @params.field(param_name, **attributes)
27
+ end
28
+
29
+ def add_return(nesting = nil, **)
30
+ return_def = Taro::ReturnDef.new(nesting:, **)
31
+ add_return_def(return_def)
32
+ end
33
+
34
+ # Return types are evaluated lazily to avoid unnecessary autoloading
35
+ # of all types in dev/test envs.
36
+ def returns
37
+ @returns ||= evaluate_return_defs
38
+ end
39
+
40
+ def routes
41
+ raise NotImplementedError, "implement ##{__method__} in subclass"
42
+ end
43
+
44
+ def endpoint
45
+ raise NotImplementedError, "implement ##{__method__} in subclass"
46
+ end
47
+
48
+ def polymorphic_route?
49
+ routes.size > 1
50
+ end
51
+
52
+ def inspect
53
+ "#<#{self.class} (#{endpoint || 'not finalized'})>"
54
+ end
55
+
56
+ private
57
+
58
+ def add_return_def(return_def)
59
+ raise_if_already_declared(return_def.code)
60
+
61
+ return_defs[return_def.code] = return_def
62
+ return_descriptions[return_def.code] = return_def.desc
63
+ end
64
+
65
+ def raise_if_already_declared(code)
66
+ (prev = return_defs[code]) && raise(Taro::ArgumentError, <<~MSG)
67
+ response for status #{code} already declared at #{prev.defined_at}
68
+ MSG
69
+ end
70
+
71
+ def evaluate_return_defs
72
+ return_defs.transform_values do |rd|
73
+ type = rd.evaluate
74
+ type.define_name("ResponseType(#{endpoint})") if rd.nesting
75
+ type
76
+ end
77
+ end
78
+
79
+ def <=>(other)
80
+ routes.first.openapi_operation_id <=> other.routes.first.openapi_operation_id
81
+ end
82
+ end
@@ -0,0 +1,34 @@
1
+ module Taro
2
+ def self.declarations
3
+ DeclarationsMap
4
+ end
5
+
6
+ module DeclarationsMap
7
+ class << self
8
+ include Enumerable
9
+
10
+ def [](key)
11
+ map[key]
12
+ end
13
+
14
+ def []=(key, declaration)
15
+ map.key?(key) && raise(Taro::InvariantError, "#{key} already declared")
16
+ map[key] = declaration
17
+ end
18
+
19
+ def each(&)
20
+ map.each_value(&)
21
+ end
22
+
23
+ def reset
24
+ map.clear
25
+ end
26
+
27
+ private
28
+
29
+ def map
30
+ @map ||= {}
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/taro/errors.rb CHANGED
@@ -1,12 +1,25 @@
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.sub(/\n(?=\S)/, ' ')
4
+ super.chomp.tr("\n", ' ')
5
5
  end
6
6
  end
7
7
 
8
8
  class Taro::ArgumentError < Taro::Error; end
9
9
  class Taro::RuntimeError < Taro::Error; end
10
- class Taro::ValidationError < Taro::RuntimeError; end # not to be used directly
10
+ class Taro::InvariantError < Taro::RuntimeError; end
11
+
12
+ class Taro::ValidationError < Taro::RuntimeError
13
+ attr_reader :object, :origin
14
+
15
+ def initialize(message, object, origin)
16
+ raise 'Abstract class' if instance_of?(Taro::ValidationError)
17
+
18
+ super(message)
19
+ @object = object
20
+ @origin = origin
21
+ end
22
+ end
23
+
11
24
  class Taro::InputError < Taro::ValidationError; end
12
25
  class Taro::ResponseError < Taro::ValidationError; end
@@ -1,7 +1,7 @@
1
1
  class Taro::Export::Base
2
2
  attr_reader :result
3
3
 
4
- def self.call(declarations:, title: 'Taro-based API', version: '1.0', **)
4
+ def self.call(declarations: Taro.declarations, title: Taro.config.api_name, version: Taro.config.api_version, **)
5
5
  new.call(declarations:, title:, version:, **)
6
6
  end
7
7
 
@@ -6,8 +6,6 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
6
6
  @schemas = {}
7
7
  end
8
8
 
9
- # TODO:
10
- # - use json-schema gem to validate overall result against OpenAPIv3 schema
11
9
  def call(declarations:, title:, version:)
12
10
  @result = { openapi: '3.1.0', info: { title:, version: } }
13
11
  paths = export_paths(declarations)
@@ -34,7 +32,7 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
34
32
  operationId: route.openapi_operation_id,
35
33
  parameters: route_parameters(declaration, route),
36
34
  requestBody: request_body(declaration, route),
37
- responses: responses(declaration, route),
35
+ responses: responses(declaration),
38
36
  }.compact,
39
37
  }
40
38
  end
@@ -45,9 +43,10 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
45
43
 
46
44
  def path_parameters(declaration, route)
47
45
  route.path_params.map do |param_name|
48
- param_field = declaration.params.fields[param_name] || raise(<<~MSG)
49
- Declaration missing for path param #{param_name} of route #{route.endpoint}
50
- MSG
46
+ param_field = declaration.params.fields[param_name] || raise(
47
+ Taro::InvariantError,
48
+ "Declaration missing for path param #{param_name} of route #{route}"
49
+ )
51
50
 
52
51
  # path params are always required in rails
53
52
  export_parameter(param_field).merge(in: 'path', required: true)
@@ -77,8 +76,11 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
77
76
  end
78
77
 
79
78
  def validate_path_or_query_parameter(field)
80
- [:array, :object].include?(field.type.openapi_type) &&
81
- raise("Unexpected object as path/query param #{field.name}: #{field.type}")
79
+ ok = %i[string integer]
80
+ ok.include?(field.type.openapi_type) || raise(Taro::ArgumentError, <<~MSG)
81
+ Unsupported #{field.openapi_type} as path/query param "#{field.name}",
82
+ expected one of: #{ok.join(', ')}
83
+ MSG
82
84
  end
83
85
 
84
86
  def request_body(declaration, route)
@@ -110,28 +112,21 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
110
112
  end
111
113
  end
112
114
 
113
- def responses(declaration, route)
114
- name_anonymous_return_types(declaration, route)
115
-
115
+ def responses(declaration)
116
116
  declaration.returns.sort.to_h do |code, type|
117
+ # response description is required in openapi 3 – fall back to status code
118
+ description = declaration.return_descriptions[code] || type.desc ||
119
+ Taro::StatusCode.coerce_to_message(code)
117
120
  [
118
121
  code.to_s,
119
122
  {
120
- description: declaration.return_descriptions[code],
123
+ description:,
121
124
  content: { 'application/json': { schema: export_type(type) } },
122
125
  }
123
126
  ]
124
127
  end
125
128
  end
126
129
 
127
- def name_anonymous_return_types(declaration, route)
128
- declaration.returns.each do |code, type|
129
- next if type.openapi_name?
130
-
131
- type.openapi_name = "#{route.openapi_operation_id}_#{code}_Response"
132
- end
133
- end
134
-
135
130
  def export_type(type)
136
131
  if type < Taro::Types::ScalarType && !custom_scalar_type?(type)
137
132
  { type: type.openapi_type }
@@ -199,7 +194,7 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
199
194
  elsif custom_scalar_type?(type)
200
195
  custom_scalar_type_details(type)
201
196
  else
202
- raise NotImplementedError, "Unsupported type: #{type}"
197
+ raise Taro::InvariantError, "Unexpected type: #{type}"
203
198
  end
204
199
  end
205
200
 
@@ -244,8 +239,10 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
244
239
 
245
240
  def assert_unique_openapi_name(type)
246
241
  @name_to_type_map ||= {}
247
- if (prev = @name_to_type_map[type.openapi_name]) && type != prev
248
- raise("Duplicate openapi_name \"#{type.openapi_name}\" for types #{prev} and #{type}")
242
+ if (prev = @name_to_type_map[type.openapi_name]) && !prev.equivalent?(type)
243
+ raise Taro::InvariantError, <<~MSG
244
+ Duplicate openapi_name "#{type.openapi_name}" for types #{prev} and #{type}
245
+ MSG
249
246
  else
250
247
  @name_to_type_map[type.openapi_name] = type
251
248
  end