diff --git a/sphinxcontrib/openapi/openapi30.py b/sphinxcontrib/openapi/openapi30.py index 499f44c..2955d18 100644 --- a/sphinxcontrib/openapi/openapi30.py +++ b/sphinxcontrib/openapi/openapi30.py @@ -225,13 +225,25 @@ def _example(media_type_objects, method=None, endpoint=None, status=None, yield '' +def _find_scope_description(scope, securityScheme): + desc = 'No description available' + for flow in securityScheme['flows']: + for sc in securityScheme['flows'][flow]['scopes']: + if sc == scope: + desc = securityScheme['flows'][flow]['scopes'][sc] + break + return desc + + def _httpresource(endpoint, method, properties, convert, render_examples, - render_request): + render_request, components=None): # https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.0.md#operation-object parameters = properties.get('parameters', []) responses = properties['responses'] indent = ' ' + components = components or {} + yield '.. http:{0}:: {1}'.format(method, endpoint) yield ' :synopsis: {0}'.format(properties.get('summary', 'null')) yield '' @@ -246,6 +258,61 @@ def _httpresource(endpoint, method, properties, convert, render_examples, yield '{indent}{line}'.format(**locals()) yield '' + securities = properties.get('security', []) + for security in securities: + yield ('{indent}This resource requires the ' + 'following authentication scheme(s):').format(**locals()) + yield '' + for ref in security.keys(): + secScheme = components.get('securitySchemes', {}).get(ref, {}) + yield ('{indent}:scheme: ' + ref).format(**locals()) + if secScheme['type'] == 'http': + if secScheme['scheme'].lower() == 'basic': + line = ':security: HTTP Basic' + yield '{indent}{line}'.format(**locals()) + elif secScheme['scheme'].lower() == 'bearer': + line = ':security: HTTP Bearer' + yield '{indent}{line}'.format(**locals()) + else: + raise Exception( + 'Unknown http scheme "%s"' % secScheme['scheme']) + elif secScheme['type'] == 'apiKey': + key_loc = secScheme['in'] + yield ('{indent}:security: ' + 'API key in {key_loc}').format(**locals()) + elif secScheme['type'] == 'openIdConnect': + yield ('{indent}:security: ' + 'OpenID Connect').format(**locals()) + if secScheme.get('openIdConnectUrl'): + discoveryUrl = secScheme.get('openIdConnectUrl') + yield ('{indent}{indent}' + 'Discovery URL: {discoveryUrl}').format(**locals()) + yield '' + yield '{indent}{indent}Scope(s):'.format(**locals()) + for scope in security[ref]: + yield ('{indent}{indent}' + '{indent}:scope {scope}:').format(**locals()) + elif secScheme['type'] == 'oauth2': + yield '{indent}:security: OAuth 2.0'.format(**locals()) + if 'description' in secScheme: + desc = secScheme['description'] + else: + desc = 'No description available' + yield '{indent}{indent}{desc}'.format(**locals()) + yield '' + yield '{indent}{indent}Scope(s):'.format(**locals()) + for scope in security[ref]: + yield ('{indent}{indent}' + '{indent}:scope {scope}:').format(**locals()) + # find the scope description + scope_desc = _find_scope_description(scope, secScheme) + yield ('{indent}{indent}{indent}' + '{indent}' + scope_desc).format(**locals()) + else: + raise Exception( + 'Unknown security scheme "%s"' % secScheme['type']) + yield '' + # print request's path params for param in filter(lambda p: p['in'] == 'path', parameters): yield indent + ':param {type} {name}:'.format( @@ -264,6 +331,18 @@ def _httpresource(endpoint, method, properties, convert, render_examples, yield '{indent}{indent}{line}'.format(**locals()) if param.get('required', False): yield '{indent}{indent}(Required)'.format(**locals()) + # security params + securities = properties.get('security', []) + for security in securities: + for ref in security.keys(): + secScheme = components.get('securitySchemes', {}).get(ref, {}) + if secScheme['type'] == 'apiKey': + if secScheme['in'] == 'query': + yield indent + ':query {type} {name}:'.format( + type='string', + name=secScheme['name']) + yield ('{indent}{indent}(Required' + ' by authentication "{ref}")').format(**locals()) # print request content if render_request: @@ -307,6 +386,34 @@ def _httpresource(endpoint, method, properties, convert, render_examples, yield '{indent}{indent}{line}'.format(**locals()) if param.get('required', False): yield '{indent}{indent}(Required)'.format(**locals()) + # security header params + securities = properties.get('security', []) + for security in securities: + for ref in security.keys(): + secScheme = components.get('securitySchemes', {}).get(ref, {}) + # bearer auth + if secScheme['type'] == 'http' and\ + secScheme.get('scheme') == 'bearer': + yield indent + ':reqheader Authorization:' + yield '{indent}{indent}Bearer '.format(**locals()) + yield ('{indent}{indent}(Required ' + 'by security scheme "{ref}")'.format(**locals())) + # API key header + elif (secScheme['type'] == 'apiKey' and + secScheme.get('in') == 'header'): + sechead_name = secScheme['name'] + yield indent + ':reqheader {sechead_name}:'.format(**locals()) + yield ('{indent}{indent}(Required ' + 'by security scheme "{ref}")'.format(**locals())) + # API key cookie + elif (secScheme['type'] == 'apiKey' and + secScheme.get('in') == 'cookie'): + cookie_name = secScheme['name'] + yield indent + ':reqheader Cookie:' + yield ('{indent}{indent}' + '{cookie_name}=').format(**locals()) + yield ('{indent}{indent}(Required ' + 'by security scheme "{ref}")').format(**locals()) # print response headers for status, response in responses.items(): @@ -399,6 +506,7 @@ def openapihttpdomain(spec, **options): properties, convert, render_examples='examples' in options, - render_request=render_request)) + render_request=render_request, + components=spec.get('components', {}))) return iter(itertools.chain(*generators)) diff --git a/tests/test_openapi.py b/tests/test_openapi.py index bf0a275..f938907 100644 --- a/tests/test_openapi.py +++ b/tests/test_openapi.py @@ -450,6 +450,360 @@ def test_basic(self): Last known resource ETag. ''').lstrip() + def _test_securityScheme(self, component, security, expected): + text = '\n'.join(openapi30.openapihttpdomain({ + 'openapi': '3.0.0', + 'components': { + 'securitySchemes': component, + }, + 'paths': { + '/resources/{kind}': { + 'get': { + 'security': security, + 'summary': 'List Resources', + 'description': '~ some useful description ~', + 'parameters': [ + { + 'name': 'kind', + 'in': 'path', + 'schema': {'type': 'string'}, + 'description': 'Kind of resource to list.', + }, + { + 'name': 'limit', + 'in': 'query', + 'schema': {'type': 'integer'}, + 'description': 'Show up to `limit` entries.', + }, + { + 'name': 'If-None-Match', + 'in': 'header', + 'schema': {'type': 'string'}, + 'description': 'Last known resource ETag.' + }, + ], + 'requestBody': { + 'content': { + 'application/json': { + 'example': '{"foo2": "bar2"}' + } + } + }, + 'responses': { + '200': { + 'description': 'An array of resources.', + 'content': { + 'application/json': { + 'example': '{"foo": "bar"}' + } + } + }, + }, + }, + }, + }, + })) + assert text == expected + + def test_basicAuth(self): + component = { + 'bAuth': { + 'type': 'http', + 'scheme': 'basic' + } + } + security = [ + {'bAuth': []} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: bAuth + :security: HTTP Basic + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_bearerAuth(self): + component = { + 'bearerAuth': { + 'type': 'http', + 'scheme': 'bearer', + 'bearerFormat': 'JWT' # Optional + } + } + security = [ + {'bearerAuth': []} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: bearerAuth + :security: HTTP Bearer + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + :reqheader Authorization: + Bearer + (Required by security scheme "bearerAuth") + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_apiKey_header(self): + component = { + 'apiKeyAuth': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'X-API-KEY' + } + } + security = [ + {'apiKeyAuth': []} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: apiKeyAuth + :security: API key in header + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + :reqheader X-API-KEY: + (Required by security scheme "apiKeyAuth") + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_apiKey_qstring(self): + component = { + 'apiKeyAuth': { + 'type': 'apiKey', + 'in': 'query', + 'name': 'api_key' + } + } + security = [ + {'apiKeyAuth': []} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: apiKeyAuth + :security: API key in query + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :query string api_key: + (Required by authentication "apiKeyAuth") + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_apiKey_cookie(self): + component = { + 'apiKeyAuth': { + 'type': 'apiKey', + 'in': 'cookie', + 'name': 'JSESSIONID' + } + } + security = [ + {'apiKeyAuth': []} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: apiKeyAuth + :security: API key in cookie + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + :reqheader Cookie: + JSESSIONID= + (Required by security scheme "apiKeyAuth") + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_OAuth2(self): + component = { + 'oAuthSample': { + 'type': 'oauth2', + 'description': 'More info at https://example.com/docs/auth', + 'flows': { + 'implicit': { + 'authorizationUrl': 'https://example.com/authorize', + 'scopes': { + 'read': 'read resources', + 'write': 'modify resources' + } + }, + 'authorizationCode': { + 'authorizationUrl': 'https://example.com/authorize', + 'tokenUrl': 'https://example.com/token', + 'scopes': { + 'read': 'read resources', + 'write': 'modify resources' + } + }, + 'password': { + 'tokenUrl': 'https://example.com/token', + 'scopes': { + 'read': 'read resources', + 'write': 'modify resources' + } + }, + 'clientCredentials': { + 'tokenUrl': 'https://example.com/token', + 'scopes': {} + } + } + } + } + security = [ + {'oAuthSample': [ + 'read', + 'write' + ]} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: oAuthSample + :security: OAuth 2.0 + More info at https://example.com/docs/auth + + Scope(s): + :scope read: + read resources + :scope write: + modify resources + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + ''').lstrip() + self._test_securityScheme(component, security, expected) + + def test_OIDC(self): + component = { + 'oidcSample': { + 'type': 'openIdConnect', + 'openIdConnectUrl': 'https://example.com/' + '.well-known/configuration', + } + } + security = [ + {'oidcSample': [ + 'read', + 'write' + ]} + ] + expected = textwrap.dedent(''' + .. http:get:: /resources/{kind} + :synopsis: List Resources + + **List Resources** + + ~ some useful description ~ + + This resource requires the following authentication scheme(s): + + :scheme: oidcSample + :security: OpenID Connect + Discovery URL: https://example.com/.well-known/configuration + + Scope(s): + :scope read: + :scope write: + + :param string kind: + Kind of resource to list. + :query integer limit: + Show up to `limit` entries. + :status 200: + An array of resources. + :reqheader If-None-Match: + Last known resource ETag. + ''').lstrip() + self._test_securityScheme(component, security, expected) + def test_groups(self): text = '\n'.join(openapi30.openapihttpdomain({ 'openapi': '3.0.0',