diff --git a/examples/config.ru b/examples/config.ru index cf5c0df..cc30aef 100644 --- a/examples/config.ru +++ b/examples/config.ru @@ -40,6 +40,18 @@ use Apia::OpenApi::Rack, external_docs: { description: "Find out more", url: "https://example.com" + }, + security_schemes: { + OAuth2: { + type: "oauth2", + "x-scope-prefix": "example.com/core/v1", + flows: { + authorizationCode: { + authorizationUrl: "https://example.com/oauth/authorize", + tokenUrl: "https://example.com/oauth/token" + } + } + } } use Apia::Rack, CoreAPI::Base, "/core/v1", development: true diff --git a/lib/apia/open_api/objects/path.rb b/lib/apia/open_api/objects/path.rb index aac8f06..5c856ee 100644 --- a/lib/apia/open_api/objects/path.rb +++ b/lib/apia/open_api/objects/path.rb @@ -38,12 +38,14 @@ def initialize(spec:, path_ids:, route:, name:, api_authenticator:) operationId: convert_route_to_id, summary: @route.endpoint.definition.name, description: @route.endpoint.definition.description, - tags: route.group ? get_group_tags(route.group) : [name] + tags: route.group ? get_group_tags(route.group) : [name], + security: [] } end def add_to_spec add_scopes_description + add_scopes_security path = @route.path if @route.request_method == :get @@ -104,6 +106,45 @@ def add_scopes_description "- `#{scope}`" end.join("\n")} DESCRIPTION + + @spec[:security].each do |auth| + auth.each_key do |key| + scope_prefix = @spec[:components][:securitySchemes][key][:"x-scope-prefix"] + next unless scope_prefix.present? + + @route_spec[:description] = + <<~DESCRIPTION + #{@route_spec[:description]} + ### #{key} Scopes + When using #{key} authentication, scopes are prefixed with `#{scope_prefix}`. + DESCRIPTION + end + end + end + + # Adds scopes security to the OpenAPI path specification. + # + # This method checks if the route's endpoint definition has any scopes defined. + # If scopes are present, it iterates over the security schemes in the OpenAPI + # specification and adds the corresponding scopes to the route's security section. + # + # @return [void] + def add_scopes_security + unless @route.endpoint.definition.scopes.any? + @route_spec.delete(:security) + return + end + + @spec[:security].each do |auth| + auth.each_key do |key| + scopes = @route.endpoint.definition.scopes + if scope_prefix = @spec[:components][:securitySchemes][key][:"x-scope-prefix"] + scopes = scopes.map { |v| "#{scope_prefix}/#{v}" } + end + + @route_spec[:security] << { key => scopes } + end + end end # It's worth creating a 'nice' operationId for each route, as this is used as the diff --git a/lib/apia/open_api/rack.rb b/lib/apia/open_api/rack.rb index 3341274..dc56eb6 100644 --- a/lib/apia/open_api/rack.rb +++ b/lib/apia/open_api/rack.rb @@ -29,6 +29,10 @@ def base_url @options[:base_url] || "https://api.example.com/api/v1" end + def security_schemes + @options[:security_schemes] || {} + end + def external_docs @options[:external_docs] || {} end @@ -46,7 +50,12 @@ def call(env) return @app.call(env) end - specification = Specification.new(api_class, base_url, @options[:name], info, external_docs) + specification = Specification.new(api_class, base_url, @options[:name], + { + info: info, + external_docs: external_docs, + security_schemes: security_schemes + }) body = specification.json [200, { "content-type" => "application/json", "content-length" => body.bytesize.to_s }, [body]] diff --git a/lib/apia/open_api/specification.rb b/lib/apia/open_api/specification.rb index e333f7d..fab2116 100644 --- a/lib/apia/open_api/specification.rb +++ b/lib/apia/open_api/specification.rb @@ -14,14 +14,17 @@ class Specification OPEN_API_VERSION = "3.0.0" # The Ruby client generator currently only supports v3.0.0 https://openapi-generator.tech/ - def initialize(api, base_url, name, info = {}, external_docs = {}) + def initialize(api, base_url, name, additions = {}) + default_additions = { info: {}, external_docs: {}, security_schemes: {} } + additions = default_additions.merge(additions) + @api = api @base_url = base_url @name = name || "Core" # will be suffixed with 'Api' and used in the client generator @spec = { openapi: OPEN_API_VERSION, - info: info, - externalDocs: external_docs, + info: additions[:info], + externalDocs: additions[:external_docs], servers: [], paths: {}, components: { @@ -36,6 +39,8 @@ def initialize(api, base_url, name, info = {}, external_docs = {}) @spec.delete(:externalDocs) end + add_additional_security_schemes(additions[:security_schemes]) + # path_ids is used to keep track of all the IDs of all the paths we've generated, to avoid duplicates # refer to the Path object for more info @path_ids = [] @@ -57,8 +62,8 @@ def sort_hash_by_nested_tag(hash) def build_spec add_info add_servers - add_paths add_security + add_paths add_tag_groups @spec[:paths] = sort_hash_by_nested_tag(@spec[:paths]) @@ -160,6 +165,14 @@ def add_tag_groups @spec[:"x-tagGroups"].each { |group| group[:tags].sort! } end + def add_additional_security_schemes(security_schemes) + security_schemes.each do |key, value| + @spec[:components][:securitySchemes] ||= {} + @spec[:components][:securitySchemes][key] = value + @spec[:security] << { key => [] } + end + end + end end end diff --git a/spec/apia/open_api/rack_spec.rb b/spec/apia/open_api/rack_spec.rb index 3b37261..fb9ba18 100644 --- a/spec/apia/open_api/rack_spec.rb +++ b/spec/apia/open_api/rack_spec.rb @@ -61,7 +61,11 @@ class MockApi < Apia::API; end ) expect(Apia::OpenApi::Specification).to have_received(:new).with( - MockApi, default_base_url, name_option, {}, {} + MockApi, default_base_url, name_option, { + info: {}, + external_docs: {}, + security_schemes: {} + } ) end @@ -80,7 +84,11 @@ class MockApi < Apia::API; end middleware_response expect(Apia::OpenApi::Specification).to have_received(:new).with( - MockApi, base_url_option, name_option, {}, {} + MockApi, base_url_option, name_option, { + info: {}, + external_docs: {}, + security_schemes: {} + } ) end end @@ -119,7 +127,11 @@ class MockApi < Apia::API; end ) expect(Apia::OpenApi::Specification).to have_received(:new).with( - MockApi, default_base_url, name_option, {}, {} + MockApi, default_base_url, name_option, { + info: {}, + external_docs: {}, + security_schemes: {} + } ) end end @@ -151,7 +163,11 @@ class MockApi < Apia::API; end ) expect(Apia::OpenApi::Specification).to have_received(:new).with( - api_class, default_base_url, name_option, {}, {} + api_class, default_base_url, name_option, { + info: {}, + external_docs: {}, + security_schemes: {} + } ) end end diff --git a/spec/apia/open_api/specification_spec.rb b/spec/apia/open_api/specification_spec.rb index 002f6bf..fc8a3c1 100644 --- a/spec/apia/open_api/specification_spec.rb +++ b/spec/apia/open_api/specification_spec.rb @@ -14,6 +14,7 @@ spec = described_class.new(example_api, base_url, "Core", { + info: { version: "2.1.3", contact: { name: "API Support", @@ -27,10 +28,23 @@ termsOfService: "https://example.com/terms", "x-added-info": "This is an example of adding custom information to the OpenAPI spec" }, - { + external_docs: { description: "Find out more", url: "https://example.com" - }) + }, + security_schemes: { + OAuth2: { + type: "oauth2", + "x-scope-prefix": "example.com/core/v1", + flows: { + authorizationCode: { + authorizationUrl: "https://example.com/oauth/authorize", + tokenUrl: "https://example.com/oauth/token" + } + } + } + } + }) # uncomment the following line for debugging :) # puts spec.json diff --git a/spec/support/fixtures/openapi.json b/spec/support/fixtures/openapi.json index 37ce85d..db3830f 100644 --- a/spec/support/fixtures/openapi.json +++ b/spec/support/fixtures/openapi.json @@ -30,10 +30,24 @@ "get": { "operationId": "get:time_formatting_incredibly_super_duper_long_format", "summary": "Format Time", - "description": "Format the given time\n## Scopes\n- `time`\n- `time:format`\n", + "description": "Format the given time\n## Scopes\n- `time`\n- `time:format`\n\n### OAuth2 Scopes\nWhen using OAuth2 authentication, scopes are prefixed with `example.com/core/v1`.\n", "tags": [ "Core" ], + "security": [ + { + "OAuth2": [ + "example.com/core/v1/time", + "example.com/core/v1/time:format" + ] + }, + { + "MainAuthenticator": [ + "time", + "time:format" + ] + } + ], "parameters": [ { "in": "query", @@ -96,10 +110,24 @@ "post": { "operationId": "post:example_format", "summary": "Format Time", - "description": "Format the given time\n## Scopes\n- `time`\n- `time:format`\n", + "description": "Format the given time\n## Scopes\n- `time`\n- `time:format`\n\n### OAuth2 Scopes\nWhen using OAuth2 authentication, scopes are prefixed with `example.com/core/v1`.\n", "tags": [ "Core" ], + "security": [ + { + "OAuth2": [ + "example.com/core/v1/time", + "example.com/core/v1/time:format" + ] + }, + { + "MainAuthenticator": [ + "time", + "time:format" + ] + } + ], "requestBody": { "content": { "application/json": { @@ -156,10 +184,24 @@ "post": { "operationId": "post:example_format_multiple", "summary": "Format Multiple Times", - "description": "Format the given times\n## Scopes\n- `time`\n- `time:format`\n", + "description": "Format the given times\n## Scopes\n- `time`\n- `time:format`\n\n### OAuth2 Scopes\nWhen using OAuth2 authentication, scopes are prefixed with `example.com/core/v1`.\n", "tags": [ "Core" ], + "security": [ + { + "OAuth2": [ + "example.com/core/v1/time", + "example.com/core/v1/time:format" + ] + }, + { + "MainAuthenticator": [ + "time", + "time:format" + ] + } + ], "requestBody": { "content": { "application/json": { @@ -333,10 +375,24 @@ "get": { "operationId": "get:t_f_i_s_d_l_f", "summary": "Format Time", - "description": "Format the given time\n## Scopes\n- `time`\n- `time:format`\n", + "description": "Format the given time\n## Scopes\n- `time`\n- `time:format`\n\n### OAuth2 Scopes\nWhen using OAuth2 authentication, scopes are prefixed with `example.com/core/v1`.\n", "tags": [ "Formatting" ], + "security": [ + { + "OAuth2": [ + "example.com/core/v1/time", + "example.com/core/v1/time:format" + ] + }, + { + "MainAuthenticator": [ + "time", + "time:format" + ] + } + ], "parameters": [ { "in": "query", @@ -399,10 +455,24 @@ "post": { "operationId": "post:time_formatting_format", "summary": "Format Time", - "description": "Format the given time\n## Scopes\n- `time`\n- `time:format`\n", + "description": "Format the given time\n## Scopes\n- `time`\n- `time:format`\n\n### OAuth2 Scopes\nWhen using OAuth2 authentication, scopes are prefixed with `example.com/core/v1`.\n", "tags": [ "Formatting" ], + "security": [ + { + "OAuth2": [ + "example.com/core/v1/time", + "example.com/core/v1/time:format" + ] + }, + { + "MainAuthenticator": [ + "time", + "time:format" + ] + } + ], "requestBody": { "content": { "application/json": { @@ -459,10 +529,24 @@ "get": { "operationId": "get:time_now", "summary": "Time Now Endpoint", - "description": "Returns the current time\n## Scopes\n- `time`\n- `time:now`\n", + "description": "Returns the current time\n## Scopes\n- `time`\n- `time:now`\n\n### OAuth2 Scopes\nWhen using OAuth2 authentication, scopes are prefixed with `example.com/core/v1`.\n", "tags": [ "Time functions" ], + "security": [ + { + "OAuth2": [ + "example.com/core/v1/time", + "example.com/core/v1/time:now" + ] + }, + { + "MainAuthenticator": [ + "time", + "time:now" + ] + } + ], "parameters": [ { "name": "timezone", @@ -565,10 +649,24 @@ "post": { "operationId": "post:time_now", "summary": "Time Now Endpoint", - "description": "Returns the current time\n## Scopes\n- `time`\n- `time:now`\n", + "description": "Returns the current time\n## Scopes\n- `time`\n- `time:now`\n\n### OAuth2 Scopes\nWhen using OAuth2 authentication, scopes are prefixed with `example.com/core/v1`.\n", "tags": [ "Time functions" ], + "security": [ + { + "OAuth2": [ + "example.com/core/v1/time", + "example.com/core/v1/time:now" + ] + }, + { + "MainAuthenticator": [ + "time", + "time:now" + ] + } + ], "requestBody": { "content": { "application/json": { @@ -670,10 +768,22 @@ "get": { "operationId": "get:test_object", "summary": "Test Endpoint", - "description": "Returns the current time\n## Scopes\n- `time`\n", + "description": "Returns the current time\n## Scopes\n- `time`\n\n### OAuth2 Scopes\nWhen using OAuth2 authentication, scopes are prefixed with `example.com/core/v1`.\n", "tags": [ "Time functions" ], + "security": [ + { + "OAuth2": [ + "example.com/core/v1/time" + ] + }, + { + "MainAuthenticator": [ + "time" + ] + } + ], "parameters": [ { "in": "query", @@ -743,10 +853,22 @@ "post": { "operationId": "post:test_object", "summary": "Test Endpoint", - "description": "Returns the current time\n## Scopes\n- `time`\n", + "description": "Returns the current time\n## Scopes\n- `time`\n\n### OAuth2 Scopes\nWhen using OAuth2 authentication, scopes are prefixed with `example.com/core/v1`.\n", "tags": [ "Time functions" ], + "security": [ + { + "OAuth2": [ + "example.com/core/v1/time" + ] + }, + { + "MainAuthenticator": [ + "time" + ] + } + ], "requestBody": { "content": { "application/json": { @@ -1341,6 +1463,22 @@ } } }, + "securitySchemes": { + "OAuth2": { + "type": "oauth2", + "x-scope-prefix": "example.com/core/v1", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://example.com/oauth/authorize", + "tokenUrl": "https://example.com/oauth/token" + } + } + }, + "MainAuthenticator": { + "scheme": "bearer", + "type": "http" + } + }, "responses": { "InvalidTimeResponse": { "description": "400 error response", @@ -1442,15 +1580,14 @@ } } } - }, - "securitySchemes": { - "MainAuthenticator": { - "scheme": "bearer", - "type": "http" - } } }, "security": [ + { + "OAuth2": [ + + ] + }, { "MainAuthenticator": [