Skip to content

Commit

Permalink
polymorphs and other bits
Browse files Browse the repository at this point in the history
  • Loading branch information
paulsturgess committed Oct 30, 2023
1 parent eb5b33c commit 6a8b33f
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 53 deletions.
8 changes: 6 additions & 2 deletions examples/core_api/controllers/time_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class TimeController < Apia::Controller

endpoint :now, Endpoints::TimeNowEndpoint

# TODO: add example of multiple objects using the same objects, to ensure
# we are handing circular references correctly
endpoint :format do
description 'Format the given time'
argument :time, type: ArgumentSets::TimeLookupArgumentSet, required: true
Expand All @@ -28,12 +30,14 @@ class TimeController < Apia::Controller
description 'Format the given times'
argument :times, type: [ArgumentSets::TimeLookupArgumentSet], required: true
field :formatted_times, type: [:string]
field :times, type: [Objects::Time], include: 'unix,year[as_string],as_array_of_objects[as_integer]'
action do
times = []
request.arguments[:times].each do |time|
times << time.resolve.to_s
times << time.resolve
end
response.add_field :formatted_times, times.join(", ")
response.add_field :formatted_times, times.map(&:to_s).join(", ")
response.add_field :times, times
end
end

Expand Down
2 changes: 2 additions & 0 deletions examples/core_api/endpoints/time_now_endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ class TimeNowEndpoint < Apia::Endpoint
field :time, type: Objects::Time
field :time_zones, type: [Objects::TimeZone]
field :filters, [:string]
field :my_polymorph, type: Objects::MonthPolymorph
scope 'time'

def call
response.add_field :time, get_time_now
response.add_field :filters, request.arguments[:filters]
response.add_field :time_zones, request.arguments[:time_zones]
response.add_field :my_polymorph, get_time_now
end

private
Expand Down
15 changes: 15 additions & 0 deletions examples/core_api/objects/month_long.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module CoreAPI
module Objects
class MonthLong < Apia::Object

description 'Represents a month'

field :month_long, type: :string do
backend { |t| t.strftime('%B') }
end

end
end
end
15 changes: 15 additions & 0 deletions examples/core_api/objects/month_polymorph.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

require 'core_api/objects/month_long'
require 'core_api/objects/month_short'

module CoreAPI
module Objects
class MonthPolymorph < Apia::Polymorph

option 'MonthLong', type: CoreAPI::Objects::MonthLong, matcher: proc { |time| time.seconds.even? }
option 'MonthShort', type: CoreAPI::Objects::MonthShort, matcher: proc { |time| time.seconds.odd? }

end
end
end
15 changes: 15 additions & 0 deletions examples/core_api/objects/month_short.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module CoreAPI
module Objects
class MonthShort < Apia::Object

description 'Represents a month'

field :month_short, type: :string do
backend { |t| t.strftime('%b') }
end

end
end
end
19 changes: 14 additions & 5 deletions examples/core_api/objects/time.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,22 @@

require 'core_api/objects/day'
require 'core_api/objects/year'
require 'core_api/objects/month_polymorph'

module CoreAPI
module Objects
class Time < Apia::Object

description 'Represents a time'

field :unix, type: :integer do
field :unix, type: :unix_time do
backend(&:to_i)
end

field :day_of_week, type: Objects::Day do
backend { |t| t.strftime('%A') }
end

field :month, type: :string do
backend { |t| t.strftime('%b') }
end

field :full, type: :string do
backend { |t| t.to_s }
end
Expand All @@ -29,6 +26,18 @@ class Time < Apia::Object
backend { |t| t.year }
end

field :month, type: Objects::MonthPolymorph do
backend { |t| t }
end

field :as_array, type: [:integer] do
backend { |t| [t.year, t.month, t.day, t.hour, t.minute, t.second] }
end

field :as_array_of_objects, type: [Objects::Year] do
backend { |t| [t.year] }
end

end
end
end
176 changes: 130 additions & 46 deletions lib/apia-openapi/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def initialize(api, base_url)
end

def json
@spec.to_json
JSON.pretty_generate(@spec)# .to_json
end

private
Expand Down Expand Up @@ -125,14 +125,46 @@ def add_component_schema(definition)
id = generate_id(definition.type.klass.definition)
return unless @spec.dig(:components, :schemas, id).nil?

@spec[:components][:schemas][id] = generate_schema(definition: definition)
component_schema = {}
@spec[:components][:schemas][id] = component_schema
generate_schema(definition: definition, schema: component_schema)
end

# we generate schemas for two reasons:
# We generate schemas for two reasons:
# 1. to add them to the components section (so they can be referenced)
# 2. to add them to the request body when not all fields are returned in the response
def generate_schema(definition:, endpoint: nil, path: nil)
schema = {}
def generate_schema(definition:, schema: ,endpoint: nil, path: nil)
if definition.type.polymorph?
schema[:type] = 'object'
schema[:properties] ||= {}
refs = []
definition.type.klass.definition.options.map do |name, polymorph_option|
refs << { "$ref": "#/components/schemas/#{generate_id(polymorph_option.type.klass.definition)}" }
add_component_schema(polymorph_option)
end
schema[:properties][definition.name.to_s] = { oneOf: refs }
return schema
elsif definition.respond_to?(:array?) && definition.array?
schema[:type] = 'object'
schema[:properties] ||= {}
if definition.type.argument_set? || definition.type.enum? || definition.type.object?
if definition.type.argument_set? # TODO add array of argument sets to the example app (refer to CoreAPI::ArgumentSets::KeyValue)
children = definition.type.klass.definition.arguments.values
else
children = definition.type.klass.definition.fields.values
end
else
items = { type: definition.type.klass.definition.name.downcase }
end

if items
schema[:properties][definition.name.to_s] = {
type: "array",
items: items
}
return schema
end
end

if definition.type.argument_set?
children = definition.type.klass.definition.arguments.values
Expand All @@ -144,13 +176,15 @@ def generate_schema(definition:, endpoint: nil, path: nil)
children = []
end

all_properties_included = definition.type.enum? || endpoint.nil? || children.all? { |child| endpoint.include_field?(path + [child.name]) }

children.each do |child|
next unless endpoint.nil? || (!definition.type.enum? && endpoint.include_field?(path + [child.name]))

if definition.type.enum?
schema[:type] = 'string'
schema[:enum] = children.map { |c| c[:name] }
elsif child.type.argument_set? || child.type.enum? # polymorph?
elsif child.type.argument_set? || child.type.enum? || child.type.polymorph?
schema[:type] = 'object'
schema[:properties] ||= {}
schema[:properties][child.name.to_s] = {
Expand All @@ -162,43 +196,54 @@ def generate_schema(definition:, endpoint: nil, path: nil)
schema[:properties] ||= {}
# In theory we could point to a ref here if * is used to include all fields of a child object, but we'd
# need to parse the endpoint include string to determine if that's the case.
child_path = path.nil? ? nil : path + [child]
schema[:properties][child.name.to_s] = generate_schema(definition: child, endpoint: endpoint, path: child_path)
if all_properties_included
schema[:properties][child.name.to_s] = {
"$ref": "#/components/schemas/#{generate_id(child.type.klass.definition)}"
}
add_component_schema(child)
else
child_path = path.nil? ? nil : path + [child]
puts "definition.type: #{definition.type.inspect}"
child_schema = {}
schema[:properties][child.name.to_s] = child_schema
generate_schema(definition: child, schema: child_schema, endpoint: endpoint, path: child_path)
end
else
schema[:type] = 'object'
schema[:properties] ||= {}
schema[:properties][child.name.to_s] = {
type: child.type.klass.definition.name.downcase
type: map_type_to_openapi_property_type(child.type)

}
end
end

schema
end

# TODO: can you use a polymorph in a request body?
def add_request_body(route, route_spec)
properties = {}
route.endpoint.definition.argument_set.definition.arguments.each_value do |arg|
id = generate_id(arg.type.klass.definition)
if arg.array?
if arg.type.argument_set? || arg.type.enum? # polymorph?
if arg.type.argument_set? || arg.type.enum?
items = { "$ref": "#/components/schemas/#{id}" }
add_component_schema(arg)
else
items = { type: arg.type.klass.definition.name.downcase }
end

properties[arg.name.to_s] = {
type: "array",
items: items
}

elsif arg.type.argument_set? || arg.type.enum? # polymorph?
type: "array",
items: items
}
elsif arg.type.argument_set? || arg.type.enum?
properties[arg.name.to_s] = {
"$ref": "#/components/schemas/#{id}"
}
add_component_schema(arg)
else
else # scalar
properties[arg.name.to_s] = {
type: arg.type.klass.definition.name.downcase
}
Expand All @@ -222,36 +267,9 @@ def add_request_body(route, route_spec)
def add_responses(route, route_spec)
properties = {}
route.endpoint.definition.fields.each do |name, field|
if field.array?
if field.type.object? || field.type.enum? # polymorph?
if field.include.nil?
items = { "$ref": "#/components/schemas/#{generate_id(field.type.klass.definition)}" }
add_component_schema(field)
else
schema = generate_schema(definition: field, endpoint: route.endpoint, path: [field])
items = schema[:properties]
end
else
items = { type: field.type.klass.definition.name.downcase }
end
properties[name] = {
type: "array",
items: items
}
elsif field.type.object? || field.type.enum? # polymorph?
if field.include.nil?
properties[name] = {
"$ref": "#/components/schemas/#{generate_id(field.type.klass.definition)}"
}
add_component_schema(field)
elsif schema = generate_schema(definition: field, endpoint: route.endpoint, path: [field])
properties[name] = schema
end
else
properties[name] = {
type: field.type.klass.definition.name.downcase
}
end
properties.merge!(
generate_properties_for_response(name, field, route.endpoint)
)
end

schema = {
Expand All @@ -273,6 +291,62 @@ def add_responses(route, route_spec)
}
end

# Response fields can often just point to a ref of a schema. But it's also
# possible to reference a return type and not include all fields of that type.
# The presence of the `include` keyword arg defines which fields are included.
def generate_properties_for_response(name, field, endpoint)
properties = {}
if field.type.polymorph?
if field.include.nil?
refs = []
field.type.klass.definition.options.map do |name, polymorph_option|
refs << { "$ref": "#/components/schemas/#{generate_id(polymorph_option.type.klass.definition)}" }
add_component_schema(polymorph_option)
end
properties[name] = { oneOf: refs }
else
# TODO
end
elsif field.array?
if field.type.object? || field.type.enum? # polymorph?
if field.include.nil?
items = { "$ref": "#/components/schemas/#{generate_id(field.type.klass.definition)}" }
add_component_schema(field)
else
array_schema = {}
generate_schema(definition: field, schema: array_schema, endpoint: endpoint, path: [field])
if array_schema[:properties].any?
items = array_schema
end
end
else
items = { type: field.type.klass.definition.name.downcase }
end
if items
properties[name] = {
type: "array",
items: items
}
end
elsif field.type.object? || field.type.enum?
if field.include.nil?
properties[name] = {
"$ref": "#/components/schemas/#{generate_id(field.type.klass.definition)}"
}
add_component_schema(field)
else
object_schema = {}
generate_schema(definition: field, schema: object_schema, endpoint: endpoint, path: [field])
properties[name] = object_schema
end
else # scalar
properties[name] = {
type: map_type_to_openapi_property_type(field.type)
}
end
properties
end

def add_security
@api.objects.select { |o| o.ancestors.include?(Apia::Authenticator) }.each do |authenticator|
next unless authenticator.definition.type == :bearer
Expand All @@ -293,6 +367,16 @@ def add_security
def generate_id(definition)
definition.id.gsub(/\//, '_')
end

def map_type_to_openapi_property_type(type)
if type.klass == Apia::Scalars::UnixTime
'integer'
elsif type.klass == Apia::Scalars::Decimal
'string' # TODO: or integer, add this to example app
else
type.klass.definition.name.downcase
end
end
end
end
end

0 comments on commit 6a8b33f

Please sign in to comment.