Skip to content

Commit

Permalink
Make derived types system extensible
Browse files Browse the repository at this point in the history
  • Loading branch information
jaynetics committed Nov 17, 2024
1 parent 014ff9f commit a719e1f
Show file tree
Hide file tree
Showing 16 changed files with 113 additions and 52 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
### Added

- Improved error messages
- Option to define custom derived types
- Option to use custom keys in paginated content

### Fixed
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,35 @@ Why e.g. `field :id, type: 'UUID'` instead of `field :id, type: UUID`?

The purpose of this is to reduce unnecessary autoloading of the whole type dependency tree in dev and test environments.

#### Can I define my own derived types like `page_of` or `array_of`?

Yes.

```ruby
# Call define_derived_type after implementing ::derive_from.
class PreviewType < Taro::Types::Scalar::StringType
singleton_class.attr_reader :type_to_preview

def self.derive_from(other_type)
self.type_to_preview = other_type
end

def coerce_response
type_to_preview.new(object).coerce_response.to_s.truncate(100)
end

define_derived_type :preview
end

# Usage:
class MyController < ApplicationController
returns code: :ok, preview_of: 'BikeType'
def show
render json: BikeType.preview.render(Bike.find(params[:id]))
end
end
```

## Possible future features

- warning/raising for undeclared input params (currently they are ignored)
Expand Down
2 changes: 1 addition & 1 deletion lib/taro/rails/declaration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def check_return_kwargs(kwargs)
MSG
end

bad_keys = kwargs.keys - (Taro::Types::Coercion::KEYS + %i[code desc nesting])
bad_keys = kwargs.keys - (Taro::Types::Coercion.keys + %i[code desc nesting])
return if bad_keys.empty?

raise Taro::ArgumentError, "Invalid `returns` options: #{bad_keys.join(', ')}"
Expand Down
1 change: 1 addition & 0 deletions lib/taro/types/base_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Taro::Types::BaseType = Data.define(:object) do
require_relative "shared"
extend Taro::Types::Shared::AdditionalProperties
extend Taro::Types::Shared::DerivedTypes
extend Taro::Types::Shared::Description
extend Taro::Types::Shared::OpenAPIName
extend Taro::Types::Shared::OpenAPIType
Expand Down
28 changes: 17 additions & 11 deletions lib/taro/types/coercion.rb
Original file line number Diff line number Diff line change
@@ -1,35 +1,41 @@
module Taro::Types::Coercion
KEYS = %i[type array_of page_of].freeze

class << self
def call(arg)
validate_hash(arg)
from_hash(arg)
end

# Coercion keys can be expanded by the DerivedTypes module.
def keys
@keys ||= %i[type]
end

private

def validate_hash(arg)
arg.is_a?(Hash) || raise(Taro::ArgumentError, <<~MSG)
Type coercion argument must be a Hash, got: #{arg.class}
MSG

types = arg.slice(*KEYS)
types = arg.slice(*keys)
types.size == 1 || raise(Taro::ArgumentError, <<~MSG)
Exactly one of type, array_of, or page_of must be given, got: #{types}
MSG
end

def from_hash(hash)
if hash[:type]
from_string(hash[:type])
elsif (inner_type = hash[:array_of])
from_string(inner_type).array
elsif (inner_type = hash[:page_of])
from_string(inner_type).page
else
raise NotImplementedError, 'Unsupported type coercion'
keys.each do |key|
next unless (value = hash[key])

# e.g. `returns type: 'MyType'` -> MyType
return from_string(value) if key == :type

# DerivedTypes
# e.g. `returns array_of: 'MyType'` -> MyType.array
return from_string(value).send(key.to_s.chomp('_of'))
end

raise NotImplementedError, 'Unsupported type coercion'
end

def from_string(arg)
Expand Down
8 changes: 1 addition & 7 deletions lib/taro/types/list_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# Unlike other types, this one should not be manually inherited from,
# but is used indirectly via `array_of: SomeType`.
class Taro::Types::ListType < Taro::Types::BaseType
extend Taro::Types::Shared::DerivableType
extend Taro::Types::Shared::ItemType

self.openapi_type = :array
Expand All @@ -20,11 +19,6 @@ def coerce_response
item_type = self.class.item_type
object.map { |el| item_type.new(el).coerce_response }
end
end

# add shortcut to other types
class Taro::Types::BaseType
def self.array
Taro::Types::ListType.for(self)
end
define_derived_type :array
end
8 changes: 1 addition & 7 deletions lib/taro/types/object_types/page_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# The gem rails_cursor_pagination must be installed to use this.
#
class Taro::Types::ObjectTypes::PageType < Taro::Types::BaseType
extend Taro::Types::Shared::DerivableType
extend Taro::Types::Shared::ItemType

def coerce_input
Expand Down Expand Up @@ -36,11 +35,6 @@ def coerce_paginated_list(list, items_key:)
def self.items_key
:page
end
end

# add shortcut to other types
class Taro::Types::BaseType
def self.page
Taro::Types::ObjectTypes::PageType.for(self)
end
define_derived_type :page
end
9 changes: 0 additions & 9 deletions lib/taro/types/shared/derivable_types.rb

This file was deleted.

21 changes: 21 additions & 0 deletions lib/taro/types/shared/derived_types.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module Taro::Types::Shared::DerivedTypes
def derived_types
@derived_types ||= {}
end

def define_derived_type(name)
type = self
root = Taro::Types::BaseType
raise ArgumentError, "#{name} is already in use" if root.respond_to?(name)

key = :"#{name}_of"
keys = Taro::Types::Coercion.keys
raise ArgumentError, "#{key} is already in use" if keys.include?(key)

root.define_singleton_method(name) do
derived_types[type] ||= Class.new(type).tap { |t| t.derive_from(self) }
end

keys << key
end
end
9 changes: 5 additions & 4 deletions lib/taro/types/shared/fields.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ def validate_options(name, defined_at:, **kwargs)
[true, false].include?(kwargs[:null]) ||
raise(Taro::ArgumentError, "null has to be specified as true or false for field #{name} at #{defined_at}")

(type_keys = (kwargs.keys & Taro::Types::Coercion::KEYS)).size == 1 ||
raise(Taro::ArgumentError, "exactly one of type, array_of, or page_of must be given for field #{name} at #{defined_at}")
c_keys = Taro::Types::Coercion.keys
(type_keys = (kwargs.keys & c_keys)).size == 1 ||
raise(Taro::ArgumentError, "exactly one of #{c_keys.join(', ')} must be given for field #{name} at #{defined_at}")

kwargs[type_keys.first].class == String ||
raise(Taro::ArgumentError, "#{type_key} must be a String for field #{name} at #{defined_at}")
raise(Taro::ArgumentError, "#{type_keys.first} must be a String for field #{name} at #{defined_at}")
end

def validate_no_override(name, defined_at:)
Expand All @@ -46,7 +47,7 @@ def field_defs
def evaluate_field_defs
field_defs.transform_values do |field_def|
type = Taro::Types::Coercion.call(field_def)
Taro::Types::Field.new(**field_def.except(*Taro::Types::Coercion::KEYS), type:)
Taro::Types::Field.new(**field_def.except(*Taro::Types::Coercion.keys), type:)
end
end

Expand Down
1 change: 1 addition & 0 deletions lib/taro/types/shared/item_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ def item_type=(new_type)
item_type.nil? || new_type == item_type || raise_mixed_types(new_type)
@item_type = new_type
end
alias_method :derive_from, :item_type=

def raise_mixed_types(new_type)
raise Taro::ArgumentError, <<~MSG
Expand Down
2 changes: 1 addition & 1 deletion spec/taro/types/list_type_spec.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
describe Taro::Types::ListType do
let(:example) { described_class.for(S::StringType) }
let(:example) { S::StringType.array }

it 'coerces input' do
expect(example.new(%w[a]).coerce_input).to eq %w[a]
Expand Down
2 changes: 1 addition & 1 deletion spec/taro/types/object_types/page_type_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
require 'rails_cursor_pagination'

describe Taro::Types::ObjectTypes::PageType do
let(:example) { described_class.for(S::StringType) }
let(:example) { S::StringType.page }
let(:page) { [] }
let(:page_info) do
{ has_previous_page: true, has_next_page: true, start_cursor: 'x', end_cursor: 'y' }
Expand Down
14 changes: 14 additions & 0 deletions spec/taro/types/shared/derived_types_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
describe Taro::Types::Shared::DerivedTypes do
it 'raises an error if the deriving method is already in use' do
expect do
Taro::Types::BaseType.define_derived_type(:inspect)
end.to raise_error(ArgumentError, 'inspect is already in use')
end

it 'raises an error if the coercion key is already in use' do
allow(Taro::Types::Coercion).to receive(:keys).and_return([:blob_of])
expect do
Taro::Types::BaseType.define_derived_type(:blob)
end.to raise_error(ArgumentError, 'blob_of is already in use')
end
end
28 changes: 18 additions & 10 deletions spec/taro/types/shared/fields_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
end

it 'raises when evaluating fields without type' do
expect { example.field :bar, null: true }.to raise_error(Taro::ArgumentError, /type/)
expect { example.field :bar, null: true }
.to raise_error(Taro::ArgumentError, /type.*must be given/)
end

it 'raises when evaluating fields with non-string type' do
expect { example.field :bar, type: S::StringType, null: true }
.to raise_error(Taro::ArgumentError, /type must be a String/)
end

it 'raises when evaluating fields without null' do
Expand All @@ -24,25 +30,27 @@

it 'raises when redefining fields' do
example.field :bar, type: 'String', null: true
expect { example.field :bar, type: 'Boolean', null: false }.to raise_error(Taro::Error, /defined/)
expect { example.field :bar, type: 'Boolean', null: false }
.to raise_error(Taro::Error, /previously defined/)
end

it 'takes array_of instead of type' do
example.field :bar, array_of: 'String', null: true
field = example.fields[:bar]
expect(field.type).to be < Taro::Types::ListType
expect(field.type).to be < T::ListType
end

# it 'handles array_of with nested types' do
# example.field :bar, array_of: 'Object', null: true
# field = example.fields[:bar]
# expect(field.type).to be < Taro::Types::ListType
# expect(field.type.item_type).to be < Taro::Types::ObjectTypes::ObjectType
# end
it 'handles array_of with nested types' do
stub_const('FooType', Class.new(T::ObjectType))
example.field :bar, array_of: 'FooType', null: true
field = example.fields[:bar]
expect(field.type).to be < T::ListType
expect(field.type.item_type).to eq FooType
end

it 'takes page_of instead of type' do
example.field :bar, page_of: 'Integer', null: true
field = example.fields[:bar]
expect(field.type).to be < Taro::Types::ObjectTypes::PageType
expect(field.type).to be < T::ObjectTypes::PageType
end
end
2 changes: 1 addition & 1 deletion tasks/benchmark.rake
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ task :benchmark do
field :version, type: 'Float', null: false
end

type = Taro::Types::ListType.for(item_type)
type = item_type.array

# 143.889k (± 2.7%) i/s - 723.816k in 5.034247s
Benchmark.ips do |x|
Expand Down

0 comments on commit a719e1f

Please sign in to comment.