diff --git a/Gemfile b/Gemfile index 3eaff64..1685056 100644 --- a/Gemfile +++ b/Gemfile @@ -13,3 +13,5 @@ gem 'solargraph' gem 'rake' gem 'rubocop' + +gem 'pry' diff --git a/examples/config.ru b/examples/config.ru index 2796708..af91442 100644 --- a/examples/config.ru +++ b/examples/config.ru @@ -7,6 +7,9 @@ require 'apia' require 'apia/rack' require 'core_api/base' +require 'apia-openapi' + +use Apia::OpenAPI::Rack, 'CoreAPI::Base', '/core/v1/schema/openapi.json', base_url: 'http://127.0.0.1:9292/core/v1/' use Apia::Rack, CoreAPI::Base, '/core/v1', development: true app = proc do diff --git a/examples/core_api/argument_sets/object_lookup.rb b/examples/core_api/argument_sets/object_lookup.rb new file mode 100644 index 0000000..b321d90 --- /dev/null +++ b/examples/core_api/argument_sets/object_lookup.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module CoreAPI + module ArgumentSets + class ObjectLookup < Apia::LookupArgumentSet + + name 'Object Lookup' + description 'Provides for objects to be looked up' + + argument :id, type: :string + argument :permalink, type: :string + + potential_error 'ObjectNotFound' do + code :object_not_found + description 'No object was found matching any of the criteria provided in the arguments' + http_status 404 + end + + resolver do |set, request, scope| + objects = [{id: "123", permalink: "perma-123"}] + object = objects.find { |o| o[:id] == set[:id] || o[:permalink] == set[:permalink] } + raise_error 'ObjectNotFound' if object.nil? + + object + end + + end + end +end diff --git a/examples/core_api/base.rb b/examples/core_api/base.rb index 7876d06..a7125ef 100644 --- a/examples/core_api/base.rb +++ b/examples/core_api/base.rb @@ -2,6 +2,7 @@ require 'core_api/main_authenticator' require 'core_api/controllers/time_controller' +require 'core_api/endpoints/test_endpoint' module CoreAPI class Base < Apia::API @@ -17,11 +18,16 @@ class Base < Apia::API get 'example/format', controller: Controllers::TimeController, endpoint: :format post 'example/format', controller: Controllers::TimeController, endpoint: :format + post 'example/format_multiple', controller: Controllers::TimeController, endpoint: :format_multiple group :time do name 'Time functions' description 'Everything related to time elements' get 'time/now', endpoint: Endpoints::TimeNowEndpoint + post 'time/now', endpoint: Endpoints::TimeNowEndpoint + + get 'test/:object', endpoint: Endpoints::TestEndpoint + post 'test/:object', endpoint: Endpoints::TestEndpoint group :formatting do name 'Formatting' diff --git a/examples/core_api/controllers/time_controller.rb b/examples/core_api/controllers/time_controller.rb index a867db2..3b5b170 100644 --- a/examples/core_api/controllers/time_controller.rb +++ b/examples/core_api/controllers/time_controller.rb @@ -9,13 +9,16 @@ module Controllers class TimeController < Apia::Controller name 'Time API' - description 'Returns the current time in varying ways' + description 'Returns the time in varying ways' 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 + argument :timezone, type: Objects::TimeZone field :formatted_time, type: :string action do time = request.arguments[:time] @@ -23,6 +26,21 @@ class TimeController < Apia::Controller end end + endpoint :format_multiple do + 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 + end + response.add_field :formatted_times, times.map(&:to_s).join(", ") + response.add_field :times, times + end + end + end end end diff --git a/examples/core_api/endpoints/test_endpoint.rb b/examples/core_api/endpoints/test_endpoint.rb new file mode 100644 index 0000000..c6db59c --- /dev/null +++ b/examples/core_api/endpoints/test_endpoint.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'core_api/argument_sets/object_lookup' + +module CoreAPI + module Endpoints + class TestEndpoint < Apia::Endpoint + + description 'Returns the current time' + argument :object, type: ArgumentSets::ObjectLookup, required: true + field :time, type: Objects::Time, include: 'unix,day_of_week,year[as_string]', null: true do + condition do |o| + o[:time].year.to_s == '2023' + end + end + field :object_id, type: :string do + backend { |o| o[:object_id][:id] } + end + scope 'time' + + def call + object = request.arguments[:object].resolve + response.add_field :time, get_time_now + response.add_field :object_id, object + end + + private + + def get_time_now + Time.now + end + + end + end +end diff --git a/examples/core_api/endpoints/time_now_endpoint.rb b/examples/core_api/endpoints/time_now_endpoint.rb index 9ad549b..a1cfdfc 100644 --- a/examples/core_api/endpoints/time_now_endpoint.rb +++ b/examples/core_api/endpoints/time_now_endpoint.rb @@ -1,15 +1,26 @@ # frozen_string_literal: true +require 'core_api/objects/time_zone' + module CoreAPI module Endpoints class TimeNowEndpoint < Apia::Endpoint description 'Returns the current time' - field :time, type: Objects::Time, include: 'unix,day_of_week' + argument :timezone, type: Objects::TimeZone + argument :time_zones, [Objects::TimeZone] + argument :filters, [:string] + 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/main_authenticator.rb b/examples/core_api/main_authenticator.rb index f24e87e..212cdc0 100644 --- a/examples/core_api/main_authenticator.rb +++ b/examples/core_api/main_authenticator.rb @@ -18,15 +18,15 @@ def call cors.methods = %w[GET POST PUT PATCH DELETE OPTIONS] # Define a list of cors headers that are permitted for the request. - cors.headers = %w[X-Custom-Header] + cors.headers = %w[Authorization Content-Type] # or allow all with '*' # Define a the hostname to allow for CORS requests. cors.origin = '*' # or 'example.com' - cors.origin = 'krystal.uk' + + return if request.options? given_token = request.headers['authorization']&.sub(/\ABearer /, '') - case given_token - when 'example' + if given_token == 'example' request.identity = { name: 'Example User', id: 1234 } else raise_error 'CoreAPI/MainAuthenticator/InvalidToken', given_token: given_token.to_s 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..2640de1 --- /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.sec.even? } + option 'MonthShort', type: CoreAPI::Objects::MonthShort, matcher: proc { |time| time.sec.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 40bfcee..17ebee7 100644 --- a/examples/core_api/objects/time.rb +++ b/examples/core_api/objects/time.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require 'core_api/objects/day' +require 'core_api/objects/year' +require 'core_api/objects/month_polymorph' module CoreAPI module Objects @@ -8,22 +10,42 @@ class Time < Apia::Object description 'Represents a time' - field :unix, type: :integer do - backend(&:to_i) + field :unix, type: :unix_time do + backend { |t| t } 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 + field :year, type: Objects::Year do + 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.min, t.sec] } + end + + field :as_array_of_objects, type: [Objects::Year] do + backend { |t| [t.year] } + end + + field :as_decimal, type: :decimal do + backend { |t| t.to_f } + end + + field :as_base64, type: :base64 do + backend { |t| Base64.encode64(t.to_s) } + end + end end end diff --git a/examples/core_api/objects/time_zone.rb b/examples/core_api/objects/time_zone.rb new file mode 100644 index 0000000..2538b70 --- /dev/null +++ b/examples/core_api/objects/time_zone.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module CoreAPI + module Objects + class TimeZone < Apia::Enum + + value 'Europe/London' + value 'Europe/Madrid' + value 'Asia/Singapore' + + end + end +end diff --git a/examples/core_api/objects/year.rb b/examples/core_api/objects/year.rb new file mode 100644 index 0000000..f844114 --- /dev/null +++ b/examples/core_api/objects/year.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'core_api/objects/day' + +module CoreAPI + module Objects + class Year < Apia::Object + + description 'Represents a year' + + field :as_integer, type: :integer do + backend(&:to_i) + end + + field :as_string, type: :string do + backend(&:to_s) + end + + end + end +end diff --git a/lib/apia-openapi.rb b/lib/apia-openapi.rb new file mode 100644 index 0000000..5716ff3 --- /dev/null +++ b/lib/apia-openapi.rb @@ -0,0 +1 @@ +require 'apia-openapi/rack' diff --git a/lib/apia-openapi/rack.rb b/lib/apia-openapi/rack.rb new file mode 100644 index 0000000..5250a73 --- /dev/null +++ b/lib/apia-openapi/rack.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'apia-openapi/schema' + +module Apia + module OpenAPI + class Rack + + def initialize(app, api, path, **options) + @app = app + @api = api + @path = "/#{path.sub(/\A\/+/, '').sub(/\/+\z/, '')}" + @options = options + end + + def development? + env_is_dev = ENV['RACK_ENV'] == 'development' + return true if env_is_dev && @options[:development].nil? + + @options[:development] == true + end + + def api + return Object.const_get(@api) if @api.is_a?(String) && development? + return @cached_api ||= Object.const_get(@api) if @api.is_a?(String) + + @api + end + + def base_url + @options[:base_url] || 'https://api.example.com/api/v1' + end + + def call(env) + # if @options[:hosts]&.none? { |host| host == env['HTTP_HOST'] } + # return @app.call(env) + # end + + unless env['PATH_INFO'] == @path + return @app.call(env) + end + + schema = Schema.new(api, base_url) + body = schema.json + + [200, { 'Content-Type' => 'application/json', 'Content-Length' => body.bytesize.to_s }, [body]] + end + + end + end +end diff --git a/lib/apia-openapi/schema.rb b/lib/apia-openapi/schema.rb new file mode 100644 index 0000000..8689c5b --- /dev/null +++ b/lib/apia-openapi/schema.rb @@ -0,0 +1,390 @@ +# frozen_string_literal: true + +module Apia + module OpenAPI + class Schema + + def initialize(api, base_url) + @api = api + @base_url = base_url # TODO: should we support multiple urls? + @spec = { + openapi: '3.1.0', + info: {}, + servers: [], + paths: {}, + components: { + schemas: {} + }, + security: [] + } + build_spec + end + + def json + JSON.pretty_generate(@spec)# .to_json + end + + private + + def build_spec + add_info + add_servers + add_paths + add_security + end + + def add_info + @spec[:info] = { + version: "1.0.0", # TODO can we actually read the api version? + title: @api.definition.name || @api.definition.id + } + @spec[:info][:description] = @api.definition.description unless @api.definition.description.nil? + end + + def add_servers + @spec[:servers] << { url: @base_url } + end + + def add_paths + @api.definition.route_set.routes.each do |route| + next unless route.endpoint.definition.schema? # not all routes should be documented + + path = route.path + route_spec = { operationId: "#{route.request_method}:#{path}" } + if route.request_method == :get + add_parameters(route, route_spec) + else + add_request_body(route, route_spec) + end + + @spec[:paths]["/#{path}"] ||= {} + @spec[:paths]["/#{path}"]["#{route.request_method.to_s}"] = route_spec + + add_responses(route, route_spec) + end + end + + # aka query params + def add_parameters(route, route_spec) + route_spec[:parameters] ||= [] + + route.endpoint.definition.argument_set.definition.arguments.each_value do |arg| + if arg.type.argument_set? + # complex argument sets are not supported in query params (e.g. nested objects) + arg.type.klass.definition.arguments.each_value do |child_arg| + param = { + name: "#{arg.name.to_s}[#{child_arg.name.to_s}]", + in: "query", + schema: { + type: convert_type_to_openapi_data_type(child_arg.type) + } + } + route_spec[:parameters] << param + end + elsif arg.array? + if arg.type.enum? || arg.type.object? # polymorph? + items = { "$ref": "#/components/schemas/#{generate_id(arg.type.klass.definition)}" } + add_component_schema(arg) + else + items = { type: convert_type_to_openapi_data_type(arg.type) } + end + + param = { + name: "#{arg.name}[]", + in: "query", + schema: { + type: "array", + items: items + } + } + route_spec[:parameters] << param + elsif arg.type.enum? + param = { + name: arg.name.to_s, + in: "query", + schema: { + "$ref": "#/components/schemas/#{generate_id(arg.type.klass.definition)}" + } + } + route_spec[:parameters] << param + add_component_schema(arg) + else + param = { + name: arg.name.to_s, + in: "query", + schema: { + type: convert_type_to_openapi_data_type(arg.type) + } + } + route_spec[:parameters] << param + end + end + end + + def add_component_schema(definition) + id = generate_id(definition.type.klass.definition) + return unless @spec.dig(:components, :schemas, id).nil? + + component_schema = {} + @spec[:components][:schemas][id] = component_schema + generate_schema(definition: definition, schema: component_schema) + end + + # 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:, 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: convert_type_to_openapi_data_type(definition.type) } + 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 + elsif definition.type.object? + children = definition.type.klass.definition.fields.values + elsif definition.type.enum? + children = definition.type.klass.definition.values.values + else + children = [] + end + + # if !definition.type.enum? && !endpoint.nil? + # puts "definition.type #{definition.type.klass}" + # puts "children: #{children.map { |c| [c.name, endpoint.include_field?(path + [c.name])] }}" + # puts "children.all? #{children.all? { |child| endpoint.include_field?(path + [child.name]) }}" + # puts "" + # end + all_properties_included = definition.type.enum? || endpoint.nil? #|| children.all? { |child| endpoint.include_field?(path + [child.name]) } + #puts "all_properties_included: #{all_properties_included}" + + 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? || child.type.polymorph? + schema[:type] = 'object' + schema[:properties] ||= {} + schema[:properties][child.name.to_s] = { + "$ref": "#/components/schemas/#{generate_id(child.type.klass.definition)}" + } + add_component_schema(child) + elsif child.type.object? + schema[:type] = 'object' + 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. + 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] + 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: convert_type_to_openapi_data_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? + items = { "$ref": "#/components/schemas/#{id}" } + add_component_schema(arg) + else + items = { type: convert_type_to_openapi_data_type(arg.type) } + end + + properties[arg.name.to_s] = { + 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 # scalar + properties[arg.name.to_s] = { + type: convert_type_to_openapi_data_type(arg.type) + } + end + + end + + # TODO: description + # TODO: required + route_spec[:requestBody] = { + content: { + "application/json": { + schema: { + properties: properties + } + } + } + } + end + + def add_responses(route, route_spec) + properties = {} + route.endpoint.definition.fields.each do |name, field| + properties.merge!( + generate_properties_for_response(name, field, route.endpoint) + ) + end + + schema = { + properties: properties + } + + required_fields = route.endpoint.definition.fields.select { |name, field| field.condition.nil? } + schema[:required] = required_fields.keys if required_fields.any? + + route_spec[:responses] = { + "#{route.endpoint.definition.http_status}": { + description: route.endpoint.definition.description || "", + content: { + "application/json": { + schema: schema + } + } + } + } + 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: convert_type_to_openapi_data_type(field.type) } + 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: convert_type_to_openapi_data_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 + + @spec[:components][:securitySchemes] ||= {} + @spec[:components][:securitySchemes][generate_id(authenticator.definition)] = { + scheme: 'bearer', + type: "http", + } + + @spec[:security] << { + generate_id(authenticator.definition) => [] + } + end + end + + # forward slashes do not work in ids (e.g. schema ids) + def generate_id(definition) + definition.id.gsub(/\//, '_') + end + + def convert_type_to_openapi_data_type(type) + if type.klass == Apia::Scalars::UnixTime + 'integer' + elsif type.klass == Apia::Scalars::Decimal + 'number' + elsif type.klass == Apia::Scalars::Base64 + 'string' + else + type.klass.definition.name.downcase + end + end + end + end +end diff --git a/lib/apia/rack.rb b/lib/apia/rack.rb index 485ccfd..8797bc3 100644 --- a/lib/apia/rack.rb +++ b/lib/apia/rack.rb @@ -79,7 +79,8 @@ def handle_request(env, api_path) validate_api if development? - route = find_route(request_method, api_path) + access_control_request_method = env['HTTP_ACCESS_CONTROL_REQUEST_METHOD']&.upcase + route = find_route((access_control_request_method || request_method), api_path) if route.nil? Apia::Notifications.notify(:request_route_not_found, notify_hash) raise RackError.new(404, 'route_not_found', "No route matches '#{api_path}' for #{request_method}") @@ -159,6 +160,7 @@ class << self # @return [Array] def json_triplet(body, status: 200, headers: {}) body_as_json = body.to_json + puts "body_as_json: #{body_as_json}" [ status, headers.merge('content-type' => 'application/json', 'content-length' => body_as_json.bytesize.to_s), diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3a66f61..b6ea21e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,6 +2,7 @@ SPEC_ROOT = __dir__ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +$LOAD_PATH.unshift(File.expand_path('../examples', __dir__)) require 'apia' Dir[File.join(SPEC_ROOT, 'specs', 'support', '**', '*.rb')].sort.each { |path| require path } diff --git a/spec/specs/apia-openapi/schema_spec.rb b/spec/specs/apia-openapi/schema_spec.rb new file mode 100644 index 0000000..9fd947e --- /dev/null +++ b/spec/specs/apia-openapi/schema_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'apia-openapi/schema' +require 'core_api/base' + +describe Apia::OpenAPI::Schema do + describe "#json" do + it "produces OpenAPI JSON" do + base_url = 'http://127.0.0.1:9292/core/v1' + api = CoreAPI::Base + + schema = described_class.new(api, base_url) + spec = JSON.parse(schema.json) + + puts JSON.pretty_generate(spec) + + expect(spec).to be_a Hash + end + end +end diff --git a/spec/specs/apia/argument_set_spec.rb b/spec/specs/apia/argument_set_spec.rb index 8900690..55c11d9 100644 --- a/spec/specs/apia/argument_set_spec.rb +++ b/spec/specs/apia/argument_set_spec.rb @@ -88,6 +88,23 @@ expect(as_instance['age']).to eq '33' end + it 'can parse deeply nested query params' do + env = Rack::MockRequest.env_for('/?authors[][name]=ada&authors[][age]=40&authors[][name]=bob&authors[][age]=30', 'CONTENT_TYPE' => 'application/json') + request = Apia::Request.new(env) + author_as = Apia::ArgumentSet.create('AuthorSet') do + argument :name, type: :string + argument :age, type: :string + end + as = Apia::ArgumentSet.create('ExampleSet') do + argument :authors, type: [author_as] + end + as_instance = as.create_from_request(request) + expect(as_instance['authors'][0]['name']).to eq 'ada' + expect(as_instance['authors'][0]['age']).to eq '40' + expect(as_instance['authors'][1]['name']).to eq 'bob' + expect(as_instance['authors'][1]['age']).to eq '30' + end + it 'should create a new empty set if nothing provided' do env = Rack::MockRequest.env_for('/') request = Apia::Request.new(env) diff --git a/spec/specs/apia/rack_spec.rb b/spec/specs/apia/rack_spec.rb index 8710386..9f62bbc 100644 --- a/spec/specs/apia/rack_spec.rb +++ b/spec/specs/apia/rack_spec.rb @@ -154,6 +154,34 @@ def call(_env) expect(Apia::Notifications).to have_received(:notify).with(:request_end, hash_including(path: 'test', method: 'GET', env: kind_of(Hash))).once end + it 'should handle OPTIONS requests' do + controller = Apia::Controller.create('Controller') do + endpoint :test do + action do + response.add_field :hello, 'world' + end + end + end + api = Apia::API.create('MyAPI') do + routes do + get 'test', controller: controller, endpoint: :test + end + end + rack = described_class.new(app, api, 'api/v1') + mock_request = Rack::MockRequest.env_for( + '/api/v1/test', + 'REQUEST_METHOD' => 'OPTIONS', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET' + ) + result = rack.call(mock_request) + expect(result).to be_a Array + expect(result[0]).to eq 200 + headers = result[1] + expect(headers['Access-Control-Allow-Origin']).to eq '*' + expect(headers['Access-Control-Allow-Methods']).to eq '*' + expect(result[2][0]).to eq("\"\"") + end + it 'should catch rack errors and return an error triplet' do api = Apia::API.create('MyAPI') rack = described_class.new(app, api, 'api/v1', development: true)