taro 0.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +22 -0
  4. data/CHANGELOG.md +10 -0
  5. data/README.md +257 -1
  6. data/Rakefile +11 -0
  7. data/lib/taro/config.rb +22 -0
  8. data/lib/taro/errors.rb +12 -0
  9. data/lib/taro/export/base.rb +29 -0
  10. data/lib/taro/export/open_api_v3.rb +190 -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 +118 -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 +13 -0
  29. data/lib/taro/rails/response_validator.rb +109 -0
  30. data/lib/taro/rails/route_finder.rb +35 -0
  31. data/lib/taro/rails/tasks/export.rake +15 -0
  32. data/lib/taro/rails.rb +17 -0
  33. data/lib/taro/types/base_type.rb +17 -0
  34. data/lib/taro/types/coercion.rb +73 -0
  35. data/lib/taro/types/enum_type.rb +43 -0
  36. data/lib/taro/types/field.rb +78 -0
  37. data/lib/taro/types/field_validation.rb +27 -0
  38. data/lib/taro/types/input_type.rb +13 -0
  39. data/lib/taro/types/list_type.rb +30 -0
  40. data/lib/taro/types/object_type.rb +19 -0
  41. data/lib/taro/types/object_types/free_form_type.rb +13 -0
  42. data/lib/taro/types/object_types/no_content_type.rb +16 -0
  43. data/lib/taro/types/object_types/page_info_type.rb +6 -0
  44. data/lib/taro/types/object_types/page_type.rb +45 -0
  45. data/lib/taro/types/scalar/boolean_type.rb +19 -0
  46. data/lib/taro/types/scalar/float_type.rb +15 -0
  47. data/lib/taro/types/scalar/integer_type.rb +11 -0
  48. data/lib/taro/types/scalar/iso8601_date_type.rb +23 -0
  49. data/lib/taro/types/scalar/iso8601_datetime_type.rb +25 -0
  50. data/lib/taro/types/scalar/string_type.rb +15 -0
  51. data/lib/taro/types/scalar/timestamp_type.rb +23 -0
  52. data/lib/taro/types/scalar/uuid_v4_type.rb +22 -0
  53. data/lib/taro/types/scalar_type.rb +7 -0
  54. data/lib/taro/types/shared/additional_properties.rb +12 -0
  55. data/lib/taro/types/shared/custom_field_resolvers.rb +33 -0
  56. data/lib/taro/types/shared/derivable_types.rb +9 -0
  57. data/lib/taro/types/shared/description.rb +9 -0
  58. data/lib/taro/types/shared/errors.rb +13 -0
  59. data/lib/taro/types/shared/fields.rb +57 -0
  60. data/lib/taro/types/shared/item_type.rb +16 -0
  61. data/lib/taro/types/shared/object_coercion.rb +16 -0
  62. data/lib/taro/types/shared/openapi_name.rb +30 -0
  63. data/lib/taro/types/shared/openapi_type.rb +27 -0
  64. data/lib/taro/types/shared/rendering.rb +22 -0
  65. data/lib/taro/types/shared.rb +3 -0
  66. data/lib/taro/types.rb +3 -0
  67. data/lib/taro/version.rb +2 -3
  68. data/lib/taro.rb +1 -6
  69. data/tasks/benchmark.rake +40 -0
  70. data/tasks/benchmark_1kb.json +23 -0
  71. metadata +91 -7
@@ -0,0 +1,118 @@
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 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
+ def check_return_kwargs(kwargs)
102
+ if kwargs.key?(:null)
103
+ raise Taro::ArgumentError, <<~MSG
104
+ `null:` is not supported for top-level returns. If you want a nullable return
105
+ value, nest it, e.g. `returns :str, type: 'String', null: true`.
106
+ MSG
107
+ end
108
+
109
+ bad_keys = kwargs.keys - (Taro::Types::Coercion::KEYS + %i[code defined_at desc])
110
+ return if bad_keys.empty?
111
+
112
+ raise Taro::ArgumentError, "Invalid `returns` options: #{bad_keys.join(', ')}"
113
+ end
114
+
115
+ def raise_missing_route(controller_class, action_name)
116
+ raise(Taro::ArgumentError, "No route found for #{controller_class}##{action_name}")
117
+ end
118
+ end
@@ -0,0 +1,24 @@
1
+ # Buffers api declarations in rails controllers (e.g. `param :foo, ...`)
2
+ # until the next action method is defined (e.g. `def create`).
3
+ module Taro::Rails::DeclarationBuffer
4
+ def buffered_declaration(controller_class)
5
+ buffered_declarations[controller_class] ||= Taro::Rails::Declaration.new
6
+ end
7
+
8
+ def buffered_declarations
9
+ @buffered_declarations ||= {}
10
+ end
11
+
12
+ def apply_buffered_declaration(controller_class, action_name)
13
+ declaration = pop_buffered_declaration(controller_class)
14
+ return unless declaration
15
+
16
+ declaration.finalize(controller_class:, action_name:)
17
+
18
+ Taro::Rails.apply(declaration:, controller_class:, action_name:)
19
+ end
20
+
21
+ def pop_buffered_declaration(controller_class)
22
+ buffered_declarations.delete(controller_class)
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ module Taro::Rails::DSL
2
+ def api(summary, **kwargs)
3
+ Taro::Rails.buffered_declaration(self).add_info(summary, **kwargs)
4
+ end
5
+
6
+ def param(param_name, **kwargs)
7
+ Taro::Rails.buffered_declaration(self).add_param(param_name, **kwargs)
8
+ end
9
+
10
+ def returns(field_name = nil, **kwargs)
11
+ Taro::Rails.buffered_declaration(self).add_return(field_name, **kwargs)
12
+ end
13
+
14
+ def method_added(method_name)
15
+ Taro::Rails.apply_buffered_declaration(self, method_name)
16
+ super
17
+ end
18
+ end
@@ -0,0 +1,19 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/base'
3
+
4
+ class Taro::Rails::Generators::InstallGenerator < ::Rails::Generators::Base
5
+ desc 'Set up Taro base type files in your Rails app'
6
+
7
+ class_option :dir, type: :string, default: "app/types"
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ # :nocov:
12
+ def create_type_files
13
+ Dir["#{self.class.source_root}/**/*.erb"].each do |tmpl|
14
+ dest_dir = options[:dir].chomp('/')
15
+ copy_file tmpl, "#{dest_dir}/#{File.basename(tmpl).sub('erb', 'rb')}"
16
+ end
17
+ end
18
+ # :nocov:
19
+ end
@@ -0,0 +1,4 @@
1
+ # This file is generated by taro so you don't have to inherit from
2
+ # Taro::Types::EnumType directly. You can customize or delete it.
3
+ class EnumType < Taro::Types::EnumType
4
+ end
@@ -0,0 +1,10 @@
1
+ # This file is generated by taro.
2
+ # This and ErrorsType are a starting point for unified error presentation.
3
+ # You can use them to render errors from various sources (ActiveRecord etc.)
4
+ # and render error responses in rescue_from in a consistent way.
5
+ # You can customize these files to fit your needs, or delete them.
6
+ class ErrorType < ObjectType
7
+ field :attribute, type: 'String', null: true, desc: 'Attribute name'
8
+ field :code, type: 'String', null: false, method: :type, desc: 'Error code'
9
+ field :message, type: 'String', null: true, desc: 'Error message'
10
+ end
@@ -0,0 +1,25 @@
1
+ # This file is generated by taro.
2
+ # This and ErrorType are a starting point for unified error presentation.
3
+ # You can use them to render errors from various sources (ActiveRecord etc.)
4
+ # and render error responses in rescue_from in a consistent way.
5
+ # You can customize these files to fit your needs, or delete them.
6
+ class ErrorsType < Taro::Types::ListType
7
+ self.item_type = ErrorType
8
+
9
+ def coerce_input
10
+ input_error 'ErrorsType cannot be used as input type'
11
+ end
12
+
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
24
+ end
25
+ end
@@ -0,0 +1,4 @@
1
+ # This file is generated by taro so you don't have to inherit from
2
+ # Taro::Types::InputType directly. You can customize or delete it.
3
+ class InputType < Taro::Types::InputType
4
+ end
@@ -0,0 +1,4 @@
1
+ # This file is generated by taro so you can do `NoContentType.render`
2
+ # without using the deeply nested original type. You can customize or delete it.
3
+ class NoContentType < Taro::Types::ObjectTypes::NoContentType
4
+ end
@@ -0,0 +1,4 @@
1
+ # This file is generated by taro so you don't have to inherit from
2
+ # Taro::Types::ObjectType directly. You can customize or delete it.
3
+ class ObjectType < Taro::Types::ObjectType
4
+ end
@@ -0,0 +1,4 @@
1
+ # This file is generated by taro so you don't have to inherit from
2
+ # Taro::Types::ScalarType directly. You can customize or delete it.
3
+ class ScalarType < Taro::Types::ScalarType
4
+ end
@@ -0,0 +1,3 @@
1
+ module Taro::Rails::Generators
2
+ Dir[File.join(__dir__, 'generators', '**', '*.rb')].each { |f| require f }
3
+ end
@@ -0,0 +1,29 @@
1
+ Taro::Rails::NormalizedRoute = Data.define(:rails_route) do
2
+ def ignored?
3
+ verb.to_s.empty? || patch_update?
4
+ end
5
+
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
20
+
21
+ def path_params
22
+ openapi_path.scan(/{(\w+)}/).flatten.map(&:to_sym)
23
+ end
24
+
25
+ def endpoint
26
+ controller, action = rails_route.requirements.values_at(:controller, :action)
27
+ "#{controller}##{action}"
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ module Taro::Rails::ParamParsing
2
+ def self.install(controller_class:, action_name:)
3
+ return unless Taro.config.parse_params
4
+
5
+ key = [controller_class, action_name]
6
+ return if installed[key]
7
+
8
+ installed[key] = true
9
+
10
+ controller_class.before_action(only: action_name) do
11
+ declaration = Taro::Rails.declaration_for(self)
12
+ @api_params = declaration.parse_params(params)
13
+ end
14
+ end
15
+
16
+ def self.installed
17
+ @installed ||= {}
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ class Taro::Rails::Railtie < ::Rails::Railtie
2
+ initializer("taro") do |app|
3
+ # The `:action_controller` hook fires for both ActionController::API
4
+ # and ActionController::Base, executing the block in their context.
5
+ ActiveSupport.on_load(:action_controller) do
6
+ extend Taro::Rails::DSL
7
+ end
8
+
9
+ app.reloader.to_prepare do
10
+ Taro::Rails.reset
11
+ end
12
+ end
13
+
14
+ rake_tasks { Dir["#{__dir__}/tasks/**/*.rake"].each { |f| load f } }
15
+ end
@@ -0,0 +1,13 @@
1
+ module Taro::Rails::ResponseValidation
2
+ def self.install(controller_class:)
3
+ controller_class.prepend(self) if Taro.config.validate_response
4
+ end
5
+
6
+ def render(*, **kwargs, &)
7
+ result = super
8
+ if (declaration = Taro::Rails.declaration_for(self))
9
+ Taro::Rails::ResponseValidator.call(self, declaration, kwargs[:json])
10
+ end
11
+ result
12
+ end
13
+ end
@@ -0,0 +1,109 @@
1
+ Taro::Rails::ResponseValidator = Struct.new(:controller, :declaration, :rendered) do
2
+ def self.call(*args)
3
+ new(*args).call
4
+ end
5
+
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
14
+ else
15
+ check_custom_type
16
+ end
17
+ end
18
+
19
+ 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
25
+ end
26
+
27
+ def fail_with(message)
28
+ raise Taro::ResponseError, <<~MSG
29
+ Response validation error for
30
+ #{controller.class}##{controller.action_name}, code #{controller.status}":
31
+ #{message}
32
+ MSG
33
+ end
34
+
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
42
+
43
+ if rendered.key?(nesting)
44
+ rendered[nesting]
45
+ elsif rendered.key?(nesting.to_s)
46
+ rendered[nesting.to_s]
47
+ else
48
+ fail_with_nesting_error
49
+ end
50
+ end
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}."
58
+ end
59
+
60
+ # For scalar and enum types, we want to support e.g. `render json: 42`,
61
+ # and not require using the type as in `BeautifulNumbersEnum.render(42)`.
62
+ def check_scalar(type = declared_return_type, value = subject)
63
+ case type.openapi_type
64
+ when :integer, :number then value.is_a?(Numeric)
65
+ when :string then value.is_a?(String) || value.is_a?(Symbol)
66
+ when :boolean then [true, false].include?(value)
67
+ end || fail_with("Expected a #{type.openapi_type}, got: #{value.class}.")
68
+ end
69
+
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)
77
+ end
78
+
79
+ def check_enum
80
+ # coercion checks non-emptyness + enum match
81
+ declared_return_type.new(subject).coerce_response
82
+ rescue Taro::Error => e
83
+ fail_with(e.message)
84
+ end
85
+
86
+ # For complex/object types, we ensure conformance by checking whether
87
+ # the type was used for rendering. This has performance benefits compared
88
+ # to going over the structure a second time.
89
+ def check_custom_type
90
+ # 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
93
+
94
+ strict_check_custom_type
95
+ end
96
+
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
101
+ was: #{used_type || 'no type'}.
102
+ MSG
103
+
104
+ rendered_object_id == subject.__id__ || fail_with(<<~MSG)
105
+ #{declared_return_type}.render was called, but the result
106
+ of this call was not used in the response.
107
+ MSG
108
+ end
109
+ end
@@ -0,0 +1,35 @@
1
+ module Taro::Rails::RouteFinder
2
+ class << self
3
+ def call(controller_class:, action_name:)
4
+ endpoint = "#{controller_class.controller_path}##{action_name}"
5
+ cache[endpoint] || []
6
+ end
7
+
8
+ def clear_cache
9
+ @cache = nil
10
+ end
11
+
12
+ private
13
+
14
+ def cache
15
+ @cache ||= build_cache
16
+ end
17
+
18
+ def build_cache
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
27
+ end
28
+
29
+ def rails_routes
30
+ # make sure routes are loaded
31
+ Rails.application.reload_routes! unless Rails.application.routes.routes.any?
32
+ Rails.application.routes.routes
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,15 @@
1
+ desc 'Export all taro API declarations to a file'
2
+ task 'taro:export' => :environment do
3
+ # make sure all declarations have been seen
4
+ Rails.application.eager_load!
5
+
6
+ # 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
+
13
+ data = export.result.send("to_#{Taro.config.export_format}")
14
+ File.write(Taro.config.export_path, data)
15
+ end
data/lib/taro/rails.rb ADDED
@@ -0,0 +1,17 @@
1
+ # :nocov:
2
+ return unless defined?(::Rails)
3
+ # :nocov:
4
+
5
+ module Taro::Rails
6
+ Dir[File.join(__dir__, "rails", "*.rb")].each { |f| require f }
7
+
8
+ extend ActiveDeclarations
9
+ extend DeclarationBuffer
10
+
11
+ def self.reset
12
+ buffered_declarations.clear
13
+ declarations_map.clear
14
+ RouteFinder.clear_cache
15
+ Taro::Types::BaseType.last_render = nil
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # Abstract base class for all types.
2
+ #
3
+ # Concrete type classes must set `self.openapi_type` and implement
4
+ # the `#coerce_input` and `#coerce_response` methods.
5
+ #
6
+ # Instances of types are initialized with the object that they represent.
7
+ # The object is a parameter hash for inputs and a manually passed hash
8
+ # or object when rendering a response.
9
+ Taro::Types::BaseType = Data.define(:object) do
10
+ require_relative "shared"
11
+ extend Taro::Types::Shared::AdditionalProperties
12
+ extend Taro::Types::Shared::Description
13
+ extend Taro::Types::Shared::OpenAPIName
14
+ extend Taro::Types::Shared::OpenAPIType
15
+ extend Taro::Types::Shared::Rendering
16
+ include Taro::Types::Shared::Errors
17
+ end
@@ -0,0 +1,73 @@
1
+ module Taro::Types::Coercion
2
+ KEYS = %i[type array_of page_of].freeze
3
+
4
+ class << self
5
+ def call(arg)
6
+ validate_hash(arg)
7
+ from_hash(arg)
8
+ end
9
+
10
+ private
11
+
12
+ def validate_hash(arg)
13
+ arg.is_a?(Hash) || raise(Taro::ArgumentError, <<~MSG)
14
+ Type coercion argument must be a Hash, got: #{arg.inspect} (#{arg.class})
15
+ MSG
16
+
17
+ types = arg.slice(*KEYS)
18
+ types.size == 1 || raise(Taro::ArgumentError, <<~MSG)
19
+ Exactly one of type, array_of, or page_of must be given, got: #{types}
20
+ MSG
21
+ end
22
+
23
+ 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'
32
+ end
33
+ end
34
+
35
+ def from_string(arg)
36
+ shortcuts[arg] || from_class(Object.const_get(arg.to_s))
37
+ rescue NameError
38
+ raise Taro::ArgumentError, <<~MSG
39
+ Unsupported type: #{arg}. It should be a type-class name
40
+ or one of #{shortcuts.keys.map(&:inspect).join(', ')}.
41
+ MSG
42
+ end
43
+
44
+ def from_class(arg)
45
+ arg < Taro::Types::BaseType || raise(Taro::ArgumentError, <<~MSG)
46
+ Unsupported type: #{arg}. It should be a subclass of Taro::Types::BaseType.
47
+ MSG
48
+
49
+ arg
50
+ end
51
+
52
+ # Map some Ruby class names and other shortcuts to built-in types
53
+ # to support e.g. `returns 'String'`, or `field :foo, type: 'Boolean'` etc.
54
+ require 'date'
55
+ def shortcuts
56
+ @shortcuts ||= {
57
+ # rubocop:disable Layout/HashAlignment - buggy cop
58
+ 'Boolean' => Taro::Types::Scalar::BooleanType,
59
+ 'Date' => Taro::Types::Scalar::ISO8601DateType,
60
+ 'DateTime' => Taro::Types::Scalar::ISO8601DateTimeType,
61
+ 'Float' => Taro::Types::Scalar::FloatType,
62
+ 'FreeForm' => Taro::Types::ObjectTypes::FreeFormType,
63
+ 'Integer' => Taro::Types::Scalar::IntegerType,
64
+ 'NoContent' => Taro::Types::ObjectTypes::NoContentType,
65
+ 'String' => Taro::Types::Scalar::StringType,
66
+ 'Time' => Taro::Types::Scalar::ISO8601DateTimeType,
67
+ 'Timestamp' => Taro::Types::Scalar::TimestampType,
68
+ 'UUID' => Taro::Types::Scalar::UUIDv4Type,
69
+ # rubocop:enable Layout/HashAlignment - buggy cop
70
+ }.freeze
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,43 @@
1
+ # Abstract class.
2
+ class Taro::Types::EnumType < Taro::Types::BaseType
3
+ extend Taro::Types::Shared::ItemType
4
+
5
+ def self.value(value)
6
+ self.item_type = Taro::Types::Coercion.call(type: value.class.name)
7
+ @openapi_type ||= item_type.openapi_type
8
+ values << value
9
+ end
10
+
11
+ def self.values
12
+ @values ||= []
13
+ end
14
+
15
+ def coerce_input
16
+ self.class.raise_if_empty_enum
17
+ value = self.class.item_type.new(object).coerce_input
18
+ if self.class.values.include?(value)
19
+ value
20
+ else
21
+ input_error("must be #{self.class.values.map(&:inspect).join(' or ')}")
22
+ end
23
+ end
24
+
25
+ def coerce_response
26
+ self.class.raise_if_empty_enum
27
+ value = self.class.item_type.new(object).coerce_response
28
+ if self.class.values.include?(value)
29
+ value
30
+ else
31
+ response_error("must be #{self.class.values.map(&:inspect).join(' or ')}")
32
+ end
33
+ end
34
+
35
+ def self.raise_if_empty_enum
36
+ values.empty? && raise(Taro::RuntimeError, "Enum #{self} has no values")
37
+ end
38
+
39
+ def self.inherited(subclass)
40
+ subclass.instance_variable_set(:@values, values.dup)
41
+ super
42
+ end
43
+ end