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
data/lib/taro/export.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Taro::Export
2
- Dir[File.join(__dir__, "export", "*.rb")].each { |f| require f }
2
+ Dir[File.join(__dir__, "export", "*.rb")].each { |f| require_relative f }
3
3
  end
data/lib/taro/none.rb ADDED
@@ -0,0 +1,2 @@
1
+ # placeholder for not-given keyword arguments
2
+ Taro::None = Object.new
@@ -1,19 +1,11 @@
1
1
  module Taro::Rails::ActiveDeclarations
2
2
  def apply(declaration:, controller_class:, action_name:)
3
- (declarations_map[controller_class] ||= {})[action_name] = declaration
3
+ Taro.declarations["#{controller_class.name}##{action_name}"] = declaration
4
4
  Taro::Rails::ParamParsing.install(controller_class:, action_name:)
5
5
  Taro::Rails::ResponseValidation.install(controller_class:)
6
6
  end
7
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
8
  def declaration_for(controller)
17
- declarations_map[controller.class].to_h[controller.action_name.to_sym]
9
+ Taro.declarations["#{controller.class.name}##{controller.action_name}"]
18
10
  end
19
11
  end
@@ -1,122 +1,17 @@
1
- class Taro::Rails::Declaration
2
- attr_reader :desc, :summary, :params, :return_defs, :return_descriptions, :return_nestings, :routes, :tags
3
-
4
- def initialize
5
- @params = Class.new(Taro::Types::InputType)
6
- @return_defs = {}
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[:nesting] = nesting
28
- check_return_kwargs(kwargs)
29
-
30
- kwargs[:defined_at] = caller_locations(1..2)[1]
31
- return_defs[status] = kwargs
32
-
33
- # response desc is required in openapi 3 – fall back to status code
34
- return_descriptions[status] = desc || code.to_s
35
-
36
- # if a field name is provided, the response should be nested
37
- return_nestings[status] = nesting if nesting
38
- end
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
-
46
- def raise_if_already_declared(status)
47
- return_defs[status] &&
48
- raise(Taro::ArgumentError, "response for status #{status} already declared")
49
- end
50
-
51
- def parse_params(rails_params)
52
- params.new(rails_params.to_unsafe_h).coerce_input
53
- end
1
+ class Taro::Rails::Declaration < Taro::Declaration
2
+ attr_reader :controller_class, :action_name
54
3
 
55
4
  def finalize(controller_class:, action_name:)
56
- routes = Taro::Rails::RouteFinder.call(controller_class:, action_name:)
57
- routes.any? || raise_missing_route(controller_class, action_name)
58
- self.routes = routes
59
- end
60
-
61
- def routes=(arg)
62
- arg.is_a?(Array) || raise(Taro::ArgumentError, 'routes must be an Array')
63
- @routes = arg
64
- end
65
-
66
- def polymorphic_route?
67
- routes.size > 1
68
- end
69
-
70
- require 'rack'
71
- def self.coerce_status_to_int(status)
72
- # support using http status numbers directly
73
- return status if ::Rack::Utils::SYMBOL_TO_STATUS_CODE.key(status)
74
-
75
- # support using symbols, but coerce them to numbers
76
- ::Rack::Utils::SYMBOL_TO_STATUS_CODE[status] ||
77
- raise(Taro::ArgumentError, "Invalid status: #{status.inspect}")
78
- end
79
-
80
- private
81
-
82
- def check_return_kwargs(kwargs)
83
- # For nested returns, evaluate_return_def calls ::field, which validates
84
- # field options, but does not trigger type autoloading.
85
- return evaluate_return_def(**kwargs) if kwargs[:nesting]
86
-
87
- if kwargs.key?(:null)
88
- raise Taro::ArgumentError, <<~MSG
89
- `null:` is not supported for top-level returns. If you want a nullable return
90
- value, nest it, e.g. `returns :str, type: 'String', null: true`.
91
- MSG
92
- end
93
-
94
- bad_keys = kwargs.keys - (Taro::Types::Coercion.keys + %i[code desc nesting])
95
- return if bad_keys.empty?
96
-
97
- raise Taro::ArgumentError, "Invalid `returns` options: #{bad_keys.join(', ')}"
98
- end
99
-
100
- def evaluate_return_defs
101
- return_defs.transform_values { |defi| evaluate_return_def(**defi) }
102
- end
103
-
104
- def evaluate_return_def(nesting:, **kwargs)
105
- if nesting
106
- # ad-hoc return type, requiring the actual return type to be nested
107
- Class.new(Taro::Types::ObjectType).tap do |type|
108
- type.field(nesting, null: false, **kwargs)
109
- end
110
- else
111
- Taro::Types::Coercion.call(kwargs)
112
- end
5
+ @controller_class = controller_class
6
+ @action_name = action_name
7
+ @params.define_name("InputType(#{endpoint})")
113
8
  end
114
9
 
115
- def raise_missing_route(controller_class, action_name)
116
- raise(Taro::ArgumentError, "No route found for #{controller_class}##{action_name}")
10
+ def endpoint
11
+ action_name && "#{controller_class}##{action_name}"
117
12
  end
118
13
 
119
- def <=>(other)
120
- routes.first.openapi_operation_id <=> other.routes.first.openapi_operation_id
14
+ def routes
15
+ @routes ||= Taro::Rails::RouteFinder.call(controller_class:, action_name:)
121
16
  end
122
17
  end
@@ -2,7 +2,8 @@
2
2
  # until the next action method is defined (e.g. `def create`).
3
3
  module Taro::Rails::DeclarationBuffer
4
4
  def buffered_declaration(controller_class)
5
- buffered_declarations[controller_class] ||= Taro::Rails::Declaration.new
5
+ buffered_declarations[controller_class] ||=
6
+ Taro::Rails::Declaration.new(controller_class)
6
7
  end
7
8
 
8
9
  def buffered_declarations
@@ -1,18 +1,25 @@
1
1
  module Taro::Rails::DSL
2
- def api(summary, **kwargs)
3
- Taro::Rails.buffered_declaration(self).add_info(summary, **kwargs)
2
+ def api(summary, **)
3
+ Taro::Rails.buffered_declaration(self).add_info(summary, **)
4
4
  end
5
5
 
6
- def param(param_name, **kwargs)
7
- Taro::Rails.buffered_declaration(self).add_param(param_name, **kwargs)
6
+ def param(param_name, **)
7
+ defined_at = caller_locations(1..1)[0]
8
+ Taro::Rails.buffered_declaration(self).add_param(param_name, defined_at:, **)
8
9
  end
9
10
 
10
- def returns(field_name = nil, **kwargs)
11
- Taro::Rails.buffered_declaration(self).add_return(field_name, **kwargs)
11
+ def returns(nesting = nil, **)
12
+ defined_at = caller_locations(1..1)[0]
13
+ Taro::Rails.buffered_declaration(self).add_return(nesting, defined_at:, **)
12
14
  end
13
15
 
14
16
  def method_added(method_name)
15
17
  Taro::Rails.apply_buffered_declaration(self, method_name)
16
18
  super
17
19
  end
20
+
21
+ def common_return(nesting = nil, **)
22
+ defined_at = caller_locations(1..1)[0]
23
+ Taro::CommonReturns.define(self, nesting, defined_at:, **)
24
+ end
18
25
  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
- template tmpl, "#{dest_dir}/#{File.basename(tmpl).sub('erb', 'rb')}"
15
+ template tmpl, "#{dest_dir}/#{File.basename(tmpl, '.erb')}.rb"
16
16
  end
17
17
  end
18
18
  # :nocov:
@@ -25,6 +25,6 @@ class ErrorsType < Taro::Types::ListType
25
25
  response_error("must be an Enumerable or an object with errors")
26
26
  end
27
27
 
28
- list.map { |el| self.class.item_type.new(el).coerce_response }
28
+ list.map { |el| self.class.item_type.new(el).cached_coerce_response }
29
29
  end
30
30
  end
@@ -0,0 +1,4 @@
1
+ # This file is generated by taro so you don't have to inherit from
2
+ # Taro::Types::ResponseType directly. You can customize or delete it.
3
+ class ResponseType < Taro::Types::ResponseType
4
+ end
@@ -1,3 +1,3 @@
1
1
  module Taro::Rails::Generators
2
- Dir[File.join(__dir__, 'generators', '**', '*.rb')].each { |f| require f }
2
+ Dir[File.join(__dir__, 'generators', '**', '*.rb')].each { |f| require_relative f }
3
3
  end
@@ -1,49 +1,31 @@
1
- Taro::Rails::NormalizedRoute = Data.define(:rails_route) do
2
- def ignored?
3
- verb.to_s.empty? || patch_update?
4
- end
1
+ require_relative '../route'
5
2
 
6
- # Journey::Route#verb is a String. Its usually something like 'POST', but
7
- # manual matched routes may have e.g. 'GET|POST' (🤢). We only need one copy.
8
- def verb
9
- rails_route.verb.to_s.scan(/\w+/).sort.last&.downcase
10
- end
11
-
12
- # Rails has both PATCH and PUT routes for updates. We only need one copy.
13
- def patch_update?
14
- verb == 'patch' && rails_route.requirements[:action] == 'update'
15
- end
16
-
17
- def openapi_path
18
- rails_route.path.spec.to_s.gsub('(.:format)', '').gsub(/:(\w+)/, '{\1}')
19
- end
3
+ class Taro::Rails::NormalizedRoute < Taro::Route
4
+ def initialize(rails_route)
5
+ action, controller = rails_route.requirements.values_at(:action, :controller)
6
+ # Journey::Route#verb is a String. Its usually something like 'POST', but
7
+ # manual matched routes may have e.g. 'GET|POST' (🤢). We only need one copy.
8
+ verb = rails_route.verb.to_s.scan(/\w+/).sort.last.to_s.downcase
9
+ openapi_operation_id = "#{verb}_#{action}_#{controller}".gsub('/', '__')
10
+ openapi_path = rails_route.path.spec.to_s.gsub('(.:format)', '').gsub(/:(\w+)/, '{\1}')
11
+ endpoint = "#{controller}##{action}"
20
12
 
21
- def openapi_operation_id
22
- "#{verb}_#{action}_#{controller.gsub('/', '__')}"
13
+ super(endpoint:, openapi_operation_id:, openapi_path:, verb:)
23
14
  end
24
15
 
25
- def path_params
26
- openapi_path.scan(/{(\w+)}/).flatten.map(&:to_sym)
27
- end
28
-
29
- def endpoint
30
- "#{controller}##{action}"
31
- end
32
-
33
- def action
34
- rails_route.requirements[:action]
16
+ def ignored?
17
+ internal? || patch_update?
35
18
  end
36
19
 
37
- def controller
38
- rails_route.requirements[:controller]
39
- end
20
+ private
40
21
 
41
- def can_have_request_body?
42
- %w[patch post put].include?(verb)
22
+ # Internal routes of rails sometimes have no verb.
23
+ def internal?
24
+ verb.empty?
43
25
  end
44
26
 
45
- def inspect
46
- %(#<#{self.class} "#{verb} #{openapi_path}">)
27
+ # Rails has both PATCH and PUT routes for updates. We only need one copy.
28
+ def patch_update?
29
+ verb == 'patch' && endpoint.end_with?('#update')
47
30
  end
48
- alias to_s inspect
49
31
  end
@@ -7,9 +7,11 @@ module Taro::Rails::ParamParsing
7
7
 
8
8
  installed[key] = true
9
9
 
10
- controller_class.before_action(only: action_name) do
11
- declaration = Taro::Rails.declaration_for(self)
12
- @api_params = declaration.parse_params(params)
10
+ controller_class.prepend_before_action(only: action_name) do
11
+ declaration = Taro::Rails.declaration_for(self) || raise(
12
+ Taro::InvariantError, "missing Declaration for #{controller_class}##{action_name}"
13
+ )
14
+ @api_params = declaration.params.new(params.to_unsafe_h).coerce_input
13
15
  end
14
16
  end
15
17
 
@@ -9,6 +9,10 @@ class Taro::Rails::Railtie < ::Rails::Railtie
9
9
  app.reloader.to_prepare do
10
10
  Taro::Rails.reset
11
11
  end
12
+
13
+ app.config.after_initialize do
14
+ Taro::Cache.cache_instance = Rails.cache
15
+ end
12
16
  end
13
17
 
14
18
  rake_tasks { Dir["#{__dir__}/tasks/**/*.rake"].each { |f| load f } }
@@ -1,65 +1,70 @@
1
1
  Taro::Rails::ResponseValidator = Struct.new(:controller, :declaration, :rendered) do
2
- def self.call(*args)
3
- new(*args).call
2
+ def self.call(controller, declaration, rendered)
3
+ new(controller, declaration, rendered).call
4
4
  end
5
5
 
6
6
  def call
7
- if declared_return_type < Taro::Types::ScalarType
8
- check_scalar
9
- elsif declared_return_type < Taro::Types::ListType &&
10
- declared_return_type.item_type < Taro::Types::ScalarType
11
- check_scalar_array
12
- elsif declared_return_type < Taro::Types::EnumType
13
- check_enum
7
+ if declared_return_type.nil?
8
+ fail_if_declaration_expected
9
+ elsif declared_return_type < Taro::Types::NestedResponseType
10
+ field = declared_return_type.nesting_field
11
+ check(field.type, denest_rendered(field.name))
14
12
  else
15
- check_custom_type
13
+ check(declared_return_type, rendered)
16
14
  end
17
15
  end
18
16
 
19
17
  def declared_return_type
20
- @declared_return_type ||= begin
21
- return_type = declaration.returns[controller.status] ||
22
- fail_with('No return type declared for this status.')
23
- nesting ? return_type.fields.fetch(nesting).type : return_type
24
- end
18
+ @declared_return_type ||= declaration.returns[controller.status]
19
+ end
20
+
21
+ # Rack, Rails and gems commonly trigger rendering of 400, 404, 500 etc.
22
+ # Declaring these codes should be optional. Otherwise the api schema would get
23
+ # bloated as there are no "global" return declarations in OpenAPI v3, and we'd
24
+ # need to export all of these for every single endpoint. v4 might change this.
25
+ # https://github.com/OAI/OpenAPI-Specification/issues/521
26
+ def fail_if_declaration_expected
27
+ controller.status.to_s.match?(/^[123]|422/) && fail_with(<<~MSG)
28
+ No return type declared for this status.
29
+ MSG
25
30
  end
26
31
 
27
32
  def fail_with(message)
28
- raise Taro::ResponseError, <<~MSG
33
+ raise Taro::ResponseError.new(<<~MSG, rendered, self)
29
34
  Response validation error for
30
35
  #{controller.class}##{controller.action_name}, code #{controller.status}":
31
36
  #{message}
32
37
  MSG
33
38
  end
34
39
 
35
- # support `returns :some_nesting, type: 'SomeType'` (ad-hoc return type)
36
- def nesting
37
- @nesting ||= declaration.return_nestings[controller.status]
38
- end
39
-
40
- def denest_rendered
41
- assert_rendered_is_a_hash
40
+ # support `returns :some_nesting, type: 'SomeType'`
41
+ # used like `render json: { some_nesting: SomeType.render(some_object) }`
42
+ def denest_rendered(nesting)
43
+ rendered.is_a?(Hash) || fail_with("Expected Hash, got #{rendered.class}.")
42
44
 
43
45
  if rendered.key?(nesting)
44
46
  rendered[nesting]
45
- elsif rendered.key?(nesting.to_s)
46
- rendered[nesting.to_s]
47
47
  else
48
- fail_with_nesting_error
48
+ fail_with "Expected key :#{nesting}, got: #{rendered.keys}."
49
49
  end
50
50
  end
51
51
 
52
- def assert_rendered_is_a_hash
53
- rendered.is_a?(Hash) || fail_with("Expected Hash, got #{rendered.class}.")
54
- end
55
-
56
- def fail_with_nesting_error
57
- fail_with "Expected key :#{nesting}, got: #{rendered.keys}."
52
+ def check(type, value)
53
+ if type < Taro::Types::ScalarType
54
+ check_scalar(type, value)
55
+ elsif type < Taro::Types::ListType &&
56
+ type.item_type < Taro::Types::ScalarType
57
+ check_scalar_array(type, value)
58
+ elsif type < Taro::Types::EnumType
59
+ check_enum(type, value)
60
+ else
61
+ check_custom_type(type, value)
62
+ end
58
63
  end
59
64
 
60
65
  # For scalar and enum types, we want to support e.g. `render json: 42`,
61
66
  # and not require using the type as in `BeautifulNumbersEnum.render(42)`.
62
- def check_scalar(type = declared_return_type, value = subject)
67
+ def check_scalar(type, value)
63
68
  case type.openapi_type
64
69
  when :integer, :number then value.is_a?(Numeric)
65
70
  when :string then value.is_a?(String) || value.is_a?(Symbol)
@@ -67,18 +72,14 @@ Taro::Rails::ResponseValidator = Struct.new(:controller, :declaration, :rendered
67
72
  end || fail_with("Expected a #{type.openapi_type}, got: #{value.class}.")
68
73
  end
69
74
 
70
- def subject
71
- @subject ||= nesting ? denest_rendered : rendered
72
- end
73
-
74
- def check_scalar_array
75
- subject.is_a?(Array) || fail_with('Expected an Array.')
76
- subject.empty? || check_scalar(declared_return_type.item_type, subject.first)
75
+ def check_scalar_array(type, value)
76
+ value.is_a?(Array) || fail_with('Expected an Array.')
77
+ value.empty? || check_scalar(type.item_type, value.first)
77
78
  end
78
79
 
79
- def check_enum
80
+ def check_enum(type, value)
80
81
  # coercion checks non-emptyness + enum match
81
- declared_return_type.new(subject).coerce_response
82
+ type.new(value).cached_coerce_response
82
83
  rescue Taro::Error => e
83
84
  fail_with(e.message)
84
85
  end
@@ -86,23 +87,23 @@ Taro::Rails::ResponseValidator = Struct.new(:controller, :declaration, :rendered
86
87
  # For complex/object types, we ensure conformance by checking whether
87
88
  # the type was used for rendering. This has performance benefits compared
88
89
  # to going over the structure a second time.
89
- def check_custom_type
90
+ def check_custom_type(type, value)
90
91
  # Ignore types without a specified structure.
91
- return if declared_return_type <= Taro::Types::ObjectTypes::FreeFormType
92
- return if declared_return_type <= Taro::Types::ObjectTypes::NoContentType
92
+ return if type <= Taro::Types::ObjectTypes::FreeFormType
93
+ return if type <= Taro::Types::ObjectTypes::NoContentType
93
94
 
94
- strict_check_custom_type
95
+ strict_check_custom_type(type, value)
95
96
  end
96
97
 
97
- def strict_check_custom_type
98
- used_type, rendered_object_id = declared_return_type.last_render
99
- used_type&.<=(declared_return_type) || fail_with(<<~MSG)
100
- Expected to use #{declared_return_type}.render, but the last type rendered
98
+ def strict_check_custom_type(type, value)
99
+ used_type, rendered_object_id = type.last_render
100
+ used_type == type || used_type&.<(type) || fail_with(<<~MSG)
101
+ Expected to use #{type}.render, but the last type rendered
101
102
  was: #{used_type || 'no type'}.
102
103
  MSG
103
104
 
104
- rendered_object_id == subject.__id__ || fail_with(<<~MSG)
105
- #{declared_return_type}.render was called, but the result
105
+ rendered_object_id == value.__id__ || fail_with(<<~MSG)
106
+ #{type}.render was called, but the result
106
107
  of this call was not used in the response.
107
108
  MSG
108
109
  end
@@ -17,13 +17,11 @@ module Taro::Rails::RouteFinder
17
17
 
18
18
  def build_cache
19
19
  # Build a Hash like
20
- # { 'users#show' } => [#<NormalizedRoute>, #<NormalizedRoute>] }
21
- rails_routes.each_with_object({}) do |rails_route, hash|
22
- route = Taro::Rails::NormalizedRoute.new(rails_route:)
23
- next if route.ignored?
24
-
25
- (hash[route.endpoint] ||= []) << route
26
- end
20
+ # { 'users#show' => [#<NormalizedRoute>, #<NormalizedRoute>] }
21
+ rails_routes
22
+ .map { |r| Taro::Rails::NormalizedRoute.new(r) }
23
+ .reject(&:ignored?)
24
+ .group_by(&:endpoint)
27
25
  end
28
26
 
29
27
  def rails_routes
@@ -3,17 +3,18 @@ task 'taro:export' => :environment do
3
3
  # make sure all declarations have been seen
4
4
  Rails.application.eager_load!
5
5
 
6
+ title = Taro.config.api_name
7
+ version = Taro.config.api_version
8
+ format = Taro.config.export_format
9
+ path = Taro.config.export_path
6
10
  # the generator / openapi version might become a config option later
7
- export = Taro::Export::OpenAPIv3.call(
8
- declarations: Taro::Rails.declarations,
9
- title: Taro.config.api_name,
10
- version: Taro.config.api_version,
11
- )
12
11
 
13
- data = export.send("to_#{Taro.config.export_format}")
12
+ export = Taro::Export::OpenAPIv3.call(title:, version:)
14
13
 
15
- FileUtils.mkdir_p(File.dirname(Taro.config.export_path))
16
- File.write(Taro.config.export_path, data)
14
+ data = export.send("to_#{format}")
17
15
 
18
- puts "Exported #{Taro.config.api_name} to #{Taro.config.export_path}"
16
+ FileUtils.mkdir_p(File.dirname(path))
17
+ File.write(path, data)
18
+
19
+ puts "Exported the API #{title} v#{version} to #{path}"
19
20
  end
data/lib/taro/rails.rb CHANGED
@@ -3,15 +3,14 @@ return unless defined?(::Rails)
3
3
  # :nocov:
4
4
 
5
5
  module Taro::Rails
6
- Dir[File.join(__dir__, "rails", "*.rb")].each { |f| require f }
6
+ Dir[File.join(__dir__, "rails", "*.rb")].each { |f| require_relative f }
7
7
 
8
8
  extend ActiveDeclarations
9
9
  extend DeclarationBuffer
10
10
 
11
11
  def self.reset
12
12
  buffered_declarations.clear
13
- declarations_map.clear
14
13
  RouteFinder.clear_cache
15
- Taro::Types::BaseType.last_render = nil
14
+ Taro.reset
16
15
  end
17
16
  end
@@ -0,0 +1,43 @@
1
+ # Lazily-evaluated response type definition.
2
+ class Taro::ReturnDef
3
+ attr_reader :code, :defined_at, :desc, :nesting, :params
4
+
5
+ def initialize(code:, defined_at: nil, desc: nil, nesting: nil, **params)
6
+ @code = Taro::StatusCode.coerce_to_int(code)
7
+ @defined_at = defined_at
8
+ @desc = desc
9
+ @nesting = nesting
10
+ @params = params
11
+ validate
12
+ end
13
+
14
+ def evaluate
15
+ if nesting
16
+ Class.new(Taro::Types::NestedResponseType).tap do |type|
17
+ type.field(nesting, defined_at:, null: false, **params)
18
+ end
19
+ else
20
+ Taro::Types::Coercion.call(params)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def validate
27
+ # For nested returns, call ::field, which validates
28
+ # field options, but does not trigger type auto-loading.
29
+ return evaluate if nesting
30
+
31
+ if params.key?(:null)
32
+ raise Taro::ArgumentError, <<~MSG
33
+ `null:` is not supported for top-level returns. If you want a nullable return
34
+ value, nest it, e.g. `returns :str, type: 'String', null: true`.
35
+ MSG
36
+ end
37
+
38
+ bad_keys = params.keys - (Taro::Types::Coercion.keys + %i[defined_at])
39
+ return if bad_keys.empty?
40
+
41
+ raise Taro::ArgumentError, "Invalid `returns` options: #{bad_keys.join(', ')}"
42
+ end
43
+ end
data/lib/taro/route.rb ADDED
@@ -0,0 +1,32 @@
1
+ class Taro::Route
2
+ attr_reader :endpoint, :openapi_operation_id, :openapi_path, :verb
3
+
4
+ def initialize(endpoint:, openapi_operation_id:, openapi_path:, verb:)
5
+ @endpoint = validate_string(endpoint:)
6
+ @openapi_operation_id = validate_string(openapi_operation_id:)
7
+ @openapi_path = validate_string(openapi_path:)
8
+ @verb = validate_string(verb:).downcase
9
+ end
10
+
11
+ def path_params
12
+ openapi_path.scan(/{(\w+)}/).flatten.map(&:to_sym)
13
+ end
14
+
15
+ def can_have_request_body?
16
+ %w[patch post put].include?(verb)
17
+ end
18
+
19
+ def inspect
20
+ %(#<#{self.class} "#{verb} #{openapi_path}">)
21
+ end
22
+ alias to_s inspect
23
+
24
+ private
25
+
26
+ def validate_string(**kwarg)
27
+ name, arg = kwarg.first
28
+ return arg if arg.is_a?(String)
29
+
30
+ raise(Taro::ArgumentError, "#{name} must be a String, got #{arg.class}")
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ require 'rack'
2
+
3
+ module Taro::StatusCode
4
+ def self.coerce_to_int(arg)
5
+ # support using http status numbers directly
6
+ return arg if ::Rack::Utils::SYMBOL_TO_STATUS_CODE.key(arg)
7
+
8
+ # support using symbols, but coerce them to numbers
9
+ ::Rack::Utils::SYMBOL_TO_STATUS_CODE[arg] ||
10
+ raise(Taro::ArgumentError, "Invalid status: #{arg.inspect}")
11
+ end
12
+
13
+ def self.coerce_to_message(arg)
14
+ ::Rack::Utils::HTTP_STATUS_CODES.fetch(coerce_to_int(arg))
15
+ end
16
+ end