taro 0.0.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +22 -0
  4. data/CHANGELOG.md +5 -0
  5. data/README.md +262 -1
  6. data/Rakefile +11 -0
  7. data/lib/taro/config.rb +22 -0
  8. data/lib/taro/errors.rb +6 -0
  9. data/lib/taro/export/base.rb +29 -0
  10. data/lib/taro/export/open_api_v3.rb +189 -0
  11. data/lib/taro/export.rb +3 -0
  12. data/lib/taro/rails/active_declarations.rb +19 -0
  13. data/lib/taro/rails/declaration.rb +101 -0
  14. data/lib/taro/rails/declaration_buffer.rb +24 -0
  15. data/lib/taro/rails/dsl.rb +18 -0
  16. data/lib/taro/rails/generators/install_generator.rb +19 -0
  17. data/lib/taro/rails/generators/templates/enum_type.erb +4 -0
  18. data/lib/taro/rails/generators/templates/error_type.erb +10 -0
  19. data/lib/taro/rails/generators/templates/errors_type.erb +25 -0
  20. data/lib/taro/rails/generators/templates/input_type.erb +4 -0
  21. data/lib/taro/rails/generators/templates/no_content_type.erb +4 -0
  22. data/lib/taro/rails/generators/templates/object_type.erb +4 -0
  23. data/lib/taro/rails/generators/templates/scalar_type.erb +4 -0
  24. data/lib/taro/rails/generators.rb +3 -0
  25. data/lib/taro/rails/normalized_route.rb +29 -0
  26. data/lib/taro/rails/param_parsing.rb +19 -0
  27. data/lib/taro/rails/railtie.rb +15 -0
  28. data/lib/taro/rails/response_validation.rb +63 -0
  29. data/lib/taro/rails/route_finder.rb +35 -0
  30. data/lib/taro/rails/tasks/export.rake +15 -0
  31. data/lib/taro/rails.rb +18 -0
  32. data/lib/taro/types/base_type.rb +17 -0
  33. data/lib/taro/types/coercion.rb +72 -0
  34. data/lib/taro/types/enum_type.rb +43 -0
  35. data/lib/taro/types/field.rb +78 -0
  36. data/lib/taro/types/field_validation.rb +27 -0
  37. data/lib/taro/types/input_type.rb +13 -0
  38. data/lib/taro/types/list_type.rb +30 -0
  39. data/lib/taro/types/object_type.rb +19 -0
  40. data/lib/taro/types/object_types/free_form_type.rb +13 -0
  41. data/lib/taro/types/object_types/no_content_type.rb +16 -0
  42. data/lib/taro/types/object_types/page_info_type.rb +6 -0
  43. data/lib/taro/types/object_types/page_type.rb +45 -0
  44. data/lib/taro/types/scalar/boolean_type.rb +19 -0
  45. data/lib/taro/types/scalar/float_type.rb +15 -0
  46. data/lib/taro/types/scalar/integer_type.rb +11 -0
  47. data/lib/taro/types/scalar/iso8601_date_type.rb +23 -0
  48. data/lib/taro/types/scalar/iso8601_datetime_type.rb +25 -0
  49. data/lib/taro/types/scalar/string_type.rb +15 -0
  50. data/lib/taro/types/scalar/timestamp_type.rb +23 -0
  51. data/lib/taro/types/scalar/uuid_v4_type.rb +22 -0
  52. data/lib/taro/types/scalar_type.rb +7 -0
  53. data/lib/taro/types/shared/additional_properties.rb +12 -0
  54. data/lib/taro/types/shared/custom_field_resolvers.rb +33 -0
  55. data/lib/taro/types/shared/derivable_types.rb +9 -0
  56. data/lib/taro/types/shared/description.rb +9 -0
  57. data/lib/taro/types/shared/errors.rb +13 -0
  58. data/lib/taro/types/shared/fields.rb +57 -0
  59. data/lib/taro/types/shared/item_type.rb +16 -0
  60. data/lib/taro/types/shared/object_coercion.rb +16 -0
  61. data/lib/taro/types/shared/openapi_name.rb +30 -0
  62. data/lib/taro/types/shared/openapi_type.rb +27 -0
  63. data/lib/taro/types/shared/rendering.rb +36 -0
  64. data/lib/taro/types/shared.rb +3 -0
  65. data/lib/taro/types.rb +3 -0
  66. data/lib/taro/version.rb +2 -3
  67. data/lib/taro.rb +1 -6
  68. data/tasks/benchmark.rake +40 -0
  69. data/tasks/benchmark_1kb.json +23 -0
  70. metadata +90 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 809337809eb0579bb4de8b3f6879d8fb8d4600e6ad149e20a382e229dc779e7b
4
- data.tar.gz: 434beec1c8620de344819a9ce65962076fabb33acc1e5f62655b54a5cf0af9c3
3
+ metadata.gz: d810edb4a339ca65e24bed6cbecf1baf1f83f84f7894589e8db69a09bc9b3f23
4
+ data.tar.gz: eaef44afdc12b965d93fd4532efadf41025c30fd59eb87281ae5815c6f48bcf7
5
5
  SHA512:
6
- metadata.gz: 1a9cb8892c86197acfaab877d51d0d80e6f79862a5d6202d9d66ea90e27801de3b84ca414ae63a89addabf8d2410330413e1b5516f3c5d3b6e6c49e775730d25
7
- data.tar.gz: ab5d134d03c0c6f84cf64c0a6a77faa9e7f4f713e40761f95b7ff2ceac644727913f433be8b02db2712515dbe8f6d09bd64323fb06e83ab7df74a891c5c677de
6
+ metadata.gz: f2de8020efb8ede1493d10cb798d69fa92201d2b518177a527d0e2546e771afe8fa436f03dd839c4a75d655cc2eab26fdb35c4d37a830393fda4e37bfd6583c5
7
+ data.tar.gz: ca421c1f360b072570f7eab0b17c26ac2109f16a19d96efbe564d22e791f7fa0945d11eb914ff67b29602576499f03aed388fc10cad6f9b7b1f42bea4f905f7d
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,22 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ SuggestExtensions: false
4
+ TargetRubyVersion: 3.2
5
+
6
+ Layout/LineLength:
7
+ Enabled: false
8
+
9
+ Lint/AmbiguousOperatorPrecedence:
10
+ Enabled: false
11
+
12
+ Metrics/BlockLength:
13
+ Exclude: ['spec/**/*', 'tasks/**/*']
14
+
15
+ Metrics/MethodLength:
16
+ Max: 15
17
+
18
+ Metrics/ParameterLists:
19
+ Enabled: false
20
+
21
+ Style:
22
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-11-03
4
+
5
+ - Initial release
data/README.md CHANGED
@@ -1 +1,262 @@
1
- placeholder gem
1
+ # Taro - Typed Api using Ruby Objects
2
+
3
+ This library provides an object-based type system for RESTful Ruby APIs, with built-in parameter parsing, response rendering, and OpenAPI schema export.
4
+
5
+ It is inspired by [`apipie-rails`](https://github.com/Apipie/apipie-rails) and [`graphql-ruby`](https://github.com/rmosolgo/graphql-ruby).
6
+
7
+ ## Goals
8
+
9
+ - provide a simple, declarative way to describe API endpoints
10
+ - conveniently check request and response data against the declaration
11
+ - offer an up-to-date OpenAPI export with minimal configuration
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
+ ## Installation
19
+
20
+ ```bash
21
+ bundle add taro
22
+ ```
23
+
24
+ Then, if using rails, generate type files to inherit from:
25
+
26
+ ```bash
27
+ bundle exec rails g taro:rails:install [ --dir app/my_types_dir ]
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ The core concept of Taro are type classes.
33
+
34
+ This is how type classes can be used in a Rails controller:
35
+
36
+ ```ruby
37
+ class BikesController < ApplicationController
38
+ # This adds an endpoint summary, description, and tags to the docs (all optional)
39
+ api 'Update a bike', desc: 'My longer text', tags: ['Bikes']
40
+ # Params can come from the path, e.g. /bike/:id)
41
+ param :id, type: 'UUID', null: false, desc: 'ID of the bike to update'
42
+ # They can also come from the query string or request body
43
+ param :bike, type: 'BikeInputType', null: false
44
+ # Return types can differ by status code and can be nested as in this case:
45
+ returns :bike, code: :ok, type: 'BikeType', desc: 'update success'
46
+ # This one is not nested:
47
+ returns code: :unprocessable_content, type: 'MyErrorType', desc: 'failure'
48
+ def update
49
+ # defined params are available as @api_params
50
+ bike = Bike.find(@api_params[:id])
51
+ success = bike.update(@api_params[:bike])
52
+
53
+ # Types can be used to render responses.
54
+ # The object
55
+ if success
56
+ render json: { bike: BikeType.render(bike) }, status: :ok
57
+ else
58
+ render json: MyErrorType.render(bike.errors.first), status: :unprocessable_entity
59
+ end
60
+ end
61
+
62
+ # Support for arrays and paginated lists is built-in.
63
+ api 'List all bikes'
64
+ returns code: :ok, array_of: 'BikeType', desc: 'list of bikes'
65
+ def index
66
+ render json: BikeType.array.render(Bike.all)
67
+ end
68
+ end
69
+ ```
70
+
71
+ 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).
72
+
73
+ Here is an example of the `BikeType` from that controller:
74
+
75
+ ```ruby
76
+ class BikeType < ObjectType
77
+ # Optional description of BikeType (for API docs and the OpenAPI export)
78
+ self.desc = 'A bike and all relevant information about it'
79
+
80
+ # Object types have fields. Each field has a name, its own type,
81
+ # and a `null:` setting to indicate if it can be nil.
82
+ # Providing a desc is optional.
83
+ field :brand, type: 'String', null: true, desc: 'The brand name'
84
+
85
+ # Fields can reference other types and arrays of values
86
+ field :users, array_of: 'UserType', null: false
87
+
88
+ # Pagination is built-in for big lists
89
+ field :parts, page_of: 'PartType', null: false
90
+
91
+ # Custom methods can be chosen to resolve fields
92
+ field :has_brand, type: 'Boolean', null: false, method: :brand?
93
+
94
+ # Field resolvers can also be implemented or overridden on the type.
95
+ # The object passed in to `BikeType.render` is available as `object`.
96
+ field :fancy_info, type: 'String', null: false
97
+ def fancy_info
98
+ "A bike named #{object.name} with #{object.parts.count} parts."
99
+ end
100
+ end
101
+ ```
102
+
103
+ ### Input types
104
+
105
+ Note the use of `BikeInputType` in the `param` declaration above? It could look like so:
106
+
107
+ ```ruby
108
+ class BikeInputType < InputType
109
+ field :brand, type: 'String', null: true, desc: 'The brand name'
110
+ field :wheels, type: 'Integer', null: false, default: 2
111
+ end
112
+ ```
113
+
114
+ 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.
115
+
116
+ ### Validation
117
+
118
+ #### Request validation
119
+
120
+ Requests are automatically validated to match the declared input schema, unless you disable the automatic parsing of parameters into the `@api_params` hash:
121
+
122
+ ```ruby
123
+ Taro.config.parse_params = false
124
+ ```
125
+
126
+ #### Response validation
127
+
128
+ Responses are automatically validated to use the correct type for rendering, which guarantees that they match the declaration. This can be disabled:
129
+
130
+ ```ruby
131
+ Taro.config.validate_responses = false
132
+ ```
133
+
134
+ ### Included type options
135
+
136
+ The following type names are available by default and can be used as `type:`/`array_of:`/`page_of:` arguments:
137
+
138
+ - `'Boolean'` - accepts and renders `true` or `false`
139
+ - `'Float'`
140
+ - `'FreeForm'` - accepts and renders any JSON-serializable object, use with care
141
+ - `'Integer'`
142
+ - `'NoContentType'` - renders an empty object, for use with `status: :no_content`
143
+ - `'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
+ - `'Time'` - accepts and renders a time string in ISO8601 format
148
+ - `'DateTime'` - an alias for `'Time'`
149
+
150
+ ### Enums
151
+
152
+ `EnumType` can be inherited from to define shared enums:
153
+
154
+ ```ruby
155
+ class SeverityEnumType < EnumType
156
+ value 'info'
157
+ value 'warning'
158
+ value 'debacle'
159
+ end
160
+
161
+ class ErrorType < ObjectType
162
+ field :severity, type: 'SeverityEnumType', null: false
163
+ end
164
+ ```
165
+
166
+ Inline enums are also possible. Unlike EnumType classes, these are inlined in the OpenAPI export and not extracted into refs.
167
+
168
+ ```ruby
169
+ class ErrorType < ObjectType
170
+ field :severity, type: 'String', enum: %w[info warning debacle], null: false
171
+ end
172
+ ```
173
+
174
+ ### FAQ
175
+
176
+ #### How to avoid repeating common error declarations?
177
+
178
+ Hook into the DSL in your base controller(s):
179
+
180
+ ```ruby
181
+ class ApiBaseController < ApplicationController
182
+ def self.api(...)
183
+ super
184
+ returns code: :not_found, type: 'MyErrorType', desc: 'The record was not found'
185
+ end
186
+
187
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
188
+
189
+ def render_not_found
190
+ render json: MyErrorType.render(something), status: :not_found
191
+ end
192
+ end
193
+ ```
194
+
195
+ ```ruby
196
+ class AuthenticatedApiController < ApiBaseController
197
+ def self.api(...)
198
+ super
199
+ returns code: :unauthorized, type: 'MyErrorType'
200
+ end
201
+ # ... rescue_from ... render ...
202
+ end
203
+ ```
204
+
205
+ #### How to use context in my types?
206
+
207
+ Use [ActiveSupport::CurrentAttributes](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html).
208
+
209
+ ```ruby
210
+ class BikeType < ObjectType
211
+ field :secret_name, type: 'String', null: true
212
+
213
+ def secret_name
214
+ Current.user.superuser? ? object.secret_name : nil
215
+ end
216
+ end
217
+ ```
218
+
219
+ #### Why do I have to use type name strings instead of the type constants?
220
+
221
+ Why e.g. `field :id, type: 'UUID'` instead of `field :id, type: UUID`?
222
+
223
+ The purpose of this is to reduce unnecessary autoloading of the whole type dependency tree in dev and test environments.
224
+
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.
226
+
227
+ ## Possible future features
228
+
229
+ - warning/raising for undeclared input params (currently they are ignored)
230
+ - usage without rails is possible but not convenient yet
231
+ - rspec matchers for testing
232
+ - sum types
233
+ - api doc rendering based on export (e.g. rails engine with web ui)
234
+ - [query logs metadata](https://github.com/rmosolgo/graphql-ruby/blob/dcaaed1cea47394fad61fceadf291ff3cb5f2932/lib/generators/graphql/install_generator.rb#L48-L52)
235
+ - deprecation feature
236
+ - maybe make `type:` optional for path params as they're always strings anyway
237
+ - various openapi features
238
+ - non-JSON content types (e.g. for file uploads)
239
+ - [examples](https://swagger.io/specification/#example-object)
240
+ - array minItems, maxItems, uniqueItems
241
+ - mixed arrays
242
+ - mixed enums
243
+ - nullable enums
244
+ - string format specifications (e.g. binary, int64, password ...)
245
+ - string pattern specifications
246
+ - string minLength and maxLength
247
+ - number minimum, exclusiveMinimum, maximum, multipleOf
248
+ - readOnly, writeOnly
249
+
250
+ ## Development
251
+
252
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
253
+
254
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
255
+
256
+ ## Contributing
257
+
258
+ Bug reports and pull requests are welcome on GitHub at https://github.com/taro-rb/taro.
259
+
260
+ ## License
261
+
262
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -1 +1,12 @@
1
1
  require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ Dir['tasks/**/*.rake'].each { |file| load(file) }
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,22 @@
1
+ module Taro::Config
2
+ singleton_class.attr_accessor(
3
+ :api_name,
4
+ :api_version,
5
+ :export_format,
6
+ :export_path,
7
+ :parse_params,
8
+ :validate_response,
9
+ )
10
+
11
+ # defaults
12
+ self.api_name = 'Taro-based API'
13
+ self.api_version = '1.0'
14
+ self.export_format = :yaml
15
+ self.export_path = 'api.yml'
16
+ self.parse_params = true
17
+ self.validate_response = true
18
+ end
19
+
20
+ def Taro.config
21
+ Taro::Config
22
+ end
@@ -0,0 +1,6 @@
1
+ class Taro::Error < StandardError; end
2
+ class Taro::ArgumentError < Taro::Error; end
3
+ class Taro::RuntimeError < Taro::Error; end
4
+ class Taro::ValidationError < Taro::RuntimeError; end # not to be used directly
5
+ class Taro::InputError < Taro::ValidationError; end
6
+ class Taro::ResponseError < Taro::ValidationError; end
@@ -0,0 +1,29 @@
1
+ class Taro::Export::Base
2
+ attr_reader :result
3
+
4
+ def self.call(declarations:, title: 'Taro-based API', version: '1.0', **)
5
+ new.call(declarations:, title:, version:, **)
6
+ end
7
+
8
+ def to_json(*)
9
+ require 'json'
10
+ JSON.pretty_generate(result)
11
+ end
12
+
13
+ def to_yaml
14
+ require 'yaml'
15
+ desymbolize(result).to_yaml
16
+ end
17
+
18
+ private
19
+
20
+ # https://github.com/ruby/psych/issues/396
21
+ def desymbolize(arg)
22
+ case arg
23
+ when Hash then arg.to_h { |k, v| [desymbolize(k), desymbolize(v)] }
24
+ when Array then arg.map { |v| desymbolize(v) }
25
+ when Symbol then arg.to_s
26
+ else arg
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,189 @@
1
+ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/ClassLength
2
+ attr_reader :schemas
3
+
4
+ def initialize
5
+ super
6
+ @schemas = {}
7
+ end
8
+
9
+ # TODO:
10
+ # - use json-schema gem to validate overall result against OpenAPIv3 schema
11
+ def call(declarations:, title:, version:)
12
+ @result = { openapi: '3.1.0', info: { title:, version: } }
13
+ paths = export_paths(declarations)
14
+ @result[:paths] = paths if paths.any?
15
+ @result[:components] = { schemas: } if schemas.any?
16
+ self
17
+ end
18
+
19
+ def export_paths(declarations)
20
+ declarations.each_with_object({}) do |declaration, paths|
21
+ declaration.routes.each do |route|
22
+ paths[route.openapi_path] = export_route(route, declaration)
23
+ end
24
+ end
25
+ end
26
+
27
+ def export_route(route, declaration)
28
+ {
29
+ route.verb.to_sym => {
30
+ description: declaration.desc,
31
+ summary: declaration.summary,
32
+ tags: declaration.tags,
33
+ parameters: path_parameters(declaration, route),
34
+ requestBody: request_body(declaration, route),
35
+ responses: responses(declaration),
36
+ }.compact,
37
+ }
38
+ end
39
+
40
+ def path_parameters(declaration, route)
41
+ route.path_params.map do |param_name|
42
+ param_field = declaration.params.fields[param_name] || raise(<<~MSG)
43
+ Declaration missing for path param #{param_name} of route #{route.endpoint}
44
+ MSG
45
+
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
53
+ end
54
+ end
55
+
56
+ def request_body(declaration, route)
57
+ params = declaration.params
58
+ body_param_fields = params.fields.reject do |name, _field|
59
+ route.path_params.include?(name)
60
+ end
61
+ return unless body_param_fields.any?
62
+
63
+ body_input_type = Class.new(params)
64
+ body_input_type.fields.replace(body_param_fields)
65
+ body_input_type.openapi_name = params.openapi_name
66
+
67
+ # For polymorphic routes (more than one for the same declaration),
68
+ # we can't use refs because they request body might differ.
69
+ # Different params might be in the path vs. in the request body.
70
+ use_refs = !declaration.polymorphic_route?
71
+ schema = request_body_schema(body_input_type, use_refs:)
72
+ { content: { 'application/json': { schema: } } }
73
+ end
74
+
75
+ def request_body_schema(type, use_refs:)
76
+ if use_refs
77
+ extract_component_ref(type)
78
+ else
79
+ type_details(type)
80
+ end
81
+ end
82
+
83
+ def responses(declaration)
84
+ declaration.returns.to_h do |code, type|
85
+ [
86
+ code.to_s,
87
+ {
88
+ description: declaration.return_descriptions[code],
89
+ content: { 'application/json': { schema: export_type(type) } },
90
+ }
91
+ ]
92
+ end
93
+ end
94
+
95
+ def export_type(type)
96
+ if type < Taro::Types::ScalarType
97
+ { type: type.openapi_type }
98
+ else
99
+ extract_component_ref(type)
100
+ end
101
+ end
102
+
103
+ def export_field(field)
104
+ if field.type < Taro::Types::ScalarType
105
+ export_scalar_field(field)
106
+ else
107
+ export_complex_field_ref(field)
108
+ end
109
+ end
110
+
111
+ def export_scalar_field(field)
112
+ base = { type: field.openapi_type }
113
+ # Using oneOf seems more correct than an array of types
114
+ # as it puts props like format together with the main type.
115
+ # https://github.com/OAI/OpenAPI-Specification/issues/3148
116
+ 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
121
+ end
122
+
123
+ def export_complex_field_ref(field)
124
+ ref = extract_component_ref(field.type)
125
+ if field.null
126
+ # RE nullable: https://stackoverflow.com/a/70658334
127
+ { description: field.desc, oneOf: [ref, { type: 'null' }] }.compact
128
+ elsif field.desc
129
+ # https://github.com/OAI/OpenAPI-Specification/issues/2033
130
+ { description: field.desc, allOf: [ref] }
131
+ else
132
+ ref
133
+ end
134
+ end
135
+
136
+ def extract_component_ref(type)
137
+ assert_unique_openapi_name(type)
138
+ schemas[type.openapi_name.to_sym] ||= type_details(type)
139
+ { '$ref': "#/components/schemas/#{type.openapi_name}" }
140
+ end
141
+
142
+ def type_details(type)
143
+ if type.respond_to?(:fields) # InputType or ObjectType
144
+ object_type_details(type)
145
+ elsif type < Taro::Types::EnumType
146
+ enum_type_details(type)
147
+ elsif type < Taro::Types::ListType
148
+ list_type_details(type)
149
+ else
150
+ raise NotImplementedError, "Unsupported type: #{type}"
151
+ end
152
+ end
153
+
154
+ def object_type_details(type)
155
+ required = type.fields.values.reject(&:null).map(&:name)
156
+ {
157
+ type: type.openapi_type,
158
+ description: type.desc,
159
+ required: (required if required.any?),
160
+ properties: type.fields.to_h { |name, f| [name, export_field(f)] },
161
+ additionalProperties: (true if type.additional_properties?),
162
+ }.compact
163
+ end
164
+
165
+ def enum_type_details(enum)
166
+ {
167
+ type: enum.item_type.openapi_type,
168
+ description: enum.desc,
169
+ enum: enum.values,
170
+ }.compact
171
+ end
172
+
173
+ def list_type_details(list)
174
+ {
175
+ type: 'array',
176
+ description: list.desc,
177
+ items: export_type(list.item_type),
178
+ }.compact
179
+ end
180
+
181
+ def assert_unique_openapi_name(type)
182
+ @name_to_type_map ||= {}
183
+ if (prev = @name_to_type_map[type.openapi_name]) && type != prev
184
+ raise("Duplicate openapi_name \"#{type.openapi_name}\" for types #{prev} and #{type}")
185
+ else
186
+ @name_to_type_map[type.openapi_name] = type
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,3 @@
1
+ module Taro::Export
2
+ Dir[File.join(__dir__, "export", "*.rb")].each { |f| require f }
3
+ end
@@ -0,0 +1,19 @@
1
+ module Taro::Rails::ActiveDeclarations
2
+ def apply(declaration:, controller_class:, action_name:)
3
+ (declarations_map[controller_class] ||= {})[action_name] = declaration
4
+ Taro::Rails::ParamParsing.install(controller_class:, action_name:)
5
+ Taro::Rails::ResponseValidation.install(controller_class:, action_name:)
6
+ end
7
+
8
+ def declarations_map
9
+ @declarations_map ||= {}
10
+ end
11
+
12
+ def declarations
13
+ declarations_map.values.flat_map(&:values)
14
+ end
15
+
16
+ def declaration_for(controller)
17
+ declarations_map[controller.class].to_h[controller.action_name.to_sym]
18
+ end
19
+ end
@@ -0,0 +1,101 @@
1
+ class Taro::Rails::Declaration
2
+ attr_reader :desc, :summary, :params, :returns, :return_descriptions, :return_nestings, :routes, :tags
3
+
4
+ def initialize
5
+ @params = Class.new(Taro::Types::InputType)
6
+ @returns = {}
7
+ @return_descriptions = {}
8
+ @return_nestings = {}
9
+ end
10
+
11
+ def add_info(summary, desc: nil, tags: nil)
12
+ summary.is_a?(String) || raise(Taro::ArgumentError, 'api summary must be a String')
13
+ @summary = summary
14
+ @desc = desc
15
+ @tags = Array(tags) if tags
16
+ end
17
+
18
+ def add_param(param_name, **kwargs)
19
+ kwargs[:defined_at] = caller_locations(1..2)[1]
20
+ @params.field(param_name, **kwargs)
21
+ end
22
+
23
+ def add_return(nesting = nil, code:, desc: nil, **kwargs)
24
+ status = self.class.coerce_status_to_int(code)
25
+ raise_if_already_declared(status)
26
+
27
+ kwargs[:defined_at] = caller_locations(1..2)[1]
28
+ returns[status] = return_type_from(nesting, **kwargs)
29
+
30
+ # response desc is required in openapi 3 – fall back to status code
31
+ return_descriptions[status] = desc || code.to_s
32
+
33
+ # if a field name is provided, the response should be nested
34
+ return_nestings[status] = nesting if nesting
35
+ end
36
+
37
+ def raise_if_already_declared(status)
38
+ returns[status] &&
39
+ raise(Taro::ArgumentError, "response for status #{status} already declared")
40
+ end
41
+
42
+ def parse_params(rails_params)
43
+ hash = params.new(rails_params.to_unsafe_h).coerce_input
44
+ hash
45
+ end
46
+
47
+ def finalize(controller_class:, action_name:)
48
+ add_routes(controller_class:, action_name:)
49
+ add_openapi_names(controller_class:, action_name:)
50
+ end
51
+
52
+ def add_routes(controller_class:, action_name:)
53
+ routes = Taro::Rails::RouteFinder.call(controller_class:, action_name:)
54
+ routes.any? || raise_missing_route(controller_class, action_name)
55
+ self.routes = routes
56
+ end
57
+
58
+ def routes=(arg)
59
+ arg.is_a?(Array) || raise(Taro::ArgumentError, 'routes must be an Array')
60
+ @routes = arg
61
+ end
62
+
63
+ def polymorphic_route?
64
+ routes.size > 1
65
+ end
66
+
67
+ # TODO: these change when the controller class is renamed.
68
+ # We might need a way to set `base`. Perhaps as a kwarg to `::api`?
69
+ def add_openapi_names(controller_class:, action_name:)
70
+ base = "#{controller_class.name.chomp('Controller').sub('::', '_')}_#{action_name}"
71
+ params.openapi_name = "#{base}_Input"
72
+ returns.each do |status, return_type|
73
+ return_type.openapi_name = "#{base}_#{status}_Response"
74
+ end
75
+ end
76
+
77
+ require 'rack'
78
+ def self.coerce_status_to_int(status)
79
+ # support using http status numbers directly
80
+ return status if ::Rack::Utils::SYMBOL_TO_STATUS_CODE.key(status)
81
+
82
+ # support using symbols, but coerce them to numbers
83
+ ::Rack::Utils::SYMBOL_TO_STATUS_CODE[status] ||
84
+ raise(Taro::ArgumentError, "Invalid status: #{status.inspect}")
85
+ end
86
+
87
+ private
88
+
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 { |t| t.field(nesting, **kwargs) }
93
+ else
94
+ Taro::Types::Coercion.call(kwargs)
95
+ end
96
+ end
97
+
98
+ def raise_missing_route(controller_class, action_name)
99
+ raise(Taro::ArgumentError, "No route found for #{controller_class}##{action_name}")
100
+ end
101
+ end