From 588f38207f62f0bbedb0f4147bd4f816d90b64e9 Mon Sep 17 00:00:00 2001 From: Paul Sturgess Date: Wed, 25 Oct 2023 18:16:41 +0100 Subject: [PATCH 1/5] fix(rack): ensure OPTIONS preflight requests are handled --- examples/core_api/main_authenticator.rb | 8 +++---- lib/apia/rack.rb | 3 ++- spec/specs/apia/rack_spec.rb | 28 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) 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/lib/apia/rack.rb b/lib/apia/rack.rb index 485ccfd..b734afa 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}") 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) From 512e3ad81284d0547b1848c785ad5fc82c6a6276 Mon Sep 17 00:00:00 2001 From: Paul Sturgess Date: Fri, 27 Oct 2023 08:39:53 +0100 Subject: [PATCH 2/5] WIP --- Gemfile | 2 + examples/config.ru | 3 + .../core_api/argument_sets/object_lookup.rb | 29 ++ examples/core_api/base.rb | 5 + .../core_api/controllers/time_controller.rb | 16 +- examples/core_api/endpoints/test_endpoint.rb | 27 ++ .../core_api/endpoints/time_now_endpoint.rb | 6 +- examples/core_api/objects/time_zone.rb | 11 + lib/apia-openapi.rb | 1 + lib/apia-openapi/rack.rb | 51 ++++ lib/apia-openapi/schema.rb | 258 ++++++++++++++++++ lib/apia/rack.rb | 1 + spec/spec_helper.rb | 1 + spec/specs/apia-openapi/schema_spec.rb | 21 ++ spec/specs/apia/argument_set_spec.rb | 17 ++ 15 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 examples/core_api/argument_sets/object_lookup.rb create mode 100644 examples/core_api/endpoints/test_endpoint.rb create mode 100644 examples/core_api/objects/time_zone.rb create mode 100644 lib/apia-openapi.rb create mode 100644 lib/apia-openapi/rack.rb create mode 100644 lib/apia-openapi/schema.rb create mode 100644 spec/specs/apia-openapi/schema_spec.rb 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..b24eed8 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' 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..3a5212e 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,12 +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 + get 'test/:object', endpoint: Endpoints::TestEndpoint + post 'test/:object', endpoint: Endpoints::TestEndpoint + group :formatting do name 'Formatting' controller Controllers::TimeController diff --git a/examples/core_api/controllers/time_controller.rb b/examples/core_api/controllers/time_controller.rb index a867db2..e7490cc 100644 --- a/examples/core_api/controllers/time_controller.rb +++ b/examples/core_api/controllers/time_controller.rb @@ -9,13 +9,14 @@ 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 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 +24,19 @@ 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] + action do + times = [] + request.arguments[:times].each do |time| + times << time.resolve.to_s + end + response.add_field :formatted_times, times.join(", ") + 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..9ca8c2e --- /dev/null +++ b/examples/core_api/endpoints/test_endpoint.rb @@ -0,0 +1,27 @@ +# 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' + scope 'time' + + def call + object = request.arguments[:object].resolve + response.add_field :time, get_time_now + 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..c7b98cf 100644 --- a/examples/core_api/endpoints/time_now_endpoint.rb +++ b/examples/core_api/endpoints/time_now_endpoint.rb @@ -1,12 +1,16 @@ # frozen_string_literal: true +require 'core_api/objects/time_zone' + module CoreAPI module Endpoints class TimeNowEndpoint < Apia::Endpoint description 'Returns the current time' + argument :timezone, type: Objects::TimeZone + # argument :filters, [:string] field :time, type: Objects::Time, include: 'unix,day_of_week' - scope 'time' + scope 'time' # TODO: what does this do? def call response.add_field :time, get_time_now diff --git a/examples/core_api/objects/time_zone.rb b/examples/core_api/objects/time_zone.rb new file mode 100644 index 0000000..56860ab --- /dev/null +++ b/examples/core_api/objects/time_zone.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module CoreAPI + module Objects + class TimeZone < Apia::Enum + + value 'Europe/London' + + 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..346dcd7 --- /dev/null +++ b/lib/apia-openapi/schema.rb @@ -0,0 +1,258 @@ +# 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.0.0', # swagger-editor does not support 3.1.0 :( + info: {}, + servers: [], + paths: {}, + components: { + schemas: {}, + responses: {} + }, + security: [] + } + build_spec + end + + def json + @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_without_params = route.path.gsub(/:[^\/]+/, '_') + path_without_params = route.path + route_spec = { operationId: "#{route.request_method}:#{path_without_params}" } + if route.request_method == :get + add_parameters(route, route_spec) + else + add_request_body(route, route_spec) + end + + @spec[:paths]["/#{path_without_params}"] ||= {} + @spec[:paths]["/#{path_without_params}"]["#{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: child_arg.type.klass.definition.name.downcase + } + } + route_spec[:parameters] << param + end + elsif arg.array? + # TODO: array of objects + param = { + name: arg.name.to_s, + in: "query", + schema: { + type: "array", + items: { + type: arg.type.klass.definition.name.downcase + } + } + } + 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: arg.type.klass.definition.name.downcase # TODO: do these map to OpenAPI types? + } + } + 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? + + schema = {} + + 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 + + children.each do |child| + if definition.type.enum? + schema[:type] = 'string' + schema[:enum] = children.map { |c| c[:name] } + elsif child.type.argument_set? || child.type.enum? # || child_type.object? # 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) + else + schema[:type] = 'object' + schema[:properties] ||= {} + # TODO: do these map to OpenAPI types? + # object? polymorph? + schema[:properties][child.name.to_s] = { + type: child.type.klass.definition.name.downcase + } + end + end + + @spec[:components][:schemas][id] = schema + end + + 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.type.argument_set? || arg.type.enum? # || arg.type.object? # polymorph? + if arg.array? + properties[arg.name.to_s] = { + type: "array", + items: { + "$ref": "#/components/schemas/#{id}" + } + } + else + properties[arg.name.to_s] = { + "$ref": "#/components/schemas/#{id}" + } + end + add_component_schema(arg) + else + properties[arg.name.to_s] = { + type: arg.type.klass.definition.name.downcase # TODO: do these map to OpenAPI types? + } + end + + end + + # TODO: description + # TODO: required + route_spec[:requestBody] = { + content: { + "application/json": { + schema: { + properties: properties + } + } + } + } + end + + # TODO: object might not include all fields in the response + def add_responses(route, route_spec) + properties = {} + route.endpoint.definition.fields.each do |name, field| + if field.type.argument_set? + properties[name] = { + "$ref": "#/components/schemas/#{generate_id(field.type.klass.definition)}" + } + add_component_schema(field) + elsif field.type.object? + properties[name] = { + "$ref": "#/components/schemas/#{generate_id(field.type.klass.definition)}" + } + add_component_schema(field) + else + properties[name] = { + type: field.type.klass.definition.name.downcase # TODO: do these map to OpenAPI types? + } + end + end + + route_spec[:responses] = { + "#{route.endpoint.definition.http_status}": { + description: route.endpoint.definition.description, # does this break if nil? + content: { + "application/json": { + schema: { + properties: 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 + + def generate_id(definition) + definition.id.gsub(/\//, '_') + end + end + end +end diff --git a/lib/apia/rack.rb b/lib/apia/rack.rb index b734afa..8797bc3 100644 --- a/lib/apia/rack.rb +++ b/lib/apia/rack.rb @@ -160,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) From eb5b33c6a663a496a4fc4d4882df4394afb33081 Mon Sep 17 00:00:00 2001 From: Paul Sturgess Date: Mon, 30 Oct 2023 12:05:47 +0000 Subject: [PATCH 3/5] partial responses --- examples/core_api/base.rb | 1 + examples/core_api/endpoints/test_endpoint.rb | 10 +- .../core_api/endpoints/time_now_endpoint.rb | 11 +- examples/core_api/objects/time.rb | 5 + examples/core_api/objects/time_zone.rb | 2 + examples/core_api/objects/year.rb | 21 +++ lib/apia-openapi/schema.rb | 122 ++++++++++++------ 7 files changed, 127 insertions(+), 45 deletions(-) create mode 100644 examples/core_api/objects/year.rb diff --git a/examples/core_api/base.rb b/examples/core_api/base.rb index 3a5212e..a7125ef 100644 --- a/examples/core_api/base.rb +++ b/examples/core_api/base.rb @@ -24,6 +24,7 @@ class Base < Apia::API 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 diff --git a/examples/core_api/endpoints/test_endpoint.rb b/examples/core_api/endpoints/test_endpoint.rb index 9ca8c2e..c6db59c 100644 --- a/examples/core_api/endpoints/test_endpoint.rb +++ b/examples/core_api/endpoints/test_endpoint.rb @@ -8,12 +8,20 @@ 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' + 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 diff --git a/examples/core_api/endpoints/time_now_endpoint.rb b/examples/core_api/endpoints/time_now_endpoint.rb index c7b98cf..782fc0a 100644 --- a/examples/core_api/endpoints/time_now_endpoint.rb +++ b/examples/core_api/endpoints/time_now_endpoint.rb @@ -8,12 +8,17 @@ class TimeNowEndpoint < Apia::Endpoint description 'Returns the current time' argument :timezone, type: Objects::TimeZone - # argument :filters, [:string] - field :time, type: Objects::Time, include: 'unix,day_of_week' - scope 'time' # TODO: what does this do? + argument :time_zones, [Objects::TimeZone] + argument :filters, [:string] + field :time, type: Objects::Time + field :time_zones, type: [Objects::TimeZone] + field :filters, [:string] + 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] end private diff --git a/examples/core_api/objects/time.rb b/examples/core_api/objects/time.rb index 40bfcee..e55c372 100644 --- a/examples/core_api/objects/time.rb +++ b/examples/core_api/objects/time.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'core_api/objects/day' +require 'core_api/objects/year' module CoreAPI module Objects @@ -24,6 +25,10 @@ class Time < Apia::Object backend { |t| t.to_s } end + field :year, type: Objects::Year do + backend { |t| t.year } + end + end end end diff --git a/examples/core_api/objects/time_zone.rb b/examples/core_api/objects/time_zone.rb index 56860ab..2538b70 100644 --- a/examples/core_api/objects/time_zone.rb +++ b/examples/core_api/objects/time_zone.rb @@ -5,6 +5,8 @@ module Objects class TimeZone < Apia::Enum value 'Europe/London' + value 'Europe/Madrid' + value 'Asia/Singapore' 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/schema.rb b/lib/apia-openapi/schema.rb index 346dcd7..e961838 100644 --- a/lib/apia-openapi/schema.rb +++ b/lib/apia-openapi/schema.rb @@ -13,8 +13,7 @@ def initialize(api, base_url) servers: [], paths: {}, components: { - schemas: {}, - responses: {} + schemas: {} }, security: [] } @@ -50,17 +49,16 @@ def add_paths @api.definition.route_set.routes.each do |route| next unless route.endpoint.definition.schema? # not all routes should be documented - #path_without_params = route.path.gsub(/:[^\/]+/, '_') - path_without_params = route.path - route_spec = { operationId: "#{route.request_method}:#{path_without_params}" } + 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_without_params}"] ||= {} - @spec[:paths]["/#{path_without_params}"]["#{route.request_method.to_s}"] = route_spec + @spec[:paths]["/#{path}"] ||= {} + @spec[:paths]["/#{path}"]["#{route.request_method.to_s}"] = route_spec add_responses(route, route_spec) end @@ -84,15 +82,19 @@ def add_parameters(route, route_spec) route_spec[:parameters] << param end elsif arg.array? - # TODO: array of objects + 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: arg.type.klass.definition.name.downcase } + end + param = { - name: arg.name.to_s, + name: "#{arg.name}[]", in: "query", schema: { type: "array", - items: { - type: arg.type.klass.definition.name.downcase - } + items: items } } route_spec[:parameters] << param @@ -123,6 +125,13 @@ 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) + 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:, endpoint: nil, path: nil) schema = {} if definition.type.argument_set? @@ -136,51 +145,62 @@ def add_component_schema(definition) end 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.object? # polymorph? + elsif child.type.argument_set? || child.type.enum? # 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. + child_path = path.nil? ? nil : path + [child] + schema[:properties][child.name.to_s] = generate_schema(definition: child, endpoint: endpoint, path: child_path) else schema[:type] = 'object' schema[:properties] ||= {} - # TODO: do these map to OpenAPI types? - # object? polymorph? schema[:properties][child.name.to_s] = { type: child.type.klass.definition.name.downcase } end end - @spec[:components][:schemas][id] = schema + schema end 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.type.argument_set? || arg.type.enum? # || arg.type.object? # polymorph? - if arg.array? - properties[arg.name.to_s] = { - type: "array", - items: { - "$ref": "#/components/schemas/#{id}" - } - } + if arg.array? + if arg.type.argument_set? || arg.type.enum? # polymorph? + items = { "$ref": "#/components/schemas/#{id}" } + add_component_schema(arg) else - properties[arg.name.to_s] = { - "$ref": "#/components/schemas/#{id}" - } + 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? + properties[arg.name.to_s] = { + "$ref": "#/components/schemas/#{id}" + } add_component_schema(arg) else properties[arg.name.to_s] = { - type: arg.type.klass.definition.name.downcase # TODO: do these map to OpenAPI types? + type: arg.type.klass.definition.name.downcase } end @@ -199,35 +219,54 @@ def add_request_body(route, route_spec) } end - # TODO: object might not include all fields in the response def add_responses(route, route_spec) properties = {} route.endpoint.definition.fields.each do |name, field| - if field.type.argument_set? - properties[name] = { - "$ref": "#/components/schemas/#{generate_id(field.type.klass.definition)}" - } - add_component_schema(field) - elsif field.type.object? + 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] = { - "$ref": "#/components/schemas/#{generate_id(field.type.klass.definition)}" + type: "array", + items: items } - add_component_schema(field) + 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 # TODO: do these map to OpenAPI types? + type: field.type.klass.definition.name.downcase } end 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, # does this break if nil? content: { "application/json": { - schema: { - properties: properties - } + schema: schema } } } @@ -250,6 +289,7 @@ def add_security end end + # forward slashes do not work in ids (e.g. schema ids) def generate_id(definition) definition.id.gsub(/\//, '_') end From 6a8b33f52b13989dbcfc00fe7dfbe6c1271d6ffb Mon Sep 17 00:00:00 2001 From: Paul Sturgess Date: Mon, 30 Oct 2023 18:37:57 +0000 Subject: [PATCH 4/5] polymorphs and other bits --- .../core_api/controllers/time_controller.rb | 8 +- .../core_api/endpoints/time_now_endpoint.rb | 2 + examples/core_api/objects/month_long.rb | 15 ++ examples/core_api/objects/month_polymorph.rb | 15 ++ examples/core_api/objects/month_short.rb | 15 ++ examples/core_api/objects/time.rb | 19 +- lib/apia-openapi/schema.rb | 176 +++++++++++++----- 7 files changed, 197 insertions(+), 53 deletions(-) create mode 100644 examples/core_api/objects/month_long.rb create mode 100644 examples/core_api/objects/month_polymorph.rb create mode 100644 examples/core_api/objects/month_short.rb 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 From 0788a7edd81bd647d193e2f8a2715d8276b71fa7 Mon Sep 17 00:00:00 2001 From: Paul Sturgess Date: Tue, 31 Oct 2023 14:47:34 +0000 Subject: [PATCH 5/5] convert_type_to_openapi_data_type v3.1.0 --- examples/config.ru | 2 +- examples/core_api/objects/month_polymorph.rb | 4 +-- examples/core_api/objects/time.rb | 12 +++++-- lib/apia-openapi/schema.rb | 38 ++++++++++++-------- 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/examples/config.ru b/examples/config.ru index b24eed8..af91442 100644 --- a/examples/config.ru +++ b/examples/config.ru @@ -9,7 +9,7 @@ require 'core_api/base' require 'apia-openapi' -use Apia::OpenAPI::Rack, 'CoreAPI::Base', '/core/v1/schema/openapi.json' +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/objects/month_polymorph.rb b/examples/core_api/objects/month_polymorph.rb index 10a33e9..2640de1 100644 --- a/examples/core_api/objects/month_polymorph.rb +++ b/examples/core_api/objects/month_polymorph.rb @@ -7,8 +7,8 @@ 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? } + 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 diff --git a/examples/core_api/objects/time.rb b/examples/core_api/objects/time.rb index 2e2177a..17ebee7 100644 --- a/examples/core_api/objects/time.rb +++ b/examples/core_api/objects/time.rb @@ -11,7 +11,7 @@ class Time < Apia::Object description 'Represents a time' field :unix, type: :unix_time do - backend(&:to_i) + backend { |t| t } end field :day_of_week, type: Objects::Day do @@ -31,13 +31,21 @@ class Time < Apia::Object end field :as_array, type: [:integer] do - backend { |t| [t.year, t.month, t.day, t.hour, t.minute, t.second] } + 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/lib/apia-openapi/schema.rb b/lib/apia-openapi/schema.rb index 711aa5a..8689c5b 100644 --- a/lib/apia-openapi/schema.rb +++ b/lib/apia-openapi/schema.rb @@ -8,7 +8,7 @@ def initialize(api, base_url) @api = api @base_url = base_url # TODO: should we support multiple urls? @spec = { - openapi: '3.0.0', # swagger-editor does not support 3.1.0 :( + openapi: '3.1.0', info: {}, servers: [], paths: {}, @@ -76,7 +76,7 @@ def add_parameters(route, route_spec) name: "#{arg.name.to_s}[#{child_arg.name.to_s}]", in: "query", schema: { - type: child_arg.type.klass.definition.name.downcase + type: convert_type_to_openapi_data_type(child_arg.type) } } route_spec[:parameters] << param @@ -86,7 +86,7 @@ def add_parameters(route, route_spec) items = { "$ref": "#/components/schemas/#{generate_id(arg.type.klass.definition)}" } add_component_schema(arg) else - items = { type: arg.type.klass.definition.name.downcase } + items = { type: convert_type_to_openapi_data_type(arg.type) } end param = { @@ -113,7 +113,7 @@ def add_parameters(route, route_spec) name: arg.name.to_s, in: "query", schema: { - type: arg.type.klass.definition.name.downcase # TODO: do these map to OpenAPI types? + type: convert_type_to_openapi_data_type(arg.type) } } route_spec[:parameters] << param @@ -154,7 +154,7 @@ def generate_schema(definition:, schema: ,endpoint: nil, path: nil) children = definition.type.klass.definition.fields.values end else - items = { type: definition.type.klass.definition.name.downcase } + items = { type: convert_type_to_openapi_data_type(definition.type) } end if items @@ -176,7 +176,14 @@ def generate_schema(definition:, schema: ,endpoint: nil, path: nil) children = [] end - all_properties_included = definition.type.enum? || endpoint.nil? || children.all? { |child| endpoint.include_field?(path + [child.name]) } + # 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])) @@ -203,7 +210,6 @@ def generate_schema(definition:, schema: ,endpoint: nil, path: nil) 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) @@ -212,7 +218,7 @@ def generate_schema(definition:, schema: ,endpoint: nil, path: nil) schema[:type] = 'object' schema[:properties] ||= {} schema[:properties][child.name.to_s] = { - type: map_type_to_openapi_property_type(child.type) + type: convert_type_to_openapi_data_type(child.type) } end @@ -231,7 +237,7 @@ def add_request_body(route, route_spec) items = { "$ref": "#/components/schemas/#{id}" } add_component_schema(arg) else - items = { type: arg.type.klass.definition.name.downcase } + items = { type: convert_type_to_openapi_data_type(arg.type) } end properties[arg.name.to_s] = { @@ -245,7 +251,7 @@ def add_request_body(route, route_spec) add_component_schema(arg) else # scalar properties[arg.name.to_s] = { - type: arg.type.klass.definition.name.downcase + type: convert_type_to_openapi_data_type(arg.type) } end @@ -281,7 +287,7 @@ def add_responses(route, route_spec) route_spec[:responses] = { "#{route.endpoint.definition.http_status}": { - description: route.endpoint.definition.description, # does this break if nil? + description: route.endpoint.definition.description || "", content: { "application/json": { schema: schema @@ -320,7 +326,7 @@ def generate_properties_for_response(name, field, endpoint) end end else - items = { type: field.type.klass.definition.name.downcase } + items = { type: convert_type_to_openapi_data_type(field.type) } end if items properties[name] = { @@ -341,7 +347,7 @@ def generate_properties_for_response(name, field, endpoint) end else # scalar properties[name] = { - type: map_type_to_openapi_property_type(field.type) + type: convert_type_to_openapi_data_type(field.type) } end properties @@ -368,11 +374,13 @@ def generate_id(definition) definition.id.gsub(/\//, '_') end - def map_type_to_openapi_property_type(type) + def convert_type_to_openapi_data_type(type) if type.klass == Apia::Scalars::UnixTime 'integer' elsif type.klass == Apia::Scalars::Decimal - 'string' # TODO: or integer, add this to example app + 'number' + elsif type.klass == Apia::Scalars::Base64 + 'string' else type.klass.definition.name.downcase end