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.
- checksums.yaml +4 -4
- data/.rspec +3 -0
- data/.rubocop.yml +22 -0
- data/CHANGELOG.md +10 -0
- data/README.md +257 -1
- data/Rakefile +11 -0
- data/lib/taro/config.rb +22 -0
- data/lib/taro/errors.rb +12 -0
- data/lib/taro/export/base.rb +29 -0
- data/lib/taro/export/open_api_v3.rb +190 -0
- data/lib/taro/export.rb +3 -0
- data/lib/taro/rails/active_declarations.rb +19 -0
- data/lib/taro/rails/declaration.rb +118 -0
- data/lib/taro/rails/declaration_buffer.rb +24 -0
- data/lib/taro/rails/dsl.rb +18 -0
- data/lib/taro/rails/generators/install_generator.rb +19 -0
- data/lib/taro/rails/generators/templates/enum_type.erb +4 -0
- data/lib/taro/rails/generators/templates/error_type.erb +10 -0
- data/lib/taro/rails/generators/templates/errors_type.erb +25 -0
- data/lib/taro/rails/generators/templates/input_type.erb +4 -0
- data/lib/taro/rails/generators/templates/no_content_type.erb +4 -0
- data/lib/taro/rails/generators/templates/object_type.erb +4 -0
- data/lib/taro/rails/generators/templates/scalar_type.erb +4 -0
- data/lib/taro/rails/generators.rb +3 -0
- data/lib/taro/rails/normalized_route.rb +29 -0
- data/lib/taro/rails/param_parsing.rb +19 -0
- data/lib/taro/rails/railtie.rb +15 -0
- data/lib/taro/rails/response_validation.rb +13 -0
- data/lib/taro/rails/response_validator.rb +109 -0
- data/lib/taro/rails/route_finder.rb +35 -0
- data/lib/taro/rails/tasks/export.rake +15 -0
- data/lib/taro/rails.rb +17 -0
- data/lib/taro/types/base_type.rb +17 -0
- data/lib/taro/types/coercion.rb +73 -0
- data/lib/taro/types/enum_type.rb +43 -0
- data/lib/taro/types/field.rb +78 -0
- data/lib/taro/types/field_validation.rb +27 -0
- data/lib/taro/types/input_type.rb +13 -0
- data/lib/taro/types/list_type.rb +30 -0
- data/lib/taro/types/object_type.rb +19 -0
- data/lib/taro/types/object_types/free_form_type.rb +13 -0
- data/lib/taro/types/object_types/no_content_type.rb +16 -0
- data/lib/taro/types/object_types/page_info_type.rb +6 -0
- data/lib/taro/types/object_types/page_type.rb +45 -0
- data/lib/taro/types/scalar/boolean_type.rb +19 -0
- data/lib/taro/types/scalar/float_type.rb +15 -0
- data/lib/taro/types/scalar/integer_type.rb +11 -0
- data/lib/taro/types/scalar/iso8601_date_type.rb +23 -0
- data/lib/taro/types/scalar/iso8601_datetime_type.rb +25 -0
- data/lib/taro/types/scalar/string_type.rb +15 -0
- data/lib/taro/types/scalar/timestamp_type.rb +23 -0
- data/lib/taro/types/scalar/uuid_v4_type.rb +22 -0
- data/lib/taro/types/scalar_type.rb +7 -0
- data/lib/taro/types/shared/additional_properties.rb +12 -0
- data/lib/taro/types/shared/custom_field_resolvers.rb +33 -0
- data/lib/taro/types/shared/derivable_types.rb +9 -0
- data/lib/taro/types/shared/description.rb +9 -0
- data/lib/taro/types/shared/errors.rb +13 -0
- data/lib/taro/types/shared/fields.rb +57 -0
- data/lib/taro/types/shared/item_type.rb +16 -0
- data/lib/taro/types/shared/object_coercion.rb +16 -0
- data/lib/taro/types/shared/openapi_name.rb +30 -0
- data/lib/taro/types/shared/openapi_type.rb +27 -0
- data/lib/taro/types/shared/rendering.rb +22 -0
- data/lib/taro/types/shared.rb +3 -0
- data/lib/taro/types.rb +3 -0
- data/lib/taro/version.rb +2 -3
- data/lib/taro.rb +1 -6
- data/tasks/benchmark.rake +40 -0
- data/tasks/benchmark_1kb.json +23 -0
- 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,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,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
|