diff --git a/examples/core_api/controllers/time_controller.rb b/examples/core_api/controllers/time_controller.rb index e7490cc..3b5b170 100644 --- a/examples/core_api/controllers/time_controller.rb +++ b/examples/core_api/controllers/time_controller.rb @@ -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 @@ -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 diff --git a/examples/core_api/endpoints/time_now_endpoint.rb b/examples/core_api/endpoints/time_now_endpoint.rb index 782fc0a..a1cfdfc 100644 --- a/examples/core_api/endpoints/time_now_endpoint.rb +++ b/examples/core_api/endpoints/time_now_endpoint.rb @@ -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 diff --git a/examples/core_api/objects/month_long.rb b/examples/core_api/objects/month_long.rb new file mode 100644 index 0000000..116cde2 --- /dev/null +++ b/examples/core_api/objects/month_long.rb @@ -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 diff --git a/examples/core_api/objects/month_polymorph.rb b/examples/core_api/objects/month_polymorph.rb new file mode 100644 index 0000000..10a33e9 --- /dev/null +++ b/examples/core_api/objects/month_polymorph.rb @@ -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 diff --git a/examples/core_api/objects/month_short.rb b/examples/core_api/objects/month_short.rb new file mode 100644 index 0000000..dd8d193 --- /dev/null +++ b/examples/core_api/objects/month_short.rb @@ -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 diff --git a/examples/core_api/objects/time.rb b/examples/core_api/objects/time.rb index e55c372..2e2177a 100644 --- a/examples/core_api/objects/time.rb +++ b/examples/core_api/objects/time.rb @@ -2,6 +2,7 @@ require 'core_api/objects/day' require 'core_api/objects/year' +require 'core_api/objects/month_polymorph' module CoreAPI module Objects @@ -9,7 +10,7 @@ class Time < Apia::Object description 'Represents a time' - field :unix, type: :integer do + field :unix, type: :unix_time do backend(&:to_i) end @@ -17,10 +18,6 @@ class Time < Apia::Object 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 @@ -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 diff --git a/lib/apia-openapi/schema.rb b/lib/apia-openapi/schema.rb index e961838..711aa5a 100644 --- a/lib/apia-openapi/schema.rb +++ b/lib/apia-openapi/schema.rb @@ -21,7 +21,7 @@ def initialize(api, base_url) end def json - @spec.to_json + JSON.pretty_generate(@spec)# .to_json end private @@ -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 @@ -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] = { @@ -162,13 +196,24 @@ 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 @@ -176,12 +221,13 @@ def generate_schema(definition:, endpoint: nil, path: nil) 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 @@ -189,16 +235,15 @@ def add_request_body(route, route_spec) 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 } @@ -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 = { @@ -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 @@ -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