taro 2.0.0 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62127cdef8f44e34831cb4dec7d900bfa25c3c00994e750624de1f486c6880ab
4
- data.tar.gz: 40bd31112d3e8fb3fe96bfa282acd6ad909c60f66431aa556e01052b3098ab44
3
+ metadata.gz: d73348290387aeef3de0558f6557af6572326043dff427ee295386a12e8bf6a6
4
+ data.tar.gz: 89b0a6c8efae50665d43385b0b4182fba6b07e5f97e933d3add5d0e1ba19f421
5
5
  SHA512:
6
- metadata.gz: a609041860bc3516a70a1ebd2f073d031cac39a04f9ff5562ff7c6d0711b1c5dc3a900cfa4842ff77f6ab573ab0fa2164282cf68fff58133071a7b842201cecc
7
- data.tar.gz: 4942e27cdc067cb5c46ae7f726743d1cf54282536195b892d93507757820e0c53db46ebfb5a40ad25cc13c60ef2eac6b493cb1e2d8b9cfa195a0db782734d765
6
+ metadata.gz: 67a0d0a5d2464c7ec6954143c95311b92b1a4a78041b298449815b1e57340de7091ac89235fd9e674ee1d3e99ed531049e99207ec62498fa94687b7c91956edd
7
+ data.tar.gz: '0768d75380ee9b18de093bebf1952b4eeac1e7965fea8c3a0cb5fbec3ef84ec6a27d5e141a837bf6a0f9d698e5b2ceff9ba2e30a33143edd434ebd25812a933a'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [2.2.0] - 2025-02-22
4
+
5
+ ### Added
6
+
7
+ - error message when incorrectly chaining `with_cache`
8
+
9
+ ## [2.1.0] - 2025-02-21
10
+
11
+ ### Added
12
+
13
+ - added support for caching on type level and when calling render
14
+ - default cache will be set to Rails.cache in the railtie
15
+ - cache_key can be a string, an array, a hash or a proc
16
+
3
17
  ## [2.0.0] - 2024-12-15
4
18
 
5
19
  ### Changed
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Taro - Typed Api using Ruby Objects
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/taro.svg)](https://rubygems.org/gems/taro)
4
+ [![Build Status](https://github.com/taro-rb/taro/actions/workflows/main.yml/badge.svg)](https://github.com/taro-rb/taro/actions)
5
+
3
6
  This library provides an object-based type system for RESTful Ruby APIs, with built-in parameter parsing, response rendering, and OpenAPI schema export.
4
7
 
5
8
  It is inspired by [`apipie-rails`](https://github.com/Apipie/apipie-rails) and [`graphql-ruby`](https://github.com/rmosolgo/graphql-ruby).
@@ -34,7 +37,7 @@ class BikesController < ApplicationController
34
37
  api 'Update a bike', desc: 'My longer text', tags: ['Bikes']
35
38
 
36
39
  # Params can come from the path, e.g. /bike/:id.
37
- # Some types, like UUID in this case, are predefined. See below for more.
40
+ # Some types, like UUID in this case, are predefined. See below for more types.
38
41
  param :id, type: 'UUID', null: false, desc: 'ID of the bike to update'
39
42
 
40
43
  # Params can also come from the query string or request body.
@@ -139,7 +142,14 @@ Taro.config.parse_params = false
139
142
 
140
143
  #### Response validation
141
144
 
142
- Responses are automatically validated to have used the correct type for rendering, which guarantees that they match the declaration.
145
+ Responses are automatically validated to have used the correct type for rendering, which guarantees that they match the declaration. This means you have to use the types to render complex responses, and manually building a Hash that conforms to the schema will raise an error. Primitive/scalar types are an exception, e.g. you *can* do:
146
+
147
+ ```ruby
148
+ returns code: :ok, array_of: 'String', desc: 'Bike names'
149
+ def index
150
+ render json: ['Super bike', 'Slow bike']
151
+ end
152
+ ```
143
153
 
144
154
  An error is also raised if a documented endpoint renders an undocumented status code, e.g.:
145
155
 
@@ -151,7 +161,7 @@ def create
151
161
  end
152
162
  ```
153
163
 
154
- However, all HTTP error codes except 422 can be rendered without prior declaration. E.g.:
164
+ HTTP error codes, except 422, are an exception. They can be rendered without prior declaration. E.g.:
155
165
 
156
166
  ```ruby
157
167
  returns code: :ok, type: 'BikeType'
@@ -162,12 +172,12 @@ end
162
172
 
163
173
  The reason for this is that Rack and Rails commonly render codes like 400, 404, 500 and various others, but you might not want to have that fact documented on every single endpoint.
164
174
 
165
- However, if you do declare an error code, responses with this code are validated:
175
+ However, if you do declare an error code, responses with this code *are* validated:
166
176
 
167
177
  ```ruby
168
- returns code: :not_found, type: 'ExpectedType'
178
+ returns code: :not_found, type: 'ExpectedErrorType'
169
179
  def show
170
- render json: WrongType.render(foo), status: :not_found
180
+ render json: WrongErrorType.render(foo), status: :not_found
171
181
  # => type mismatch, raises response validation error
172
182
  end
173
183
  ```
@@ -194,7 +204,7 @@ end
194
204
 
195
205
  ### Included type options
196
206
 
197
- The following type names are available by default and can be used as `type:`/`array_of:`/`page_of:` arguments:
207
+ The following type names are available by default and can be used as `type:`, `array_of:`, or `page_of:` arguments:
198
208
 
199
209
  - `'Boolean'` - accepts and renders `true` or `false`
200
210
  - `'Date'` - accepts and renders a date string in ISO8601 format
@@ -208,7 +218,7 @@ The following type names are available by default and can be used as `type:`/`ar
208
218
  - `'Timestamp'` - renders a `Time` as unix timestamp integer and turns incoming integers into a `Time`
209
219
  - `'UUID'` - accepts and renders UUIDs
210
220
 
211
- Also, when using the generator, `ErrorsType` and `ErrorDetailsType` are generated as a starting point for unified error presentation. `ErrorsType` can render invalid `ActiveRecord` instances, `ActiveModel::Errors` and other data structures.
221
+ Also, when using the rails generator, `ErrorsType` and `ErrorDetailsType` are generated as a starting point for unified error presentation. `ErrorsType` can render invalid `ActiveRecord` instances, `ActiveModel::Errors` and other data structures.
212
222
 
213
223
  ### Enums
214
224
 
@@ -234,13 +244,34 @@ class ErrorType < ObjectType
234
244
  end
235
245
  ```
236
246
 
237
- ### FAQ
247
+ ### Caching
238
248
 
239
- #### How do I render API docs?
249
+ Taro provides support for caching. The cache instance can be configured by setting `Taro::Cache.cache_instance`. By default, the railtie will set it to `Rails.cache`.
240
250
 
241
- Rendering docs is outside of the scope of this project. You can use the OpenAPI export to generate docs with tools such as RapiDoc, ReDoc, or Swagger UI.
251
+ It supports configuring an ad hoc cache when using a render call, e.g.
242
252
 
243
- #### How do I use context in my types?
253
+ ```ruby
254
+ bike = Bike.find(params[:id])
255
+ BikeType.with_cache(cache_key: bike.cache_key, expires_in: 3.minutes).render(bike)
256
+ ```
257
+
258
+ Or by configuring the cache rule on a per type basis, e.g.
259
+
260
+ ```ruby
261
+ class BikeType < ObjectType
262
+ self.cache_key = ->(bike) { bike.cache_key_with_version }
263
+ self.expires_in = 1.hour
264
+ # ...
265
+ end
266
+ ```
267
+
268
+ ## FAQ
269
+
270
+ ### How do I render API docs?
271
+
272
+ Rendering docs is outside of the scope of this project. You can use the OpenAPI export to generate docs with tools such as RapiDoc, ReDoc, Swagger UI, and [many others](https://tools.openapis.org/categories/documentation.html).
273
+
274
+ ### How do I use context in my types?
244
275
 
245
276
  Use [ActiveSupport::CurrentAttributes](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html).
246
277
 
@@ -254,18 +285,23 @@ class BikeType < ObjectType
254
285
  end
255
286
  ```
256
287
 
257
- #### How do I migrate from apipie-rails?
288
+ ### How do I migrate from apipie-rails?
258
289
 
259
290
  First of all, if you don't need a better OpenAPI export, or better support for hashes and arrays, or less repetitive definitions, it might not be worth it.
260
291
 
261
- If you do:
292
+ Please also note the following:
293
+
294
+ - `taro` currently only supports the latest OpenAPI standard (instead of v2 like `apipie-rails`)
295
+ - `taro` does not support arbitrary validations - only those that can be expressed in the OpenAPI schema
296
+ - `taro` does not render API docs, as mentioned above
297
+
298
+ If you want to migrate anyway:
262
299
 
263
- - note that `taro` currently only supports the latest OpenAPI standard (instead of v2 like `apipie-rails`)
264
300
  - extract complex param declarations into InputTypes
265
301
  - extract complex response declarations into ObjectTypes or ResponseTypes
266
302
  - replace `required: true` with `null: false` and `required: false` with `null: true`
267
303
 
268
- Taro uses some of the same DSL as `apipie`, so for a step-by-step migration, you might want to make `taro` use a different one:
304
+ Taro uses some of the same DSL as `apipie`, so for a step-by-step migration, you might want to make `taro` use a different one. This initializer will change the `taro` DSL to `taro_api`, `taro_param`, and `taro_returns` and leave `api`, `param`, and `returns` to `apipie`:
269
305
 
270
306
  ```ruby
271
307
  # config/initializers/taro.rb
@@ -275,7 +311,7 @@ Taro uses some of the same DSL as `apipie`, so for a step-by-step migration, you
275
311
  end
276
312
  ```
277
313
 
278
- #### How do I keep lengthy API descriptions out of my controller?
314
+ ### How do I keep lengthy API descriptions out of my controller?
279
315
 
280
316
  ```ruby
281
317
  module BikeUpdateDesc
@@ -293,13 +329,13 @@ class BikesController < ApplicationController
293
329
  end
294
330
  ```
295
331
 
296
- #### Why do I have to use type name strings instead of the type constants?
332
+ ### Why do I have to use type name strings instead of the type constants?
297
333
 
298
334
  Why e.g. `field :id, type: 'UUID'` instead of `field :id, type: UUID`?
299
335
 
300
336
  The purpose of this is to reduce unnecessary autoloading of the whole type dependency tree in dev and test environments.
301
337
 
302
- #### Can I define my own derived types like `page_of` or `array_of`?
338
+ ### Can I define my own derived types like `page_of` or `array_of`?
303
339
 
304
340
  Yes.
305
341
 
data/lib/taro/cache.rb ADDED
@@ -0,0 +1,14 @@
1
+ module Taro::Cache
2
+ singleton_class.attr_accessor :cache_instance
3
+
4
+ def self.call(object, cache_key: nil, expires_in: nil)
5
+ case cache_key
6
+ when nil
7
+ yield
8
+ when Hash, Proc
9
+ call(object, cache_key: cache_key[object], expires_in: expires_in) { yield }
10
+ else
11
+ cache_instance.fetch(cache_key, expires_in: expires_in) { yield }
12
+ end
13
+ end
14
+ 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
@@ -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,6 +1,6 @@
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
@@ -79,7 +79,7 @@ Taro::Rails::ResponseValidator = Struct.new(:controller, :declaration, :rendered
79
79
 
80
80
  def check_enum(type, value)
81
81
  # coercion checks non-emptyness + enum match
82
- type.new(value).coerce_response
82
+ type.new(value).cached_coerce_response
83
83
  rescue Taro::Error => e
84
84
  fail_with(e.message)
85
85
  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
@@ -20,5 +20,7 @@ Taro::Types::BaseType = Struct.new(:object) do
20
20
  extend Taro::Types::Shared::OpenAPIName
21
21
  extend Taro::Types::Shared::OpenAPIType
22
22
  extend Taro::Types::Shared::Rendering
23
+ include Taro::Types::Shared::Caching
23
24
  include Taro::Types::Shared::Errors
25
+ include Taro::Types::Shared::TypeClass
24
26
  end
@@ -64,7 +64,7 @@ module Taro::Types::Coercion
64
64
  require 'date'
65
65
  def shortcuts
66
66
  @shortcuts ||= {
67
- # rubocop:disable Layout/HashAlignment - buggy cop
67
+ # rubocop:disable Layout/HashAlignment
68
68
  'Boolean' => Taro::Types::Scalar::BooleanType,
69
69
  'Date' => Taro::Types::Scalar::ISO8601DateType,
70
70
  'DateTime' => Taro::Types::Scalar::ISO8601DateTimeType,
@@ -76,7 +76,7 @@ module Taro::Types::Coercion
76
76
  'Time' => Taro::Types::Scalar::ISO8601DateTimeType,
77
77
  'Timestamp' => Taro::Types::Scalar::TimestampType,
78
78
  'UUID' => Taro::Types::Scalar::UUIDv4Type,
79
- # rubocop:enable Layout/HashAlignment - buggy cop
79
+ # rubocop:enable Layout/HashAlignment
80
80
  }.freeze
81
81
  end
82
82
  end
@@ -24,7 +24,7 @@ class Taro::Types::EnumType < Taro::Types::BaseType
24
24
 
25
25
  def coerce_response
26
26
  self.class.raise_if_empty_enum
27
- value = self.class.item_type.new(object).coerce_response
27
+ value = self.class.item_type.new(object).cached_coerce_response
28
28
  if self.class.values.include?(value)
29
29
  value
30
30
  else
@@ -3,6 +3,7 @@ require_relative 'field_validation'
3
3
  Taro::Types::Field = Data.define(:name, :type, :null, :method, :default, :enum, :defined_at, :desc, :deprecated) do
4
4
  include Taro::Types::FieldValidation
5
5
  include Taro::Types::Shared::Errors
6
+ include Taro::Types::Shared::TypeClass
6
7
 
7
8
  def initialize(name:, type:, null:, method: name, default: Taro::None, enum: nil, defined_at: nil, desc: nil, deprecated: nil)
8
9
  enum = coerce_to_enum(enum)
@@ -65,7 +66,7 @@ Taro::Types::Field = Data.define(:name, :type, :null, :method, :default, :enum,
65
66
  return default if value.nil? && default_specified?
66
67
 
67
68
  type_obj = type.new(value)
68
- from_input ? type_obj.coerce_input : type_obj.coerce_response
69
+ from_input ? type_obj.coerce_input : type_obj.cached_coerce_response
69
70
  rescue Taro::ValidationError => e
70
71
  reraise_recursively_with_path_info(e)
71
72
  end
@@ -17,7 +17,7 @@ class Taro::Types::ListType < Taro::Types::BaseType
17
17
  object.respond_to?(:map) || response_error('must be an Enumerable')
18
18
 
19
19
  item_type = self.class.item_type
20
- object.map { |el| item_type.new(el).coerce_response }
20
+ object.map { |el| item_type.new(el).cached_coerce_response }
21
21
  end
22
22
 
23
23
  def self.default_openapi_name
@@ -0,0 +1,31 @@
1
+ module Taro::Types::Shared::Caching
2
+ def cached_coerce_response
3
+ Taro::Cache.call(object, cache_key: self.class.cache_key, expires_in: self.class.expires_in) do
4
+ coerce_response
5
+ end
6
+ end
7
+
8
+ def self.included(klass)
9
+ klass.extend(ClassMethods)
10
+ klass.singleton_class.attr_accessor :expires_in
11
+ klass.singleton_class.attr_reader :cache_key
12
+ end
13
+
14
+ module ClassMethods
15
+ def cache_key=(arg)
16
+ arg.nil? || arg.is_a?(Proc) && arg.arity == 1 || arg.is_a?(Hash) ||
17
+ raise(Taro::ArgumentError, "Type.cache_key must be a Proc with arity 1, a Hash, or nil")
18
+
19
+ @cache_key = arg
20
+ end
21
+
22
+ def with_cache(cache_key:, expires_in: nil)
23
+ klass = dup
24
+ klass.cache_key = cache_key.is_a?(Proc) ? cache_key : ->(_) { cache_key }
25
+ klass.expires_in = expires_in
26
+ this = self
27
+ klass.define_singleton_method(:type_class) { this }
28
+ klass
29
+ end
30
+ end
31
+ end
@@ -35,9 +35,11 @@ module Taro::Types::Shared::DerivedTypes
35
35
 
36
36
  root.define_singleton_method(method_name) do
37
37
  derived_types[type] ||= begin
38
- type_class = Taro::Types::Coercion.call(type:)
39
- new_type = Class.new(type_class)
40
- new_type.define_name("#{self.name}.#{method_name}")
38
+ name || raise(Taro::ArgumentError, 'Cannot derive from anonymous type')
39
+
40
+ coerced_type = Taro::Types::Coercion.call(type:)
41
+ new_type = Class.new(coerced_type)
42
+ new_type.define_name("#{name}.#{method_name}")
41
43
  new_type.derive_from(self)
42
44
  new_type
43
45
  end
@@ -8,8 +8,7 @@ module Taro::Types::Shared::Equivalence
8
8
 
9
9
  # @fields is lazy-loaded. Comparing @field_defs suffices.
10
10
  ignored = %i[@fields]
11
- props = instance_variables - ignored
12
- props == (other.instance_variables - ignored) &&
13
- props.all? { |p| instance_variable_get(p) == other.instance_variable_get(p) }
11
+ (instance_variables - ignored).to_h { |i| [i, instance_variable_get(i)] } ==
12
+ (other.instance_variables - ignored).to_h { |i| [i, other.instance_variable_get(i)] }
14
13
  end
15
14
  end
@@ -1,9 +1,11 @@
1
1
  module Taro::Types::Shared::Rendering
2
2
  # The `::render` method is intended for use in controllers.
3
3
  # Overrides of this method must call super.
4
- def render(object)
5
- result = new(object).coerce_response
6
- self.last_render = [self, result.__id__]
4
+ def render(object, cache_attrs = {})
5
+ result = Taro::Cache.call(object, **cache_attrs) do
6
+ new(object).cached_coerce_response
7
+ end
8
+ self.last_render = [type_class, result.__id__]
7
9
  result
8
10
  end
9
11
 
@@ -0,0 +1,12 @@
1
+ # `type_class` is a convenience method to get the type class of types,
2
+ # of with_cache-types, of type instances, and of fields in the same way.
3
+ module Taro::Types::Shared::TypeClass
4
+ def self.included(klass)
5
+ if klass.instance_methods.include?(:type) # Field
6
+ klass.alias_method :type_class, :type
7
+ else # BaseType
8
+ klass.singleton_class.alias_method :type_class, :itself
9
+ klass.alias_method :type_class, :class
10
+ end
11
+ end
12
+ end
data/lib/taro/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # :nocov:
2
2
  module Taro
3
- VERSION = "2.0.0"
3
+ VERSION = "2.2.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: 2.0.0
4
+ version: 2.2.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-12-15 00:00:00.000000000 Z
12
+ date: 2025-02-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
@@ -39,6 +39,7 @@ files:
39
39
  - README.md
40
40
  - Rakefile
41
41
  - lib/taro.rb
42
+ - lib/taro/cache.rb
42
43
  - lib/taro/common_returns.rb
43
44
  - lib/taro/config.rb
44
45
  - lib/taro/declaration.rb
@@ -101,6 +102,7 @@ files:
101
102
  - lib/taro/types/scalar_type.rb
102
103
  - lib/taro/types/shared.rb
103
104
  - lib/taro/types/shared/additional_properties.rb
105
+ - lib/taro/types/shared/caching.rb
104
106
  - lib/taro/types/shared/custom_field_resolvers.rb
105
107
  - lib/taro/types/shared/deprecation.rb
106
108
  - lib/taro/types/shared/derived_types.rb
@@ -115,6 +117,7 @@ files:
115
117
  - lib/taro/types/shared/openapi_type.rb
116
118
  - lib/taro/types/shared/pattern.rb
117
119
  - lib/taro/types/shared/rendering.rb
120
+ - lib/taro/types/shared/type_class.rb
118
121
  - lib/taro/version.rb
119
122
  - tasks/benchmark.rake
120
123
  - tasks/benchmark_1kb.json