taro 1.2.0 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5007f07dcb3230a1a45011138c19cc0f233e0a0e0fa81d64d554ee3d5cc4a82e
4
- data.tar.gz: 99dde57ec71bb2724a29005e644a0bb23d642df11fe4555bd9203340169814df
3
+ metadata.gz: d30f413c950e02e52f99ed96d234acdba122c92ec4e39dd2d35e87c899ed8a13
4
+ data.tar.gz: 5ed455623397ce6fbcb19412710bf98bc7ca2b6898db193a2cf858104a149327
5
5
  SHA512:
6
- metadata.gz: abb5fb481da01a4a50b19e891c3373b42372c97993788ed24c51b07146e087e25d040bc4ea077606a14013633b30f6254f6ced2e939ba9b039b341d95f918211
7
- data.tar.gz: b5dd6c2222598ad3d8ee64c64fd0fc6c40299c3f071b08317d17aa29c3f786924ae4b064f587b1e548ba102e0cc802b8a5adf7ccbadf033795974bfdaae5329a
6
+ metadata.gz: ba99529dd914238371708a80b5f3f6497a4884a042a0e11ec7e9b557107379d0bfa2232b6a71137623658651e5d920f8ad47ab683119ae5ce105261fa627b980
7
+ data.tar.gz: 26b2652184c485aa7970707373cfe4ebfdecd3141fc7e995066060b5a0a5452b1e749d6e16fdc3d99e34a199434ed9edc7400d89fe8f8ed32df442d764d06175
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.4.0] - 2024-11-27
4
+
5
+ ### Added
6
+
7
+ - added operationId to OpenAPI export
8
+
9
+ ### Fixed
10
+
11
+ - fixed potential ref name clashes in OpenAPI export
12
+ - e.g. `FooBar::BazController` & `Foo::BarBazController`
13
+
14
+ ## [1.3.0] - 2024-11-25
15
+
16
+ ### Added
17
+
18
+ - Support for string patterns (on StringType children and in export)
19
+
20
+ ### Fixed
21
+
22
+ - Fixed OpenAPI export of params with enum (inline & type-based)
23
+
3
24
  ## [1.2.0] - 2024-11-18
4
25
 
5
26
  ### Added
data/README.md CHANGED
@@ -305,8 +305,7 @@ end
305
305
  - mixed enums
306
306
  - nullable enums
307
307
  - string format specifications (e.g. binary, int64, password ...)
308
- - string pattern specifications
309
- - string minLength and maxLength
308
+ - string minLength and maxLength (substitute: `self.pattern = /\A.{3,5}\z/`)
310
309
  - number minimum, exclusiveMinimum, maximum, multipleOf
311
310
  - readOnly, writeOnly
312
311
 
@@ -31,9 +31,10 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
31
31
  description: declaration.desc,
32
32
  summary: declaration.summary,
33
33
  tags: declaration.tags,
34
+ operationId: route.openapi_operation_id,
34
35
  parameters: route_parameters(declaration, route),
35
36
  requestBody: request_body(declaration, route),
36
- responses: responses(declaration),
37
+ responses: responses(declaration, route),
37
38
  }.compact,
38
39
  }
39
40
  end
@@ -64,15 +65,22 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
64
65
  end
65
66
 
66
67
  def export_parameter(field)
68
+ validate_path_or_query_parameter(field)
69
+
67
70
  {
68
71
  name: field.name,
69
72
  deprecated: field.deprecated,
70
73
  description: field.desc,
71
74
  required: !field.null,
72
- schema: { type: field.openapi_type },
75
+ schema: export_field(field).except(:deprecated, :description),
73
76
  }.compact
74
77
  end
75
78
 
79
+ def validate_path_or_query_parameter(field)
80
+ [:array, :object].include?(field.type.openapi_type) &&
81
+ raise("Unexpected object as path/query param #{field.name}: #{field.type}")
82
+ end
83
+
76
84
  def request_body(declaration, route)
77
85
  return unless route.can_have_request_body?
78
86
 
@@ -84,10 +92,10 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
84
92
 
85
93
  body_input_type = Class.new(params)
86
94
  body_input_type.fields.replace(body_param_fields)
87
- body_input_type.openapi_name = params.openapi_name
95
+ body_input_type.openapi_name = "#{route.openapi_operation_id}_Input"
88
96
 
89
97
  # For polymorphic routes (more than one for the same declaration),
90
- # we can't use refs because they request body might differ.
98
+ # we can't use refs because their request body might differ:
91
99
  # Different params might be in the path vs. in the request body.
92
100
  use_refs = !declaration.polymorphic_route?
93
101
  schema = request_body_schema(body_input_type, use_refs:)
@@ -102,7 +110,9 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
102
110
  end
103
111
  end
104
112
 
105
- def responses(declaration)
113
+ def responses(declaration, route)
114
+ name_anonymous_return_types(declaration, route)
115
+
106
116
  declaration.returns.sort.to_h do |code, type|
107
117
  [
108
118
  code.to_s,
@@ -114,14 +124,26 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
114
124
  end
115
125
  end
116
126
 
127
+ def name_anonymous_return_types(declaration, route)
128
+ declaration.returns.each do |code, type|
129
+ next if type.openapi_name?
130
+
131
+ type.openapi_name = "#{route.openapi_operation_id}_#{code}_Response"
132
+ end
133
+ end
134
+
117
135
  def export_type(type)
118
- if type < Taro::Types::ScalarType
136
+ if type < Taro::Types::ScalarType && !custom_scalar_type?(type)
119
137
  { type: type.openapi_type }
120
138
  else
121
139
  extract_component_ref(type)
122
140
  end
123
141
  end
124
142
 
143
+ def custom_scalar_type?(type)
144
+ type < Taro::Types::ScalarType && (type.openapi_pattern || type.deprecated)
145
+ end
146
+
125
147
  def export_field(field)
126
148
  if field.type < Taro::Types::ScalarType
127
149
  export_scalar_field(field)
@@ -174,6 +196,8 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
174
196
  enum_type_details(type)
175
197
  elsif type < Taro::Types::ListType
176
198
  list_type_details(type)
199
+ elsif custom_scalar_type?(type)
200
+ custom_scalar_type_details(type)
177
201
  else
178
202
  raise NotImplementedError, "Unsupported type: #{type}"
179
203
  end
@@ -209,6 +233,15 @@ class Taro::Export::OpenAPIv3 < Taro::Export::Base # rubocop:disable Metrics/Cla
209
233
  }.compact
210
234
  end
211
235
 
236
+ def custom_scalar_type_details(scalar)
237
+ {
238
+ type: scalar.openapi_type,
239
+ deprecated: scalar.deprecated,
240
+ description: scalar.desc,
241
+ pattern: scalar.openapi_pattern,
242
+ }.compact
243
+ end
244
+
212
245
  def assert_unique_openapi_name(type)
213
246
  @name_to_type_map ||= {}
214
247
  if (prev = @name_to_type_map[type.openapi_name]) && type != prev
@@ -53,11 +53,6 @@ class Taro::Rails::Declaration
53
53
  end
54
54
 
55
55
  def finalize(controller_class:, action_name:)
56
- add_routes(controller_class:, action_name:)
57
- add_openapi_names(controller_class:, action_name:)
58
- end
59
-
60
- def add_routes(controller_class:, action_name:)
61
56
  routes = Taro::Rails::RouteFinder.call(controller_class:, action_name:)
62
57
  routes.any? || raise_missing_route(controller_class, action_name)
63
58
  self.routes = routes
@@ -72,21 +67,6 @@ class Taro::Rails::Declaration
72
67
  routes.size > 1
73
68
  end
74
69
 
75
- # TODO: these change when the controller class is renamed.
76
- # We might need a way to set `base`. Perhaps as a kwarg to `::api`?
77
- def add_openapi_names(controller_class:, action_name:)
78
- base = "#{controller_class.name.chomp('Controller').gsub('::', '_')}_#{action_name}"
79
- params.openapi_name = "#{base}_Input"
80
- params.define_singleton_method(:name) { openapi_name }
81
-
82
- returns.each do |status, return_type|
83
- next if return_type.openapi_name? # only set for ad-hoc / nested return types
84
-
85
- return_type.openapi_name = "#{base}_#{status}_Response"
86
- return_type.define_singleton_method(:name) { openapi_name }
87
- end
88
- end
89
-
90
70
  require 'rack'
91
71
  def self.coerce_status_to_int(status)
92
72
  # support using http status numbers directly
@@ -137,6 +117,6 @@ class Taro::Rails::Declaration
137
117
  end
138
118
 
139
119
  def <=>(other)
140
- params.openapi_name <=> other.params.openapi_name
120
+ routes.first.openapi_operation_id <=> other.routes.first.openapi_operation_id
141
121
  end
142
122
  end
@@ -18,15 +18,26 @@ Taro::Rails::NormalizedRoute = Data.define(:rails_route) do
18
18
  rails_route.path.spec.to_s.gsub('(.:format)', '').gsub(/:(\w+)/, '{\1}')
19
19
  end
20
20
 
21
+ def openapi_operation_id
22
+ "#{verb}_#{action}_#{controller.gsub('/', '__')}"
23
+ end
24
+
21
25
  def path_params
22
26
  openapi_path.scan(/{(\w+)}/).flatten.map(&:to_sym)
23
27
  end
24
28
 
25
29
  def endpoint
26
- controller, action = rails_route.requirements.values_at(:controller, :action)
27
30
  "#{controller}##{action}"
28
31
  end
29
32
 
33
+ def action
34
+ rails_route.requirements[:action]
35
+ end
36
+
37
+ def controller
38
+ rails_route.requirements[:controller]
39
+ end
40
+
30
41
  def can_have_request_body?
31
42
  %w[patch post put].include?(verb)
32
43
  end
@@ -34,4 +45,5 @@ Taro::Rails::NormalizedRoute = Data.define(:rails_route) do
34
45
  def inspect
35
46
  %(#<#{self.class} "#{verb} #{openapi_path}">)
36
47
  end
48
+ alias to_s inspect
37
49
  end
@@ -2,11 +2,10 @@ class Taro::Types::Scalar::ISO8601DateType < Taro::Types::ScalarType
2
2
  self.desc = 'Represents a time as Date in ISO8601 format.'
3
3
  self.openapi_name = 'ISO8601Date'
4
4
  self.openapi_type = :string
5
-
6
- PATTERN = /\A\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\z/
5
+ self.pattern = /\A\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\z/
7
6
 
8
7
  def coerce_input
9
- if object.instance_of?(String) && object.match?(PATTERN)
8
+ if object.instance_of?(String) && object.match?(pattern)
10
9
  Date.parse(object)
11
10
  else
12
11
  input_error("must be a ISO8601 formatted string")
@@ -2,11 +2,10 @@ class Taro::Types::Scalar::ISO8601DateTimeType < Taro::Types::ScalarType
2
2
  self.desc = 'Represents a time as DateTime in ISO8601 format.'
3
3
  self.openapi_name = 'ISO8601DateTime'
4
4
  self.openapi_type = :string
5
-
6
- PATTERN = /\A\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(Z|[+-](0[0-9]|1[0-4]):[0-5]\d)\z/
5
+ self.pattern = /\A\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(Z|[+-](0[0-9]|1[0-4]):[0-5]\d)\z/
7
6
 
8
7
  def coerce_input
9
- if object.instance_of?(String) && object.match?(PATTERN)
8
+ if object.instance_of?(String) && object.match?(pattern)
10
9
  DateTime.iso8601(object)
11
10
  else
12
11
  input_error("must be a ISO8601 formatted string")
@@ -2,14 +2,25 @@ class Taro::Types::Scalar::StringType < Taro::Types::ScalarType
2
2
  self.openapi_type = :string
3
3
 
4
4
  def coerce_input
5
- object.instance_of?(String) ? object : input_error('must be a String')
5
+ object.instance_of?(String) || input_error('must be a String')
6
+
7
+ pattern.nil? || pattern.match?(object) ||
8
+ input_error("must match pattern #{pattern.inspect}")
9
+
10
+ object
6
11
  end
7
12
 
8
13
  def coerce_response
9
- case object
10
- when String then object
11
- when Symbol then object.to_s
12
- else response_error('must be a String or Symbol')
13
- end
14
+ str =
15
+ case object
16
+ when String then object
17
+ when Symbol then object.to_s
18
+ else response_error('must be a String or Symbol')
19
+ end
20
+
21
+ pattern.nil? || pattern.match?(str) ||
22
+ response_error("must match pattern #{pattern.inspect}")
23
+
24
+ str
14
25
  end
15
26
  end
@@ -1,23 +1,5 @@
1
- class Taro::Types::Scalar::UUIDv4Type < Taro::Types::ScalarType
1
+ class Taro::Types::Scalar::UUIDv4Type < Taro::Types::Scalar::StringType
2
2
  self.desc = "A UUID v4 string"
3
3
  self.openapi_name = 'UUIDv4'
4
- self.openapi_type = :string
5
-
6
- PATTERN = /\A\h{8}-?(?:\h{4}-?){3}\h{12}\z/
7
-
8
- def coerce_input
9
- if object.is_a?(String) && object.match?(PATTERN)
10
- object
11
- else
12
- input_error("must be a UUID v4 string")
13
- end
14
- end
15
-
16
- def coerce_response
17
- if object.is_a?(String) && object.match?(PATTERN)
18
- object
19
- else
20
- response_error("must be a UUID v4 string")
21
- end
22
- end
4
+ self.pattern = /\A\h{8}-?(?:\h{4}-?){3}\h{12}\z/
23
5
  end
@@ -1,5 +1,6 @@
1
1
  # Abstract base class for scalar types, i.e. types without fields.
2
2
  class Taro::Types::ScalarType < Taro::Types::BaseType
3
+ include Taro::Types::Shared::Pattern
3
4
  end
4
5
 
5
6
  module Taro::Types::Scalar
@@ -15,8 +15,8 @@ module Taro::Types::Shared::CustomFieldResolvers
15
15
  end
16
16
 
17
17
  def method_added(name)
18
- if name == :object
19
- raise(Taro::ArgumentError, '#object is a reserved, internally used method name')
18
+ if [:object, :pattern].include?(name)
19
+ raise(Taro::ArgumentError, "##{name} is a reserved, internally used method name")
20
20
  elsif ![:coerce_input, :coerce_response].include?(name) &&
21
21
  !self.name.to_s.start_with?('Taro::Types::')
22
22
  custom_resolvers[name] = true
@@ -0,0 +1,69 @@
1
+ module Taro::Types::Shared::Pattern
2
+ def pattern
3
+ self.class.pattern
4
+ end
5
+
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ attr_reader :pattern, :openapi_pattern
12
+
13
+ def pattern=(regexp)
14
+ openapi_type == :string ||
15
+ raise(Taro::RuntimeError, 'pattern requires openapi_type :string')
16
+
17
+ @pattern = regexp
18
+ @openapi_pattern = Taro::Types::Shared::Pattern.to_es262(regexp)
19
+ end
20
+ end
21
+
22
+ def self.to_es262(regexp)
23
+ validate(regexp).source.gsub(
24
+ /#{NOT_ESCAPED}\\[Ahz]/,
25
+ { '\\A' => '^', '\\h' => '[0-9a-fA-F]', '\\z' => '$' }
26
+ )
27
+ end
28
+
29
+ def self.validate(regexp)
30
+ validate_no_flags(regexp)
31
+ validate_not_empty(regexp)
32
+ validate_no_advanced_syntax(regexp)
33
+ regexp
34
+ end
35
+
36
+ def self.validate_no_flags(regexp)
37
+ (flags = regexp.inspect[%r{/\w+\z}]) &&
38
+ raise(Taro::ArgumentError, "pattern flags (#{flags}) are not supported")
39
+ end
40
+
41
+ def self.validate_not_empty(regexp)
42
+ regexp.source.empty? &&
43
+ raise(Taro::ArgumentError, 'pattern cannot be empty')
44
+ end
45
+
46
+ def self.validate_no_advanced_syntax(regexp)
47
+ return unless (match = regexp.source.match(ADVANCED_RUBY_REGEXP_SYNTAX_REGEXP))
48
+
49
+ feature = match.named_captures.find { |k, v| break k if v }
50
+ raise Taro::ArgumentError, <<~MSG
51
+ pattern uses non-JS syntax #{match} (#{feature}) at index #{match.begin(0)}
52
+ MSG
53
+ end
54
+
55
+ NOT_ESCAPED = /(?<!\\)(?:\\\\)*\K/
56
+
57
+ # This is not 100% accurate, e.g. /[?+]/ is a false positive, but it should be
58
+ # good enough so we don't need regexp_parser or js_regex as a dependency.
59
+ ADVANCED_RUBY_REGEXP_SYNTAX_REGEXP = /
60
+ #{NOT_ESCAPED}
61
+ (?:
62
+ (?<a special group or lookaround> \(\?[^:] )
63
+ | (?<a Ruby-specific escape> \\[a-zA-Z&&[^bBdDsSwWAzfhnrv]] )
64
+ | (?<an advanced quantifier> [?*+}][?+] )
65
+ | (?<a nested set> \[[^\]]*(?<!\\)\[ )
66
+ | (?<a set intersection> && )
67
+ )
68
+ /x
69
+ end
data/lib/taro/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # :nocov:
2
2
  module Taro
3
- VERSION = "1.2.0"
3
+ VERSION = "1.4.0"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: taro
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Janosch Müller
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2024-11-18 00:00:00.000000000 Z
12
+ date: 2024-11-27 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
@@ -99,6 +99,7 @@ files:
99
99
  - lib/taro/types/shared/object_coercion.rb
100
100
  - lib/taro/types/shared/openapi_name.rb
101
101
  - lib/taro/types/shared/openapi_type.rb
102
+ - lib/taro/types/shared/pattern.rb
102
103
  - lib/taro/types/shared/rendering.rb
103
104
  - lib/taro/version.rb
104
105
  - tasks/benchmark.rake