Skip to content

Commit

Permalink
Start derived types, benchmark, export
Browse files Browse the repository at this point in the history
  • Loading branch information
jaynetics committed Nov 6, 2024
1 parent a29be1d commit 4c8fcec
Show file tree
Hide file tree
Showing 43 changed files with 691 additions and 111 deletions.
5 changes: 4 additions & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ AllCops:
SuggestExtensions: false
TargetRubyVersion: 3.2

Layout/LineLength:
Enabled: false

Lint/AmbiguousOperatorPrecedence:
Enabled: false

Metrics/BlockLength:
Exclude: ['spec/**/*']
Exclude: ['spec/**/*', 'tasks/**/*']

Metrics/ParameterLists:
Enabled: false
Expand Down
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ source "https://rubygems.org"
# Specify your gem's dependencies in taro.gemspec
gemspec

gem "benchmark-ips"

gem "debug"

gem "rake", "~> 13.0"

gem "rails", "~> 7.0"

gem "rails_cursor_pagination", "~> 0.4"

gem "rspec", "~> 3.0"

gem "rubocop", "~> 1.68"
Expand Down
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ GEM
ast (2.4.2)
base64 (0.2.0)
benchmark (0.3.0)
benchmark-ips (2.13.0)
bigdecimal (3.1.8)
builder (3.3.0)
concurrent-ruby (1.3.4)
Expand Down Expand Up @@ -167,6 +168,8 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
rails_cursor_pagination (0.4.0)
activerecord (>= 6.0)
railties (7.2.2)
actionpack (= 7.2.2)
activesupport (= 7.2.2)
Expand Down Expand Up @@ -232,8 +235,10 @@ PLATFORMS
ruby

DEPENDENCIES
benchmark-ips
debug
rails (~> 7.0)
rails_cursor_pagination (~> 0.4)
rake (~> 13.0)
rspec (~> 3.0)
rubocop (~> 1.68)
Expand Down
50 changes: 29 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,14 @@ Inspired by `apipie-rails` and `graphql-ruby`.
- route inference
- apipie/application.rb:28-70
- apipie/routes_formatter.rb
- array support
- additionalProperties
- OpenAPI export (e.g. `#to_openapi` methods for types)
- maybe later: apidoc rendering based on export (rails engine?)
- pagination support (with rails-cursor-pagination? or maybe do this later?)
- maybe change controller DSL to avoid conflict with apipie?
- [query logs metadata](https://github.com/rmosolgo/graphql-ruby/blob/dcaaed1cea47394fad61fceadf291ff3cb5f2932/lib/generators/graphql/install_generator.rb#L48-L52)
- add a NoContentType?
- rspec matchers for testing?
- benchmark for validation of a big response
- examples https://swagger.io/specification/#example-object
- `deprecation`
- move coercion error out of Field, handle in ResponseValidator
- more docs

Expand All @@ -39,41 +37,51 @@ rails generate taro:install [ --dir app/my_types_dir ]
Example:

```ruby
class CarsController < ApplicationController
api 'Update a car' # optional description
accepts CarInputType # accepted params
returns ok: CarType, # return types by status code
class BikesController < ApplicationController
api 'Update a bike' # optional description
accepts BikeInputType # accepted params
returns ok: BikeType, # return types by status code
unprocessable_content: MyErrorType
def update
if car.update(@api_params) # automatically parsed params
render json: CarType.render(car), status: :ok
if bike.update(@api_params) # automatically parsed params
render json: BikeType.render(bike), status: :ok
else
render json: MyErrorType.render(car.errors), status: :unprocessable_entity
render json: MyErrorType.render(bike.errors), status: :unprocessable_entity
end
end
end

# ObjectTypes are used to define, render, and validate responses.
class CarType < ObjectType
class BikeType < ObjectType
# Optional type description (for docs and OpenAPI export)
self.description = 'A car and all relevant information about it'
self.description = 'A bike and all relevant information about it'

field(:name) { [String, null: true, description: 'The name'] }
field(:has_name) { [Boolean, null: true, method: :name?] }
field(:info) { [String, null: true] }
# Field nullability must be set, description is optional
field(:brand) { [String, null: true, description: 'The brand name'] }

# Fields can reference other types and arrays of values
field(:users) { [[UserType], null: false] }

# Pagination is built-in for big lists
field(:parts) { [PartType.page, null: false] }

# Custom methods can be chosen to resolve fields
field(:has_brand) { [Boolean, null: true, method: :brand?] }

# Field resolvers can also be implemented or overridden on the type
field(:info) { [String, null: true] }

# Field resolvers can be implemented or overridden on the type
def info
"A car named #{object.name} with #{object.wheels} wheels."
"A bike named #{object.name} with #{object.wheels} wheels."
end
end

# The usage of dedicated InputTypes is optional.
# Object types can also be used to define accepted parameters –
# or parts of them.
class CarInputType < InputType
field(:name) { [String, null: false, description: 'The name'] }
field(:wheels) { [String, null: true, default: 4] }
class BikeInputType < InputType
field(:brand) { [String, null: false, description: 'The brand name'] }
field(:wheels) { [String, null: true, default: 2] }
end
```

Expand Down
2 changes: 2 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
require "bundler/gem_tasks"
require "rspec/core/rake_task"

Dir['tasks/**/*.rake'].each { |file| load(file) }

RSpec::Core::RakeTask.new(:spec)

require "rubocop/rake_task"
Expand Down
3 changes: 3 additions & 0 deletions lib/taro/export.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module Taro::Export
Dir[File.join(__dir__, "export", "*.rb")].each { |f| require f }
end
71 changes: 71 additions & 0 deletions lib/taro/export/open_api_v3.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
class Taro::Export::OpenAPIv3
attr_reader :components

# TODO:
# - accept Taro::Rails.definitions as an argument
# - get routes, params, status codes, responses etc. from each Definition
# - use methods below to render their details
# - support list/array type
# - use json-schema gem to validate overall result against OpenAPIv3 schema
def initialize
@components = {}
end

def export_field(field)
if field.type < Taro::Types::ScalarType
export_scalar_field(field)
else
export_complex_field(field)
end
end

def export_scalar_field(field)
base = { type: field.openapi_type }
base[:description] = field.description if field.description
base[:default] = field.default if field.default_specified?
base[:enum] = field.enum if field.enum
base
end

def export_complex_field(field)
ref = extract_component_ref(field.type)
if field.description
# https://github.com/OAI/OpenAPI-Specification/issues/2033
{ description: field.description, allOf: [ref] }
else
ref
end
end

def extract_component_ref(type)
components[:schemas] ||= {}
components[:schemas][type.nesting.to_sym] ||= build_type_ref(type)
{ :'$ref' => "#/components/schemas/#{type.nesting}" }
end

def build_type_ref(type)
if type.respond_to?(:fields) # InputType or ObjectType
build_object_type_ref(type)
elsif type < Taro::Types::EnumType
build_enum_type_ref(type)
else
raise NotImplementedError, "Unsupported type: #{type}"
end
end

def build_object_type_ref(type)
{
type: type.openapi_type,
description: type.description,
properties: type.fields.to_h { |name, f| [name, export_field(f)] },
}.compact
end

def build_enum_type_ref(enum)
{
type: enum.item_type.openapi_type,
description: enum.description,
enum: enum.values,
}.compact
end
end
2 changes: 2 additions & 0 deletions lib/taro/rails/generators/templates/no_content_type.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class NoContentType < Taro::Types::ObjectTypes::NoContentType
end
4 changes: 3 additions & 1 deletion lib/taro/types/base_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
Taro::Types::BaseType = Data.define(:object) do
require_relative "shared"
extend Taro::Types::Shared::Description
extend Taro::Types::Shared::JSONRendering
extend Taro::Types::Shared::DerivedTypes
extend Taro::Types::Shared::Nesting
extend Taro::Types::Shared::OpenAPIType
extend Taro::Types::Shared::Rendering
end
62 changes: 37 additions & 25 deletions lib/taro/types/coerce_to_type.rb
Original file line number Diff line number Diff line change
@@ -1,30 +1,42 @@
class Taro::Types::CoerceToType
def self.call(arg)
return arg if arg < Taro::Types::BaseType
module Taro::Types::CoerceToType
class << self
def call(arg)
if arg.instance_of?(Array)
type(arg.first).list
else
type(arg)
end
end

shortcuts[arg] || raise_cast_error(arg)
end
private

# Map some Ruby classes to built-in types to support e.g.
# `returns String`, or `field { [Integer, ...] }`, etc.
require 'date'
def self.shortcuts
@shortcuts ||= {
# rubocop:disable Layout/HashAlignment - buggy cop
::Date => Taro::Types::Scalar::TimestampType,
::DateTime => Taro::Types::Scalar::TimestampType,
::Float => Taro::Types::Scalar::FloatType,
::Integer => Taro::Types::Scalar::IntegerType,
::String => Taro::Types::Scalar::StringType,
::Time => Taro::Types::Scalar::TimestampType,
# rubocop:enable Layout/HashAlignment - buggy cop
}.freeze
end
def type(arg)
return arg if arg < Taro::Types::BaseType

shortcuts[arg] || raise_cast_error(arg)
end

# Map some Ruby classes to built-in types to support e.g.
# `returns String`, or `field { [Integer, ...] }`, etc.
require 'date'
def shortcuts
@shortcuts ||= {
# rubocop:disable Layout/HashAlignment - buggy cop
::Date => Taro::Types::Scalar::TimestampType,
::DateTime => Taro::Types::Scalar::TimestampType,
::Float => Taro::Types::Scalar::FloatType,
::Integer => Taro::Types::Scalar::IntegerType,
::String => Taro::Types::Scalar::StringType,
::Time => Taro::Types::Scalar::TimestampType,
# rubocop:enable Layout/HashAlignment - buggy cop
}.freeze
end

def self.raise_cast_error(arg)
raise Taro::ArgumentError, <<~MSG
Unsupported type: #{arg.inspect}. Must inherit from a type class
or be one of #{shortcuts.keys.join(', ')}.
MSG
def raise_cast_error(arg)
raise Taro::ArgumentError, <<~MSG
Unsupported type: #{arg.inspect}. Must inherit from a type class
or be one of #{shortcuts.keys.join(', ')}.
MSG
end
end
end
11 changes: 6 additions & 5 deletions lib/taro/types/enum_type.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Abstract class.
class Taro::Types::EnumType < Taro::Types::BaseType
require_relative 'enum_type/value_validation'
extend ValueValidation
extend Taro::Types::Shared::ItemType

def self.value(value)
values << validate_value(value)
self.item_type = value.class
@openapi_type ||= item_type.openapi_type
values << value
end

def self.values
Expand All @@ -13,13 +14,13 @@ def self.values

def coerce_input
self.class.raise_if_empty_enum
value = self.class.value_type.new(object).coerce_input
value = self.class.item_type.new(object).coerce_input
value if self.class.values.include?(value)
end

def coerce_response
self.class.raise_if_empty_enum
value = self.class.value_type.new(object).coerce_response
value = self.class.item_type.new(object).coerce_response
value if self.class.values.include?(value)
end

Expand Down
22 changes: 0 additions & 22 deletions lib/taro/types/enum_type/value_validation.rb

This file was deleted.

Loading

0 comments on commit 4c8fcec

Please sign in to comment.