taro 0.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
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