diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml new file mode 100644 index 0000000..515aba2 --- /dev/null +++ b/.github/workflows/codecov.yml @@ -0,0 +1,36 @@ +name: Codecov +on: [push, pull_request] +jobs: + run: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + python: ["3.7","3.8","3.9","3.10"] + env: + OS: ${{ matrix.os }} + PYTHON: ${{ matrix.python }} + PYTHONPATH: "." + steps: + - uses: actions/checkout@master + - name: Setup Python + uses: actions/setup-python@master + with: + python-version: ${{ matrix.python }} + - name: Generate coverage report + run: | + pip install '.[tests]' + pip install '.[compat]' + pytest --cov=./ --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ./coverage/reports/ + env_vars: OS,PYTHON + fail_ci_if_error: false + files: ./coverage.xml + flags: unittests + name: codecov-aiopenapi3 + path_to_write_report: ./coverage/codecov_report.txt + verbose: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22b28da..7543182 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,16 @@ repos: - repo: https://github.com/hadialqattan/pycln - rev: v0.0.4 # Possible releases: https://github.com/hadialqattan/pycln/releases + rev: v1.1.0 # Possible releases: https://github.com/hadialqattan/pycln/releases hooks: - id: pycln - repo: 'https://github.com/psf/black' - rev: 21.6b0 + rev: 21.12b0 hooks: - id: black args: - "--line-length=120" - repo: 'https://github.com/pre-commit/pre-commit-hooks' - rev: v4.0.1 + rev: v4.1.0 hooks: - id: end-of-file-fixer exclude: '^docs/[^/]*\.svg$' @@ -20,20 +20,12 @@ repos: files: | .gitignore - id: check-case-conflict - - id: check-json - id: check-xml - id: check-executables-have-shebangs - - id: check-toml - - id: check-xml - - id: check-yaml - id: debug-statements - id: check-added-large-files - id: check-symlinks - id: debug-statements - - id: detect-aws-credentials - args: - - '--allow-missing-credentials' - - id: detect-private-key - repo: 'https://gitlab.com/pycqa/flake8' rev: 3.9.2 hooks: @@ -42,3 +34,13 @@ repos: - "--max-line-length=120" - "--ignore=E203,W503" - "--select=W504" + +ci: + autofix_commit_msg: | + [pre-commit.ci] auto fixes from pre-commit.ci hooks + autofix_prs: true + autoupdate_branch: '' + autoupdate_commit_msg: '[pre-commit.ci] pre-commit autoupdate' + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbbe95d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.7-slim-buster + +WORKDIR /app +COPY setup.cfg pyproject.toml requirements.txt /app/ +COPY aiopenapi3/ /app/aiopenapi3 +COPY tests /app/tests +RUN ls -al /app +RUN pip install --upgrade pip +RUN pip install ".[compat]" +RUN pip install ".[tests]" diff --git a/LICENSE b/LICENSE index 260240c..14372d7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ Copyright (c) 2019, William Smith +Copyright (c) 2022, Markus Kötter All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1ce797 --- /dev/null +++ b/README.md @@ -0,0 +1,220 @@ +# aiopenapi3 + +A Python [OpenAPI 3 Specification](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md) client and validator for Python 3. + +[![Test](https://github.com/commonism/aiopenapi3/workflows/Codecov/badge.svg?event=push&branch=master)](https://github.com/commonism/aiopenapi3/actions?query=workflow%3ACodecov+event%3Apush+branch%3Amaster) +[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/commonism/aiopenapi3/master.svg)](https://results.pre-commit.ci/latest/github/commonism/aiopenapi3/master) +[![Coverage](https://img.shields.io/codecov/c/github/commonism/aiopenapi3)](https://codecov.io/gh/commonism/aiopenapi3) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/aiopenapi3.svg)](https://pypi.org/project/aiopenapi3) + + +This project is based on [Dorthu/openapi3](github.com/Dorthu/openapi3/). + +## Features + * implements … + * Swagger 2.0 + * OpenAPI 3.0.3 + * OpenAPI 3.1.0 + * object parsing via pydantic + * request body model creation via [pydantic](https://github.com/samuelcolvin/pydantic) + * blocking and nonblocking (asyncio) interface via [httpx](https://www.python-httpx.org/) + * tests with pytest + * providing access to methods and arguments via the sad smiley ._. interface + + +## Usage as a Client + +This library also functions as an interactive client for arbitrary OpenAPI 3 +specs. For example, using `Linode's OpenAPI 3 Specification`_ for reference: + +*Unfortunately I do not have access to the Linode API to validate object creation* + +### asyncio +```python +from aiopenapi3 import OpenAPI +url = "https://www.linode.com/docs/api/openapi.yaml" + +api = await OpenAPI.load_async(url) + +# call operations and receive result models +regions = await api._.getRegions() +``` + +### blocking io +```python +from aiopenapi3 import OpenAPI +url = "https://www.linode.com/docs/api/openapi.yaml" +my_token = "Gae6aikaegainoor" +api = OpenAPI.load_sync(url) + +# call operations and receive result models +regions = api._.getRegions() + + +``` + +### objects +pydantic is used for the models. +https://pydantic-docs.helpmanual.io/usage/exporting_models/ + +```python +from aiopenapi3 import OpenAPI +url = "https://www.linode.com/docs/api/openapi.yaml" + +api = await OpenAPI.load_sync(url) + +# call operations and receive result models +regions = await api._.getRegions() + +regions.__fields_set__ +{'results', 'page', 'pages', 'data'} + +import json +print(json.dumps((list(filter(lambda x: 'eu-west' in x.id, regions.data))[0]).dict(), indent=2)) +{ + "id": "eu-west", + "country": "uk", + "capabilities": [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Kubernetes", + "Cloud Firewall" + ], + "status": "ok", + "resolvers": { + "ipv4": "178.79.182.5,176.58.107.5,176.58.116.5,176.58.121.5,151.236.220.5,212.71.252.5,212.71.253.5,109.74.192.20,109.74.193.20,109.74.194.20", + "ipv6": "2a01:7e00::9,2a01:7e00::3,2a01:7e00::c,2a01:7e00::5,2a01:7e00::6,2a01:7e00::8,2a01:7e00::b,2a01:7e00::4,2a01:7e00::7,2a01:7e00::2" + } +} +``` + +#### discriminators +discriminators are supported as well, but the linode api can't be used to show how to use them. +look at [aiopenapi3/tests/model_test.py](aiopenapi3/tests/model_test.py) test_model. + +### authentication +```python +my_token = "Gae6aikaegainoor" +api.authenticate(personalAccessToken=my_token) + +# call an operation that requires authentication +linodes = api._.getLinodeInstances() +``` + +HTTP basic authentication and HTTP digest authentication works like this: +```python +# authenticate using a securityScheme defined in the spec's components.securitySchemes +# Tuple with (username, password) as second argument +api.authenticate(basicAuth=('username', 'password')) +``` + +Resetting authentication tokens: +```python +api.authenticate(None) +``` + +### parameters + +```python +# call an opertaion with parameters +linode = api._.getLinodeInstance(parameters={"linodeId": 123}) +``` + +### body +```python +body = api._.createLinodeInstance.args()["data"].model({"region":"us-east", "type":"g6-standard-2"}) +print(json.dumps(body.dict(), indent=2)) +{ + "image": null, + "root_pass": null, + "authorized_keys": null, + "authorized_users": null, + "stackscript_id": null, + "stackscript_data": null, + "booted": null, + "backup_id": null, + "backups_enabled": null, + "swap_size": null, + "type": "g6-standard-2", + "region": "us-east", + "label": null, + "tags": null, + "group": null, + "private_ip": null, + "interfaces": null +} + +print(json.dumps(body.dict(exclude_unset=True), indent=2)) +{ + "type": "g6-standard-2", + "region": "us-east" +} + + +>>> +new_linode = api._.createLinodeInstance(data=body) +``` + +## Validation Mode + + +This module can be run against a spec file to validate it like so:: + +``` +python3 -m aiopenapi3 tests/fixtures/with-broken-links.yaml + +6 validation errors for OpenAPISpec +paths -> /with-links -> get -> responses -> 200 -> links -> exampleWithBoth -> __root__ + operationId and operationRef are mutually exclusive, only one of them is allowed (type=value_error.spec; message=operationId and operationRef are mutually exclusive, only one of them is allowed; element=None) +paths -> /with-links -> get -> responses -> 200 -> links -> exampleWithBoth -> $ref + field required (type=value_error.missing) +paths -> /with-links -> get -> responses -> 200 -> $ref + field required (type=value_error.missing) +paths -> /with-links-two -> get -> responses -> 200 -> links -> exampleWithNeither -> __root__ + operationId and operationRef are mutually exclusive, one of them must be specified (type=value_error.spec; message=operationId and operationRef are mutually exclusive, one of them must be specified; element=None) +paths -> /with-links-two -> get -> responses -> 200 -> links -> exampleWithNeither -> $ref + field required (type=value_error.missing) +paths -> /with-links-two -> get -> responses -> 200 -> $ref + field required (type=value_error.missing) +``` + +## Real World issues +### YAML +The description document may no be valid yaml. +YAML type coercion can cause this. +```python +>>> yaml.safe_load(str(datetime.datetime.now().date())) +datetime.date(2022, 1, 12) + +>>> yaml.safe_load("name: on") +{'name': True} + +>>> yaml.safe_load('12_24: "test"') +{1224: 'test'} +``` +Those can be turned of using the yload yaml.Loader argument to the Loader. + +```python +import aiopenapi3.loader + +OpenAPI.load…(…, loader=FileSystemLoader(pathlib.Path(dir), yload = aiopenapi3.loader.YAMLCompatibilityLoader)) + +``` + +### description document mismatch +In case the description document does not match the protocol, it may be required to alter the description, objects or data sent/received. +The [Plugin interface](tests/plugin_test.py) can be used to alter any of those. +It can even be used to alter an invalid description document to be usable. + + + +## Running Tests + +This project includes a test suite, run via ``pytest``. To run the test suite, +ensure that you've installed the dependencies and then run ``pytest`` in the root +of this project. + +```shell +PYTHONPATH=. pytest --cov=./ --cov-report=xml . +``` diff --git a/README.rst b/README.rst deleted file mode 100644 index d3bff73..0000000 --- a/README.rst +++ /dev/null @@ -1,85 +0,0 @@ -openapi3 -======== - -A Python `OpenAPI 3 Specification`_ client and validator for Python 3. - -.. image:: https://travis-ci.org/Dorthu/openapi3.svg?branch=master - :target: https://travis-ci.org/Dorthu/openapi3 - - -.. image:: https://badge.fury.io/py/openapi3.svg - :target: https://badge.fury.io/py/openapi3 - - -Validation Mode ---------------- - -This module can be run against a spec file to validate it like so:: - - python3 -m openapi3 /path/to/spec - -Usage as a Client ------------------ - -This library also functions as an interactive client for arbitrary OpenAPI 3 -specs. For example, using `Linode's OpenAPI 3 Specification`_ for reference:: - - from openapi3 import OpenAPI - import yaml - - # load the spec file and read the yaml - with open('openapi.yaml') as f: - spec = yaml.safe_load(f.read()) - - # parse the spec into python - this will raise if the spec is invalid - api = OpenAPI(spec) - - # call operations and receive result models - regions = api.call_getRegions() - - # authenticate using a securityScheme defined in the spec's components.securitySchemes - api.authenticate('personalAccessToken', my_token) - - # call an operation that requires authentication - linodes = api.call_getLinodeInstances() - - # call an opertaion with parameters - linode = api.call_getLinodeInstance(parameters={"linodeId": 123}) - - # the models returns are all of the same (generated) type - print(type(linode)) # openapi.schemas.Linode - type(linode) == type(linodes.data[0]) # True - - # call an operation with a request body - new_linode = api.call_createLinodeInstance(data={"region":"us-east","type":"g6-standard-2"}) - - # the returned models is still of the correct type - type(new_linode) == type(linode) # True - -HTTP basic authentication and HTTP digest authentication works like this:: - - # authenticate using a securityScheme defined in the spec's components.securitySchemes - # Tuple with (username, password) as second argument - api.authenticate('basicAuth', ('username', 'password')) - -Running Tests -------------- - -This project includes a test suite, run via ``pytest``. To run the test suite, -ensure that you've installed the dependencies and then run ``pytest`` in the root -of this project. - -Roadmap -------- - -The following features are planned for the future: - -* Request body models, creation, and validation. -* Parameters interface with validation and explicit typing. -* Support for more authentication types. -* Support for non-json request/response content. -* Full support for all objects defined in the specification. - -.. _OpenAPI 3 Specification: https://openapis.org -.. _Linode's OpenAPI 3 Specification: https://developers.linode.com/api/v4 - diff --git a/aiopenapi3/__init__.py b/aiopenapi3/__init__.py new file mode 100644 index 0000000..ad8163b --- /dev/null +++ b/aiopenapi3/__init__.py @@ -0,0 +1,16 @@ +from .version import __version__ +from .openapi import OpenAPI +from .loader import FileSystemLoader +from .errors import SpecError, ReferenceResolutionError, HTTPError, HTTPStatusError, ContentTypeError + + +__all__ = [ + "__version__", + "OpenAPI", + "FileSystemLoader", + "SpecError", + "ReferenceResolutionError", + "HTTPStatusError", + "ContentTypeError", + "HTTPError", +] diff --git a/aiopenapi3/__main__.py b/aiopenapi3/__main__.py new file mode 100644 index 0000000..afc03d8 --- /dev/null +++ b/aiopenapi3/__main__.py @@ -0,0 +1,26 @@ +import sys +import sys + +if sys.version_info >= (3, 9): + from pathlib import Path +else: + from pathlib3x import Path + +from .openapi import OpenAPI + +from .loader import FileSystemLoader + + +def main(): + name = sys.argv[1] + + try: + OpenAPI.load_file(name, Path(name), loader=FileSystemLoader(Path().cwd())) + except ValueError as e: + print(e) + else: + print("OK") + + +if __name__ == "__main__": + main() diff --git a/aiopenapi3/base.py b/aiopenapi3/base.py new file mode 100644 index 0000000..cc70585 --- /dev/null +++ b/aiopenapi3/base.py @@ -0,0 +1,226 @@ +from typing import Optional, Any + +from pydantic import BaseModel, Field, root_validator, Extra + +HTTP_METHODS = frozenset(["get", "delete", "head", "post", "put", "patch", "trace"]) + + +class ObjectBase(BaseModel): + """ + The base class for all schema objects. Includes helpers for common schema- + related functions. + """ + + class Config: + underscore_attrs_are_private = True + arbitrary_types_allowed = False + extra = Extra.forbid + + +class ObjectExtended(ObjectBase): + extensions: Optional[Any] = Field(default=None) + + @root_validator(pre=True) + def validate_ObjectExtended_extensions(cls, values): + """FIXME + https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#specification-extensions + :param values: + :return: values + """ + e = dict() + for k, v in values.items(): + if k.startswith("x-"): + e[k[2:]] = v + if len(e): + for i in e.keys(): + del values[f"x-{i}"] + if "extensions" in values.keys(): + raise ValueError("extensions") + values["extensions"] = e + + return values + + +from .json import JSONPointer +from .errors import ReferenceResolutionError + +from typing import Dict, Any + + +class PathsBase(ObjectBase): + __root__: Dict[str, Any] = Field(default_factory=dict) + + class Config: + from pydantic import Extra + + extra = Extra.allow + + @property + def extensions(self): + return self._extensions + + def __getitem__(self, item): + return self._paths[item] + + def items(self): + return self._paths.items() + + def values(self): + return self._paths.values() + + +class RootBase: + @staticmethod + def resolve(api, root, obj, _PathItem, _Reference): + if isinstance(obj, ObjectBase): + for slot in filter(lambda x: not x.startswith("_"), obj.__fields_set__): + value = getattr(obj, slot) + if value is None: + continue + + # v3.1 - Schema $ref + if isinstance(value, SchemaBase): + r = getattr(value, "ref", None) + if r is not None: + value = _Reference.construct(ref=r) + setattr(obj, slot, value) + + """ + ref fields embedded in objects -> replace the object with a Reference object + + PathItem Ref is ambigous + https://github.com/OAI/OpenAPI-Specification/issues/2635 + """ + if isinstance(obj, _PathItem) and slot == "ref": + ref = _Reference.construct(ref=value) + ref._target = api.resolve_jr(root, obj, ref) + setattr(obj, slot, ref) + + value = getattr(obj, slot) + + if isinstance(value, PathsBase): + value.items() + value = value._paths + + if isinstance(value, (str, int, float)): # , datetime.datetime, datetime.date)): + continue + elif isinstance(value, _Reference): + value._target = api.resolve_jr(root, obj, value) + elif issubclass(type(value), ObjectBase) or isinstance(value, (dict, list)): + # otherwise, continue resolving down the tree + RootBase.resolve(api, root, value, _PathItem, _Reference) + else: + raise TypeError(type(value)) + elif isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, _Reference): + if v.ref: + v._target = api.resolve_jr(root, obj, v) + elif isinstance(v, (ObjectBase, dict, list)): + RootBase.resolve(api, root, v, _PathItem, _Reference) + elif isinstance(obj, list): + # if it's a list, resolve its item's references + for item in obj: + if isinstance(item, _Reference): + item._target = api.resolve_jr(root, obj, item) + elif isinstance(item, (ObjectBase, dict, list)): + RootBase.resolve(api, root, item, _PathItem, _Reference) + + def _resolve_references(self, api): + """ + Resolves all reference objects below this object and notes their original + value was a reference. + """ + # don't circular import + + root = self + + RootBase.resolve(api, self, self, None, None) + raise NotImplementedError("specific") + + def resolve_jp(self, jp): + """ + Given a $ref path, follows the document tree and returns the given attribute. + + :param jp: The path down the spec tree to follow + :type jp: str #/foo/bar + + :returns: The node requested + :rtype: ObjectBase + :raises ValueError: if the given path is not valid + """ + path = jp.split("/")[1:] + node = self + + for part in path: + part = JSONPointer.decode(part) + + if isinstance(node, PathsBase): # forward + node = node._paths # will be dict + + if isinstance(node, dict): + if part not in node: # pylint: disable=unsupported-membership-test + raise ReferenceResolutionError(f"Invalid path {path} in Reference") + node = node.get(part) + elif isinstance(node, list): + node = node[int(part)] + elif isinstance(node, ObjectBase): + if part == "schema": + part = "schema_" + if not hasattr(node, part): + raise ReferenceResolutionError(f"Invalid path {path} in Reference") + node = getattr(node, part) + else: + raise TypeError(node) + + return node + + +from typing import List, Dict +from .model import Model + + +class DiscriminatorBase: + pass + + +class SchemaBase: + def set_type(self, names: List[str] = None, discriminators: List[DiscriminatorBase] = None): + self._model_type = Model.from_schema(self, names, discriminators) + return self._model_type + + def get_type(self, names: List[str] = None, discriminators: List[DiscriminatorBase] = None): + try: + return self._model_type + except AttributeError: + return self.set_type(names, discriminators) + + def model(self, data: Dict): + """ + Generates a model representing this schema from the given data. + + :param data: The data to create the model from. Should match this schema. + :type data: dict + + :returns: A new :any:`Model` created in this Schema's type from the data. + :rtype: self.get_type() + """ + if self.type in ("string", "number", "boolean", "integer"): + assert len(self.properties) == 0 + # more simple types + # if this schema represents a simple type, simply return the data + # TODO - perhaps assert that the type of data matches the type we + # expected + return data + elif self.type == "array": + return [self.items.get_type().parse_obj(i) for i in data] + else: + return self.get_type().parse_obj(data) + + +class ReferenceBase: + pass + + +class ParameterBase: + pass diff --git a/aiopenapi3/errors.py b/aiopenapi3/errors.py new file mode 100644 index 0000000..f4f3c78 --- /dev/null +++ b/aiopenapi3/errors.py @@ -0,0 +1,41 @@ +import dataclasses + + +class SpecError(ValueError): + """ + This error class is used when an invalid format is found while parsing an + object in the spec. + """ + + def __init__(self, message, element=None): + self.message = message + self.element = element + + +class ReferenceResolutionError(SpecError): + """ + This error class is used when resolving a reference fails, usually because + of a malformed path in the reference. + """ + + +class HTTPError(ValueError): + pass + + +@dataclasses.dataclass +class ContentTypeError(HTTPError): + """The content-type is unexpected""" + + content_type: str + message: str + response: object + + +@dataclasses.dataclass +class HTTPStatusError(HTTPError): + """The HTTP Status is unexpected""" + + http_status: int + message: str + response: object diff --git a/aiopenapi3/json.py b/aiopenapi3/json.py new file mode 100644 index 0000000..e45e91d --- /dev/null +++ b/aiopenapi3/json.py @@ -0,0 +1,29 @@ +import urllib.parse + +from yarl import URL + + +class JSONPointer: + """ + JavaScript Object Notation (JSON) Pointer + + https://datatracker.ietf.org/doc/html/rfc6901 + """ + + @staticmethod + def decode(part): + """ + + https://swagger.io/docs/specification/using-ref/ + :param part: + """ + part = urllib.parse.unquote(part) + part = part.replace("~1", "/") + return part.replace("~0", "~") + + +class JSONReference: + @staticmethod + def split(url): + u = URL(url) + return str(u.with_fragment("")), u.raw_fragment diff --git a/aiopenapi3/loader.py b/aiopenapi3/loader.py new file mode 100644 index 0000000..52bc1a2 --- /dev/null +++ b/aiopenapi3/loader.py @@ -0,0 +1,140 @@ +import abc +import json + + +import yaml +import httpx +import yarl + +import sys + +if sys.version_info >= (3, 9): + from pathlib import Path +else: + from pathlib3x import Path + +from .plugin import Plugins + +""" +https://stackoverflow.com/questions/34667108/ignore-dates-and-times-while-parsing-yaml +""" + + +class YAMLCompatibilityLoader(yaml.SafeLoader): + @classmethod + def remove_implicit_resolver(cls, tag_to_remove): + """ + Remove implicit resolvers for a particular tag + + Takes care not to modify resolvers in super classes. + + We want to load datetimes as strings, not dates, because we + go on to serialise as json which doesn't have the advanced types + of yaml, and leads to incompatibilities down the track. + """ + if not "yaml_implicit_resolvers" in cls.__dict__: + cls.yaml_implicit_resolvers = cls.yaml_implicit_resolvers.copy() + + for first_letter, mappings in cls.yaml_implicit_resolvers.items(): + cls.yaml_implicit_resolvers[first_letter] = [ + (tag, regexp) for tag, regexp in mappings if tag != tag_to_remove + ] + + +YAMLCompatibilityLoader.remove_implicit_resolver("tag:yaml.org,2002:timestamp") + +""" +example: = +""" +YAMLCompatibilityLoader.remove_implicit_resolver("tag:yaml.org,2002:value") + +""" +18_24: test +""" +YAMLCompatibilityLoader.remove_implicit_resolver("tag:yaml.org,2002:int") + +""" +name: on +""" +YAMLCompatibilityLoader.remove_implicit_resolver("tag:yaml.org,2002:bool") + + +class Loader(abc.ABC): + def __init__(self, yload: yaml.Loader = yaml.SafeLoader): + self.yload = yload + + @abc.abstractmethod + def load(self, plugins, file: Path, codec=None): + raise NotImplementedError("load") + + @classmethod + def decode(cls, data, codec): + if codec is not None: + codecs = [codec] + else: + codecs = ["ascii", "utf-8"] + for c in codecs: + try: + data = data.decode(c) + break + except UnicodeError: + continue + else: + raise ValueError("encoding") + return data + + def parse(self, plugins, file, data): + if file.suffix == ".yaml": + data = yaml.load(data, Loader=self.yload) + elif file.suffix == ".json": + data = json.loads(data) + else: + raise ValueError(f"{file.name} is not yaml/json") + return data + + def get(self, plugins, file): + data = self.load(plugins, file) + return self.parse(plugins, file, data) + + +class NullLoader(Loader): + def load(self, plugins, file: Path, codec=None): + raise NotImplementedError("load") + + +class WebLoader(Loader): + def __init__(self, baseurl, session_factory=httpx.Client, yload=yaml.SafeLoader): + super().__init__(yload) + self.baseurl = baseurl + self.session_factory = session_factory + + def load(self, plugins, file: Path, codec=None): + url = self.baseurl.join(yarl.URL(str(file))) + with self.session_factory() as session: + data = session.get(str(url)) + data = data.content + data = self.decode(data, codec) + data = plugins.document.loaded(url=str(file), document=data).document + return data + + +class FileSystemLoader(Loader): + def __init__(self, base: Path, yload: yaml.Loader = yaml.SafeLoader): + super().__init__(yload) + assert isinstance(base, Path) + self.base = base + + def load(self, plugins: Plugins, file: Path, codec=None): + assert plugins + assert isinstance(file, Path) + path = self.base / file + assert path.is_relative_to(self.base) + data = path.open("rb").read() + data = self.decode(data, codec) + data = plugins.document.loaded(url=str(file), document=data).document + return data + + def parse(self, plugins, file, data): + data = super().parse(plugins, file, data) + data = plugins.document.parsed(url=str(file), document=data).document + return data diff --git a/aiopenapi3/model.py b/aiopenapi3/model.py new file mode 100644 index 0000000..38fd74c --- /dev/null +++ b/aiopenapi3/model.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import collections +import types +import uuid + +import sys + +if sys.version_info >= (3, 9): + from typing import List, Optional, Literal, Union, Annotated +else: + from typing import List, Optional, Union + from typing_extensions import Annotated, Literal + +from pydantic import BaseModel, Extra, Field +from pydantic.schema import field_class_to_schema + +type_format_to_class = collections.defaultdict(lambda: dict()) + + +def generate_type_format_to_class(): + """ + initialize type_format_to_class + :return: None + """ + global type_format_to_class + for cls, spec in field_class_to_schema: + if spec["type"] not in frozenset(["string", "number"]): + continue + type_format_to_class[spec["type"]][spec.get("format", None)] = cls + + +def class_from_schema(s): + a = type_format_to_class[s.type] + b = a.get(s.format, a[None]) + return b + + +class Model(BaseModel): + class Config: + extra: Extra.forbid + + @classmethod + def from_schema( + cls, shma: "SchemaBase", shmanm: List[str] = None, discriminators: List["DiscriminatorBase"] = None + ): + + if shmanm is None: + shmanm = [] + + if discriminators is None: + discriminators = [] + + # do not create models for primitive types + if shma.type in ("string", "integer", "number", "boolean"): + return Model.typeof(shma) + + type_name = shma.title or getattr(shma, "_identity", None) or str(uuid.uuid4()) + namespace = dict() + annos = dict() + if shma.allOf: + for i in shma.allOf: + annos.update(Model.annotationsof(i, discriminators, shmanm)) + elif hasattr(shma, "anyOf") and shma.anyOf: + t = tuple( + i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) + for i in shma.anyOf + ) + if shma.discriminator and shma.discriminator.mapping: + annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] + else: + annos["__root__"] = Union[t] + elif hasattr(shma, "oneOf") and shma.oneOf: + t = tuple( + i.get_type(names=shmanm + [i.ref], discriminators=discriminators + [shma.discriminator]) + for i in shma.oneOf + ) + if shma.discriminator and shma.discriminator.mapping: + annos["__root__"] = Annotated[Union[t], Field(discriminator=shma.discriminator.propertyName)] + else: + annos["__root__"] = Union[t] + else: + annos = Model.annotationsof(shma, discriminators, shmanm) + namespace.update(Model.fieldof(shma)) + + namespace["__annotations__"] = annos + + m = types.new_class(type_name, (BaseModel,), {}, lambda ns: ns.update(namespace)) + m.update_forward_refs() + return m + + @staticmethod + def typeof(schema: "SchemaBase"): + r = None + if schema.type == "integer": + r = int + elif schema.type == "number": + r = class_from_schema(schema) + elif schema.type == "string": + r = class_from_schema(schema) + elif schema.type == "boolean": + r = bool + elif schema.type == "array": + r = List[schema.items.get_type()] + elif schema.type == "object": + return schema.get_type() + elif schema.type is None: # discriminated root + """ + recursively define related discriminated objects + """ + schema.get_type() + return None + else: + raise TypeError(schema.type) + + return r + + @staticmethod + def annotationsof(schema: "SchemaBase", discriminators, shmanm): + annos = dict() + if schema.type == "array": + annos["__root__"] = Model.typeof(schema) + else: + + for name, f in schema.properties.items(): + r = None + for discriminator in discriminators: + if name != discriminator.propertyName: + continue + for disc, v in discriminator.mapping.items(): + if v in shmanm: + r = Literal[disc] + break + else: + raise ValueError(schema) + break + else: + r = Model.typeof(f) + + from . import v20, v30, v31 + + if isinstance(schema, (v20.Schema, v20.Reference)): + if not f.required: + annos[name] = Optional[r] + else: + annos[name] = r + elif isinstance(schema, (v30.Schema, v31.Schema, v30.Reference, v31.Reference)): + if name not in schema.required: + annos[name] = Optional[r] + else: + annos[name] = r + else: + raise TypeError(schema) + + return annos + + @staticmethod + def fieldof(schema: "SchemaBase"): + r = dict() + if schema.type == "array": + return r + else: + for name, f in schema.properties.items(): + args = dict() + for i in ["enum", "default"]: + v = getattr(f, i, None) + if v: + args[i] = v + r[name] = Field(**args) + return r + + +if len(type_format_to_class) == 0: + generate_type_format_to_class() diff --git a/aiopenapi3/openapi.py b/aiopenapi3/openapi.py new file mode 100644 index 0000000..e4a591a --- /dev/null +++ b/aiopenapi3/openapi.py @@ -0,0 +1,368 @@ +import sys +import gc + + +if sys.version_info >= (3, 9): + import pathlib +else: + import pathlib3x as pathlib + +import re +from typing import List, Dict, Union, Callable, Tuple + +import httpx +import yarl + +from aiopenapi3.v30.general import Reference +from .json import JSONReference +from . import v20 +from . import v30 +from . import v31 +from .request import OperationIndex, HTTP_METHODS +from .errors import ReferenceResolutionError, SpecError +from .loader import Loader, NullLoader +from .plugin import Plugin, Plugins +from .base import RootBase, ReferenceBase, SchemaBase +from .v30.paths import Operation + + +class OpenAPI: + @property + def paths(self): + return self._root.paths + + @property + def components(self): + return self._root.components + + @property + def info(self): + return self._root.info + + @property + def openapi(self): + return self._root.openapi + + @property + def servers(self): + return self._root.servers + + @classmethod + def load_sync( + cls, url, session_factory: Callable[[], httpx.Client] = httpx.Client, loader=None, plugins: List[Plugin] = None + ): + resp = session_factory().get(url) + return cls.loads(url, resp.text, session_factory, loader, plugins) + + @classmethod + async def load_async( + cls, + url, + session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, + loader=None, + plugins: List[Plugin] = None, + ): + async with session_factory() as client: + resp = await client.get(url) + return cls.loads(url, resp.text, session_factory, loader, plugins) + + @classmethod + def load_file( + cls, + url, + path, + session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, + loader=None, + plugins: List[Plugin] = None, + ): + assert loader + data = loader.load(Plugins(plugins or []), path) + return cls.loads(url, data, session_factory, loader, plugins) + + @classmethod + def loads( + cls, + url, + data, + session_factory: Callable[[], httpx.AsyncClient] = httpx.AsyncClient, + loader=None, + plugins: List[Plugin] = None, + ): + if loader is None: + loader = NullLoader() + data = loader.parse(Plugins(plugins or []), pathlib.Path(url), data) + return cls(url, data, session_factory, loader, plugins) + + def _parse_obj(self, raw_document): + v = raw_document.get("openapi", None) + if v: + v = list(map(int, v.split("."))) + if v[0] == 3: + if v[1] == 0: + return v30.Root.parse_obj(raw_document) + elif v[1] == 1: + return v31.Root.parse_obj(raw_document) + else: + raise ValueError(f"openapi version 3.{v[1]} not supported") + else: + raise ValueError(f"openapi major version {v[0]} not supported") + return + + v = raw_document.get("swagger", None) + if v: + v = list(map(int, v.split("."))) + if v[0] == 2 and v[1] == 0: + return v20.Root.parse_obj(raw_document) + else: + raise ValueError(f"swagger version {'.'.join(v)} not supported") + else: + raise ValueError("missing openapi/swagger field") + + def __init__( + self, + url, + document, + session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = httpx.AsyncClient, + loader=None, + plugins: List[Plugin] = None, + ): + """ + Creates a new OpenAPI document from a loaded spec file. This is + overridden here because we need to specify the path in the parent + class' constructor. + + :param document: The raw OpenAPI file loaded into python + :type document: dct + :param session_factory: default uses new session for each call, supply your own if required otherwise. + :type session_factory: returns httpx.AsyncClient or http.Client + """ + + self._base_url: yarl.URL = yarl.URL(url) + + self._session_factory: Callable[[], Union[httpx.Client, httpx.AsyncClient]] = session_factory + + """ + Loader - loading referenced documents + """ + self.loader: Loader = loader + + """ + creates the Async/Request for the protocol required + """ + self._createRequest: Callable[["OpenAPI", str, str, "Operation"], "RequestBase"] = None + + """ + authorization informations + e.g. {"BasicAuth": ("user","secret")} + """ + self._security: Dict[str, Tuple[str]] = dict() + + """ + the related documents + """ + self._documents: Dict[str, RootBase] = dict() + + """ + the plugin interface allows taking care of defects in description documents and implementations + """ + self.plugins: Plugins = Plugins(plugins or []) + + document = self.plugins.document.parsed(url=url, document=document).document + + self._root = self._parse_obj(document) + + self._init_session_factory(session_factory) + self._init_references() + self._init_operationindex() + self._init_schema_types() + + self.plugins.init.initialized(initialized=self._root) + + def _init_session_factory(self, session_factory): + if issubclass(getattr(session_factory, "__annotations__", {}).get("return", None.__class__), httpx.Client) or ( + type(session_factory) == type and issubclass(session_factory, httpx.Client) + ): + if isinstance(self._root, v20.Root): + self._createRequest = v20.Request + elif isinstance(self._root, (v30.Root, v31.Root)): + self._createRequest = v30.Request + else: + raise ValueError(self._root) + elif issubclass( + getattr(session_factory, "__annotations__", {}).get("return", None.__class__), httpx.AsyncClient + ) or (type(session_factory) == type and issubclass(session_factory, httpx.AsyncClient)): + if isinstance(self._root, v20.Root): + self._createRequest = v20.AsyncRequest + elif isinstance(self._root, (v30.Root, v31.Root)): + self._createRequest = v30.AsyncRequest + else: + raise ValueError(self._root) + else: + raise ValueError("invalid return value annotation for session_factory") + + def _init_references(self): + self._root._resolve_references(self) + for i in list(self._documents.values()): + i._resolve_references(self) + + def _init_operationindex(self): + operation_map = set() + + def test_operation(operation_id): + if operation_id in operation_map: + raise SpecError(f"Duplicate operationId {operation_id}", element=None) + operation_map.add(operation_id) + + if isinstance(self._root, v20.Root): + if self.paths: + for path, obj in self.paths.items(): + for m in obj.__fields_set__ & HTTP_METHODS: + op = getattr(obj, m) + _validate_parameters(op, path) + if op.operationId is None: + continue + formatted_operation_id = op.operationId.replace(" ", "_") + test_operation(formatted_operation_id) + for r, response in op.responses.items(): + if isinstance(response, Reference): + continue + if isinstance(response.schema_, (v20.Schema,)): + response.schema_._identity = f"{path}.{m}.{r}" + + elif isinstance(self._root, (v30.Root, v31.Root)): + if self.components: + for name, schema in filter(lambda v: isinstance(v[1], SchemaBase), self.components.schemas.items()): + schema._identity = name + + if self.paths: + for path, obj in self.paths.items(): + for m in obj.__fields_set__ & HTTP_METHODS: + op = getattr(obj, m) + _validate_parameters(op, path) + if op.operationId is None: + continue + formatted_operation_id = op.operationId.replace(" ", "_") + test_operation(formatted_operation_id) + for r, response in op.responses.items(): + + if isinstance(response, Reference): + continue + for c, content in response.content.items(): + if content.schema_ is None: + continue + if isinstance(content.schema_, (v30.Schema, v31.Schema)): + content.schema_._identity = f"{path}.{m}.{r}.{c}" + + else: + raise ValueError(self._root) + + def _init_schema_types(self): + """ + create & cache all the types - + discriminated types are special, + they need to inherit properly and have to be created when creating the parent type + + :return: None + """ + # + gc.collect() + schemas = dict((id(i), i) for i in filter(lambda obj: isinstance(obj, SchemaBase), gc.get_objects())) + init = set(schemas.keys()) + for k, i in schemas.items(): + if not i.discriminator: + continue + init -= frozenset( + map(lambda x: id(x._target), filter(lambda x: isinstance(x, ReferenceBase), i.oneOf + i.anyOf)) + ) + + for i in init: + s = schemas[i] + s.set_type() + + @property + def url(self): + if isinstance(self._root, v20.Root): + base = yarl.URL(self._base_url) + scheme = host = port = path = None + + for i in ["https", "http"]: + if not self._root.schemes or i not in self._root.schemes: + continue + scheme = i + break + else: + scheme = base.scheme + + if self._root.host: + host, _, port = self._root.host.partition(":") + else: + host, port = base.host, base.port + + path = self._root.basePath or base.path + + r = yarl.URL.build(scheme=scheme, host=host, port=port, path=path) + return r + elif isinstance(self._root, (v30.Root, v31.Root)): + return self._base_url.join(yarl.URL(self._root.servers[0].url)) + + # public methods + def authenticate(self, *args, **kwargs): + """ + + :param args: None to remove all credentials / reset the authorizations + :param kwargs: scheme=value + """ + if len(args) == 1 and args[0] == None: + self._security = dict() + + schemes = frozenset(kwargs.keys()) + if isinstance(self._root, v20.Root): + v = schemes - frozenset(self._root.securityDefinitions) + elif isinstance(self._root, (v30.Root, v31.Root)): + v = schemes - frozenset(self._root.components.securitySchemes) + + if v: + raise ValueError("{} does not accept security schemes {}".format(self.info.title, sorted(v))) + + for security_scheme, value in kwargs.items(): + if value is None: + del self._security[security_scheme] + else: + self._security[security_scheme] = value + + def _load(self, i): + data = self.loader.get(self.plugins, i) + return self._parse_obj(data) + + @property + def _(self): + return OperationIndex(self) + + def resolve_jr(self, root: "Rootv30", obj, value: Reference): + url, jp = JSONReference.split(value.ref) + if url != "": + url = pathlib.Path(url) + if url not in self._documents: + self._documents[url] = self._load(url) + root = self._documents[url] + + try: + return root.resolve_jp(jp) + except ReferenceResolutionError as e: + # add metadata to the error + e.element = obj + raise + + +def _validate_parameters(op: "Operation", path): + """ + Ensures that all parameters for this path are valid + """ + assert isinstance(path, str) + allowed_path_parameters = frozenset(re.findall(r"{([a-zA-Z0-9\-\._~]+)}", path)) + + path_parameters = frozenset(map(lambda x: x.name, filter(lambda c: c.in_ == "path", op.parameters))) + + r = path_parameters - allowed_path_parameters + if r: + raise SpecError(f"Parameter name{'s' if len(r) > 1 else ''} not found in path: {', '.join(sorted(r))}") diff --git a/aiopenapi3/plugin.py b/aiopenapi3/plugin.py new file mode 100644 index 0000000..31c80e6 --- /dev/null +++ b/aiopenapi3/plugin.py @@ -0,0 +1,134 @@ +import dataclasses +from typing import List, Any, Dict +from pydantic import BaseModel + +""" +the plugin interface replicates the suds way of dealing with broken data/schema information +""" + + +class Plugin: + pass + + +class Init(Plugin): + @dataclasses.dataclass + class Context: + initialized: "OpenAPISpec" + + def initialized(self, ctx: "Init.Context") -> "Init.Context": # pragma: no cover + pass + + +class Document(Plugin): + @dataclasses.dataclass + class Context: + url: str + document: Dict[str, Any] + + """ + loaded(text) -> parsed(dict) + """ + + def loaded(self, ctx: "Document.Context") -> "Document.Context": # pragma: no cover + """modify the text before parsing""" + pass + + def parsed(self, ctx: "Document.Context") -> "Document.Context": # pragma: no cover + """modify the parsed dict before …""" + pass + + +class Message(Plugin): + @dataclasses.dataclass + class Context: + operationId: str + marshalled: Dict[str, Any] = None + sending: str = None + received: str = None + parsed: Dict[str, Any] = None + unmarshalled: BaseModel = None + + """ + sending: marshalled(dict)-> sending(str) + + receiving: received -> parsed -> unmarshalled + """ + + def marshalled(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover + """ + modify the dict before sending + """ + pass + + def sending(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover + """ + modify the text before sending + """ + pass + + def received(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover + """ + modify the received text + """ + pass + + def parsed(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover + """ + modify the parsed dict structure + """ + pass + + def unmarshalled(self, ctx: "Message.Context") -> "Message.Context": # pragma: no cover + """ + modify the object + """ + pass + + +class Domain: + def __init__(self, ctx, plugins: List[Plugin]): + self.Context = ctx + self.plugins = plugins + + def __getattr__(self, name: str) -> "Method": + return Method(name, self) + + +class Method: + def __init__(self, name: str, domain: Domain): + self.name = name + self.domain = domain + + def __call__(self, **kwargs): + r = self.domain.Context(**kwargs) + for plugin in self.domain.plugins: + method = getattr(plugin, self.name, None) + if method is None: + continue + method(r) + return r + + +class Plugins: + _domains: Dict[str, Plugin] = {"init": Init, "document": Document, "message": Message} + + def __init__(self, plugins: List[Plugin]): + for i in self._domains.keys(): + setattr(self, f"_{i}", self._get_domain(i, plugins)) + + def _get_domain(self, name, plugins) -> "Domain": + plugins = [p for p in filter(lambda x: isinstance(x, self._domains.get(name)), plugins)] + return Domain(self._domains.get(name).Context, plugins) + + @property + def init(self) -> Domain: + return self._init + + @property + def document(self) -> Domain: + return self._document + + @property + def message(self) -> "Domain": + return self._message diff --git a/aiopenapi3/request.py b/aiopenapi3/request.py new file mode 100644 index 0000000..214ead4 --- /dev/null +++ b/aiopenapi3/request.py @@ -0,0 +1,105 @@ +from typing import Dict +import httpx +import pydantic +import yarl + +from .base import SchemaBase, ParameterBase, HTTP_METHODS +from .version import __version__ + + +class RequestParameter: + def __init__(self, url: yarl.URL): + self.url = str(url) + self.auth = None + self.cookies = {} + self.path = {} + self.params = {} + self.content = None + self.headers = {} + + +class RequestBase: + def __init__(self, api: "OpenAPI", method: str, path: str, operation: "Operation"): + self.api = api + self.root = api._root + self.method = method + self.path = path + self.operation = operation + self.req: RequestParameter = RequestParameter(self.path) + + def __call__(self, *args, **kwargs): + return self.request(*args, **kwargs) + + def _factory_args(self): + return {"auth": self.req.auth, "headers": {"user-agent": f"aiopenapi3/{__version__}"}} + + def request(self, data=None, parameters=None): + """ + Sends an HTTP request as described by this Path + + :param data: The request body to send. + :type data: any, should match content/type + :param parameters: The parameters used to create the path + :type parameters: dict{str: str} + """ + self._prepare(data, parameters) + with self.api._session_factory(**self._factory_args()) as session: + req = self._build_req(session) + result = session.send(req) + return self._process(result) + + +class AsyncRequestBase(RequestBase): + async def __call__(self, *args, **kwargs): + return await self.request(*args, **kwargs) + + async def request(self, data=None, parameters=None): + self._prepare(data, parameters) + async with self.api._session_factory(**self._factory_args()) as session: + req = self._build_req(session) + result = await session.send(req) + + return self._process(result) + + +class OperationIndex: + class Iter: + def __init__(self, spec): + self.operations = [] + self.r = 0 + pi: "PathItem" + for path, pi in spec.paths.items(): + op: "Operation" + for method in pi.__fields_set__ & HTTP_METHODS: + op = getattr(pi, method) + if op.operationId is None: + continue + self.operations.append(op.operationId) + self.r = iter(range(len(self.operations))) + + def __iter__(self): + return self + + def __next__(self): + return self.operations[next(self.r)] + + def __init__(self, api): + self._api: "OpenAPI" = api + self._root: "RootBase" = api._root + + self._operations: Dict[str, "Operation"] = dict() + + for path, pi in self._root.paths.items(): + op: "Operation" + for method in pi.__fields_set__ & HTTP_METHODS: + op = getattr(pi, method) + if op.operationId is None: + continue + self._operations[op.operationId.replace(" ", "_")] = (method, path, op) + + def __getattr__(self, item): + (method, path, op) = self._operations[item] + return self._api._createRequest(self._api, method, path, op) + + def __iter__(self): + return self.Iter(self._root) diff --git a/aiopenapi3/v20/__init__.py b/aiopenapi3/v20/__init__.py new file mode 100644 index 0000000..77d4436 --- /dev/null +++ b/aiopenapi3/v20/__init__.py @@ -0,0 +1,5 @@ +from .root import Root +from .security import SecurityRequirement +from .glue import Request, AsyncRequest +from .schemas import Schema +from .general import Reference diff --git a/aiopenapi3/v20/general.py b/aiopenapi3/v20/general.py new file mode 100644 index 0000000..f3c8f89 --- /dev/null +++ b/aiopenapi3/v20/general.py @@ -0,0 +1,44 @@ +from typing import Optional + +from pydantic import Field, Extra + +from ..base import ObjectExtended, ObjectBase, ReferenceBase + + +class ExternalDocumentation(ObjectExtended): + """ + An `External Documentation Object`_ references external resources for extended + documentation. + + .. _External Documentation Object: https://swagger.io/specification/v2/#external-documentation-object + """ + + description: Optional[str] = Field(default=None) + url: str = Field(...) + + +class Reference(ObjectBase, ReferenceBase): + """ + A `Reference Object`_ designates a reference to another node in the specification. + + .. _Reference Object: https://swagger.io/specification/v2/#reference-object + """ + + ref: str = Field(alias="$ref") + + _target: object = None + + class Config: + extra = Extra.ignore + + def __getattr__(self, item): + if item != "_target": + return getattr(self._target, item) + else: + return getattr(self, item) + + def __setattr__(self, item, value): + if item != "_target": + setattr(self._target, item, value) + else: + super().__setattr__(item, value) diff --git a/aiopenapi3/v20/glue.py b/aiopenapi3/v20/glue.py new file mode 100644 index 0000000..73d7036 --- /dev/null +++ b/aiopenapi3/v20/glue.py @@ -0,0 +1,210 @@ +from typing import Dict, List +import json + +import httpx +import pydantic + +from ..base import SchemaBase, ParameterBase +from ..request import RequestBase, AsyncRequestBase +from ..errors import HTTPStatusError, ContentTypeError + +from .parameter import Parameter + + +class Request(RequestBase): + @property + def security(self): + return self.api._security + + @property + def _data_parameter(self) -> Parameter: + for i in filter(lambda x: x.in_ == "body", self.operation.parameters): + return i + raise ValueError("body") + + @property + def data(self) -> SchemaBase: + return self._data_parameter.schema_ + + @property + def parameters(self) -> Dict[str, ParameterBase]: + return list( + filter(lambda x: x.in_ != "body", self.operation.parameters + self.root.paths[self.path].parameters) + ) + + def args(self, content_type: str = "application/json"): + op = self.operation + parameters = op.parameters + self.root.paths[self.path].parameters + schema = op.requestBody.content[content_type].schema_ + return {"parameters": parameters, "data": schema} + + def return_value(self, http_status: int = 200, content_type: str = "application/json") -> SchemaBase: + return self.operation.responses[str(http_status)].schema_ + + def _prepare_security(self): + security = self.operation.security or self.api._root.security + + if not security: + return + + if not self.security: + if any([{} == i.__root__ for i in security]): + return + else: + options = " or ".join( + sorted(map(lambda x: f"{{{x}}}", [" and ".join(sorted(i.__root__.keys())) for i in security])) + ) + raise ValueError(f"No security requirement satisfied (accepts {options})") + + for s in security: + if frozenset(s.__root__.keys()) - frozenset(self.security.keys()): + continue + for scheme, _ in s.__root__.items(): + value = self.security[scheme] + self._prepare_secschemes(scheme, value) + break + else: + options = " or ".join( + sorted(map(lambda x: f"{{{x}}}", [" and ".join(sorted(i.__root__.keys())) for i in security])) + ) + raise ValueError(f"No security requirement satisfied (accepts {options})") + + def _prepare_secschemes(self, scheme: str, value: List[str]): + """ + https://swagger.io/specification/v2/#security-scheme-object + """ + ss = self.root.securityDefinitions[scheme] + + if ss.type == "basic": + self.req.auth = value + + if ss.type == "apiKey": + if ss.in_ == "query": + # apiKey in query parameter + self.req.params[ss.name] = value + + if ss.in_ == "header": + # apiKey in query header data + self.req.headers[ss.name] = value + + def _prepare_parameters(self, parameters): + # Parameters + path_parameters = {} + accepted_parameters = {} + p = filter(lambda x: x.in_ != "body", self.operation.parameters + self.root.paths[self.path].parameters) + + for _ in list(p): + # TODO - make this work with $refs - can operations be $refs? + accepted_parameters.update({_.name: _}) + + for name, spec in accepted_parameters.items(): + if parameters is None or name not in parameters: + if spec.required: + raise ValueError(f"Required parameter {name} not provided") + continue + + value = parameters[name] + + if spec.in_ == "path": + # The string method `format` is incapable of partial updates, + # as such we need to collect all the path parameters before + # applying them to the format string. + path_parameters[name] = value + + if spec.in_ == "query": + self.req.params[name] = value + + if spec.in_ == "header": + self.req.headers[name] = value + + self.req.url = self.req.url.format(**path_parameters) + + def _prepare_body(self, data): + try: + required = self._data_parameter.required + except ValueError: + return + + if data is None and required: + raise ValueError("Request Body is required but none was provided.") + + consumes = frozenset(self.operation.consumes or self.root.consumes) + if "application/json" in consumes: + if isinstance(data, (dict, list)): + pass + elif isinstance(data, pydantic.BaseModel): + data = dict(data._iter(to_dict=True)) + else: + raise TypeError(data) + data = self.api.plugins.message.marshalled( + operationId=self.operation.operationId, marshalled=data + ).marshalled + data = json.dumps(data, default=pydantic.json.pydantic_encoder) + data = data.encode() + data = self.api.plugins.message.sending(operationId=self.operation.operationId, sending=data).sending + self.req.content = data + self.req.headers["Content-Type"] = "application/json" + else: + raise NotImplementedError(f"unsupported mime types {consumes}") + + def _prepare(self, data, parameters): + self._prepare_security() + self._prepare_parameters(parameters) + self._prepare_body(data) + + def _build_req(self, session): + req = session.build_request( + self.method, + str(self.api.url / self.req.url[1:]), + headers=self.req.headers, + cookies=self.req.cookies, + params=self.req.params, + content=self.req.content, + ) + return req + + def _process(self, result): + # spec enforces these are strings + status_code = str(result.status_code) + + # find the response model in spec we received + expected_response = None + if status_code in self.operation.responses: + expected_response = self.operation.responses[status_code] + elif "default" in self.operation.responses: + expected_response = self.operation.responses["default"] + + if expected_response is None: + # TODO - custom exception class that has the response object in it + options = ",".join(self.operation.responses.keys()) + raise HTTPStatusError( + result.status_code, + f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""", + result, + ) + + if status_code == "204": + return + + content_type = result.headers.get("Content-Type", None) + + if content_type and content_type.lower().partition(";")[0] == "application/json": + data = result.text + data = self.api.plugins.message.received(operationId=self.operation.operationId, received=data).received + data = json.loads(data) + data = self.api.plugins.message.parsed(operationId=self.operation.operationId, parsed=data).parsed + data = expected_response.schema_.model(data) + data = self.api.plugins.message.unmarshalled( + operationId=self.operation.operationId, unmarshalled=data + ).unmarshalled + return data + else: + raise ContentTypeError( + content_type, + f"Unexpected Content-Type {content_type} returned for operation {self.operation.operationId} (expected application/json)", + result, + ) + + +class AsyncRequest(Request, AsyncRequestBase): + pass diff --git a/aiopenapi3/v20/info.py b/aiopenapi3/v20/info.py new file mode 100644 index 0000000..b03a50d --- /dev/null +++ b/aiopenapi3/v20/info.py @@ -0,0 +1,43 @@ +from typing import Optional + +from pydantic import Field + +from ..base import ObjectExtended + + +class Contact(ObjectExtended): + """ + Contact object belonging to an Info object, as described `here`_ + + .. _here: https://swagger.io/specification/v2/#contact-object + """ + + email: str = Field(default=None) + name: str = Field(default=None) + url: str = Field(default=None) + + +class License(ObjectExtended): + """ + License object belonging to an Info object, as described `here`_ + + .. _here: https://swagger.io/specification/v2/#license-object + """ + + name: str = Field(...) + url: Optional[str] = Field(default=None) + + +class Info(ObjectExtended): + """ + An OpenAPI Info object, as defined in `the spec`_. + + .. _here: https://swagger.io/specification/v2/#info-object + """ + + title: str = Field(...) + description: Optional[str] = Field(default=None) + termsOfService: Optional[str] = Field(default=None) + license: Optional[License] = Field(default=None) + contact: Optional[Contact] = Field(default=None) + version: str = Field(...) diff --git a/aiopenapi3/v20/parameter.py b/aiopenapi3/v20/parameter.py new file mode 100644 index 0000000..2259dd5 --- /dev/null +++ b/aiopenapi3/v20/parameter.py @@ -0,0 +1,99 @@ +from typing import Union, Optional, Any + +from pydantic import Field + +from .general import Reference +from .schemas import Schema +from ..base import ObjectExtended, ObjectBase + + +class Item(ObjectExtended): + """ + https://swagger.io/specification/v2/#items-object + """ + + type: str = Field(...) + format: Optional[str] = Field(default=None) + items: Optional["Item"] = Field(default=None) + collectionFormat: Optional[str] = Field(default=None) + default: Any = Field(default=None) + maximum: Optional[int] = Field(default=None) + exclusiveMaximum: Optional[bool] = Field(default=None) + minimum: Optional[int] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) + enum: Optional[Any] = Field(default=None) + multipleOf: Optional[int] = Field(default=None) + + +class Empty(ObjectExtended): + pass + + +class Parameter(ObjectExtended): + """ + Describes a single operation parameter. + + .. _Parameter Object: https://swagger.io/specification/v2/#parameter-object + """ + + name: str = Field(required=True) + in_: str = Field(required=True, alias="in") # "query", "header", "path", "formData" or "body" + + description: Optional[str] = Field(default=None) + required: Optional[bool] = Field(default=None) + + schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") + + type: Optional[str] = Field(default=None) + format: Optional[str] = Field(default=None) + allowEmptyValue: Optional[bool] = Field(default=None) + items: Optional[Union[Item, Empty]] = Field(default=None) + collectionFormat: Optional[str] = Field(default=None) + default: Any = Field(default=None) + maximum: Optional[int] = Field(default=None) + exclusiveMaximum: Optional[bool] = Field(default=None) + minimum: Optional[int] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) + enum: Optional[Any] = Field(default=None) + multipleOf: Optional[int] = Field(default=None) + + +class Header(ObjectExtended): + """ + https://swagger.io/specification/v2/#header-object + """ + + description: Optional[str] = Field(default=None) + + type: str = Field(...) + format: Optional[str] = Field(default=None) + items: Optional[Item] = Field(default=None) + collectionFormat: Optional[str] = Field(default=None) + default: Any = Field(default=None) + maximum: Optional[int] = Field(default=None) + exclusiveMaximum: Optional[bool] = Field(default=None) + minimum: Optional[int] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) + enum: Optional[Any] = Field(default=None) + multipleOf: Optional[int] = Field(default=None) + + +Item.update_forward_refs() diff --git a/aiopenapi3/v20/paths.py b/aiopenapi3/v20/paths.py new file mode 100644 index 0000000..bb1924a --- /dev/null +++ b/aiopenapi3/v20/paths.py @@ -0,0 +1,80 @@ +from typing import Union, List, Optional, Dict, Any + +from pydantic import Field, root_validator + +from .general import ExternalDocumentation +from .general import Reference +from .parameter import Header, Parameter +from .schemas import Schema +from .security import SecurityRequirement +from ..base import ObjectExtended, ObjectBase, PathsBase + + +class Response(ObjectExtended): + """ + Describes a single response from an API Operation. + + .. _Response Object: https://swagger.io/specification/v2/#response-object + """ + + description: str = Field(...) + schema_: Optional[Schema] = Field(default=None, alias="schema") + headers: Optional[Dict[str, Header]] = Field(default_factory=dict) + examples: Optional[Dict[str, Any]] = Field(default=None) + + +class Operation(ObjectExtended): + """ + An Operation object as defined `here`_ + + .. _here: https://swagger.io/specification/v2/#operation-object + """ + + tags: Optional[List[str]] = Field(default=None) + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) + operationId: Optional[str] = Field(default=None) + consumes: Optional[List[str]] = Field(default_factory=list) + produces: Optional[List[str]] = Field(default_factory=list) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + responses: Dict[str, Union[Reference, Response]] = Field(default_factory=dict) + schemes: Optional[List[str]] = Field(default_factory=list) + deprecated: Optional[bool] = Field(default=None) + security: Optional[List[SecurityRequirement]] = Field(default=None) + + +class PathItem(ObjectExtended): + """ + A Path Item, as defined `here`_. + Describes the operations available on a single path. + + .. _here: https://swagger.io/specification/v2/#path-item-object + """ + + ref: Optional[str] = Field(default=None, alias="$ref") + get: Optional[Operation] = Field(default=None) + put: Optional[Operation] = Field(default=None) + post: Optional[Operation] = Field(default=None) + delete: Optional[Operation] = Field(default=None) + options: Optional[Operation] = Field(default=None) + head: Optional[Operation] = Field(default=None) + patch: Optional[Operation] = Field(default=None) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + + +class Paths(PathsBase): + @root_validator(pre=True) + def validate_Paths(cls, values): + assert set(values.keys()) - frozenset(["__root__"]) == set([]) + p = {} + e = {} + for k, v in values.get("__root__", {}).items(): + if k[:2] == "x-": + e[k] = v + else: + p[k] = PathItem(**v) + return {"_paths": p, "_extensions": e} + + +Operation.update_forward_refs() diff --git a/aiopenapi3/v20/root.py b/aiopenapi3/v20/root.py new file mode 100644 index 0000000..e8226a3 --- /dev/null +++ b/aiopenapi3/v20/root.py @@ -0,0 +1,42 @@ +from typing import List, Optional, Dict + +from pydantic import Field, validator + +from .general import Reference, ExternalDocumentation +from .info import Info +from .parameter import Parameter +from .paths import Response, Paths, PathItem +from .schemas import Schema +from .security import SecurityScheme, SecurityRequirement +from .tag import Tag +from ..base import ObjectExtended, RootBase + + +class Root(ObjectExtended, RootBase): + """ + This is the root document object for the API specification. + + https://swagger.io/specification/v2/#swagger-object + """ + + swagger: str = Field(...) + info: Info = Field(...) + host: Optional[str] = Field(default=None) + basePath: Optional[str] = Field(default=None) + schemes: Optional[List[str]] = Field(default_factory=list) + consumes: Optional[List[str]] = Field(default_factory=list) + produces: Optional[List[str]] = Field(default_factory=list) + paths: Paths = Field(default=None) + definitions: Optional[Dict[str, Schema]] = Field(default_factory=dict) + parameters: Optional[Dict[str, Parameter]] = Field(default_factory=dict) + responses: Optional[Dict[str, Response]] = Field(default_factory=dict) + securityDefinitions: Optional[Dict[str, SecurityScheme]] = Field(default_factory=dict) + security: Optional[List[SecurityRequirement]] = Field(default=None) + tags: Optional[List[Tag]] = Field(default_factory=list) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) + + def _resolve_references(self, api): + RootBase.resolve(api, self, self, PathItem, Reference) + + +Root.update_forward_refs() diff --git a/aiopenapi3/v20/schemas.py b/aiopenapi3/v20/schemas.py new file mode 100644 index 0000000..c6b4599 --- /dev/null +++ b/aiopenapi3/v20/schemas.py @@ -0,0 +1,60 @@ +from typing import Union, List, Any, Optional, Dict + +from pydantic import Field + +from .general import Reference +from .xml import XML +from ..base import ObjectExtended, SchemaBase + + +class Schema(ObjectExtended, SchemaBase): + """ + The Schema Object allows the definition of input and output data types. + + https://swagger.io/specification/v2/#schema-object + """ + + ref: Optional[str] = Field(default=None, alias="$ref") + format: Optional[str] = Field(default=None) + title: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + default: Optional[Any] = Field(default=None) + + multipleOf: Optional[int] = Field(default=None) + maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better + exclusiveMaximum: Optional[bool] = Field(default=None) + minimum: Optional[float] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) + maxProperties: Optional[int] = Field(default=None) + minProperties: Optional[int] = Field(default=None) + required: Optional[List[str]] = Field(default_factory=list) + enum: Optional[list] = Field(default=None) + type: Optional[str] = Field(default=None) + + items: Optional[Union[List[Union["Schema", Reference]], Union["Schema", Reference]]] = Field(default=None) + allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) + + discriminator: Optional[str] = Field(default=None) # 'Discriminator' + readOnly: Optional[bool] = Field(default=None) + xml: Optional[XML] = Field(default=None) # 'XML' + externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' + example: Optional[Any] = Field(default=None) + + _model_type: object + _request_model_type: object + + """ + The _identity attribute is set during OpenAPI.__init__ and used at get_type() + """ + _identity: str + + +Schema.update_forward_refs() diff --git a/aiopenapi3/v20/security.py b/aiopenapi3/v20/security.py new file mode 100644 index 0000000..aa27af0 --- /dev/null +++ b/aiopenapi3/v20/security.py @@ -0,0 +1,41 @@ +from typing import Optional, Dict, List + +from pydantic import Field, BaseModel, root_validator + +from ..base import ObjectExtended + + +class SecurityScheme(ObjectExtended): + """ + Allows the definition of a security scheme that can be used by the operations. + + https://swagger.io/specification/v2/#security-scheme-object + """ + + type: str = Field(...) + description: Optional[str] = Field(default=None) + name: Optional[str] = Field(default=None) + in_: Optional[str] = Field(default=None, alias="in") + + flow: Optional[str] = Field(default=None) + authorizationUrl: Optional[str] = Field(default=None) + tokenUrl: Optional[str] = Field(default=None) + refreshUrl: Optional[str] = Field(default=None) + scopes: Dict[str, str] = Field(default_factory=dict) + + @root_validator + def validate_SecurityScheme(cls, values): + if values["type"] == "apiKey": + assert values["name"], "name is required for apiKey" + assert values["in_"] in frozenset(["query", "header"]), "in must be query or header" + return values + + +class SecurityRequirement(BaseModel): + """ + Lists the required security schemes to execute this operation. + + https://swagger.io/specification/v2/#security-requirement-object + """ + + __root__: Dict[str, List[str]] diff --git a/aiopenapi3/v20/tag.py b/aiopenapi3/v20/tag.py new file mode 100644 index 0000000..0dcbb2f --- /dev/null +++ b/aiopenapi3/v20/tag.py @@ -0,0 +1,19 @@ +from typing import Optional + +from pydantic import Field + +from ..base import ObjectExtended +from .general import ExternalDocumentation + + +class Tag(ObjectExtended): + """ + A `Tag Object`_ holds a reusable set of different aspects of the OAS + spec. + + .. _Tag Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#tag-object + """ + + name: str = Field(...) + description: Optional[str] = Field(default=None) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) diff --git a/aiopenapi3/v20/xml.py b/aiopenapi3/v20/xml.py new file mode 100644 index 0000000..db0cd41 --- /dev/null +++ b/aiopenapi3/v20/xml.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from .general import ObjectExtended + + +class XML(ObjectExtended): + """ + A metadata object that allows for more fine-tuned XML model definitions. + + https://swagger.io/specification/v2/#xml-object + """ + + name: str = Field(default=None) + namespace: str = Field(default=None) + prefix: str = Field(default=None) + attribute: bool = Field(default=False) + wrapped: bool = Field(default=False) diff --git a/aiopenapi3/v30/__init__.py b/aiopenapi3/v30/__init__.py new file mode 100644 index 0000000..e54baa4 --- /dev/null +++ b/aiopenapi3/v30/__init__.py @@ -0,0 +1,6 @@ +from .schemas import Schema +from .root import Root +from .paths import PathItem, Operation, SecurityRequirement +from .parameter import Parameter +from .glue import Request, AsyncRequest +from .general import Reference diff --git a/aiopenapi3/v30/components.py b/aiopenapi3/v30/components.py new file mode 100644 index 0000000..c40b9f1 --- /dev/null +++ b/aiopenapi3/v30/components.py @@ -0,0 +1,36 @@ +from typing import Union, Optional, Dict + +from pydantic import Field + +from ..base import ObjectExtended + +from .example import Example +from .paths import RequestBody, Link, Response, Callback +from .general import Reference +from .parameter import Header, Parameter +from .schemas import Schema +from .security import SecurityScheme + + +class Components(ObjectExtended): + """ + A `Components Object`_ holds a reusable set of different aspects of the OAS + spec. + + .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#components-object + """ + + schemas: Optional[Dict[str, Union[Schema, Reference]]] = Field(default_factory=dict) + responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) + parameters: Optional[Dict[str, Union[Parameter, Reference]]] = Field(default_factory=dict) + examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) + requestBodies: Optional[Dict[str, Union[RequestBody, Reference]]] = Field(default_factory=dict) + headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + securitySchemes: Optional[Dict[str, Union[SecurityScheme, Reference]]] = Field(default_factory=dict) + links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) + callbacks: Optional[Dict[str, Union[Callback, Reference]]] = Field(default_factory=dict) + + +# pathItems: Optional[Dict[str, Union[PathItem, Reference]]] = Field(default_factory=dict) #v3.1 + +Components.update_forward_refs() diff --git a/aiopenapi3/v30/example.py b/aiopenapi3/v30/example.py new file mode 100644 index 0000000..44af95d --- /dev/null +++ b/aiopenapi3/v30/example.py @@ -0,0 +1,21 @@ +from typing import Optional, Any + +from pydantic import Field + +from ..base import ObjectExtended + +from .general import Reference + + +class Example(ObjectExtended): + """ + A `Example Object`_ holds a reusable set of different aspects of the OAS + spec. + + .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#example-object + """ + + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + value: Optional[Any] = Field(default=None) + externalValue: Optional[str] = Field(default=None) diff --git a/aiopenapi3/v30/general.py b/aiopenapi3/v30/general.py new file mode 100644 index 0000000..a38881d --- /dev/null +++ b/aiopenapi3/v30/general.py @@ -0,0 +1,46 @@ +from typing import Optional + +from pydantic import Field, Extra + +from ..base import ObjectExtended, ObjectBase, ReferenceBase + + +class ExternalDocumentation(ObjectExtended): + """ + An `External Documentation Object`_ references external resources for extended + documentation. + + .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#external-documentation-object + """ + + url: str = Field(...) + description: Optional[str] = Field(default=None) + + +class Reference(ObjectBase, ReferenceBase): + """ + A `Reference Object`_ designates a reference to another node in the specification. + + .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#reference-object + """ + + ref: str = Field(alias="$ref") + + _target: object = None + + class Config: + """This object cannot be extended with additional properties and any properties added SHALL be ignored.""" + + extra = Extra.ignore + + def __getattr__(self, item): + if item != "_target": + return getattr(self._target, item) + else: + return getattr(self, item) + + def __setattr__(self, item, value): + if item != "_target": + setattr(self._target, item, value) + else: + super().__setattr__(item, value) diff --git a/aiopenapi3/v30/glue.py b/aiopenapi3/v30/glue.py new file mode 100644 index 0000000..b48c52b --- /dev/null +++ b/aiopenapi3/v30/glue.py @@ -0,0 +1,239 @@ +from typing import Dict, List +import json + +import httpx +import pydantic +import pydantic.json + +from ..base import SchemaBase, ParameterBase +from ..request import RequestBase, AsyncRequestBase +from ..errors import HTTPStatusError, ContentTypeError + + +class Request(RequestBase): + """ + This class is returned by instances of the OpenAPI class when members + formatted like call_operationId are accessed, and a valid Operation is + found, and allows calling the operation directly from the OpenAPI object + with the configured values included. This class is not intended to be used + directly. + """ + + @property + def security(self): + return self.api._security + + @property + def data(self) -> SchemaBase: + return self.operation.requestBody.content["application/json"].schema_ + + @property + def parameters(self) -> Dict[str, ParameterBase]: + return self.operation.parameters + self.root.paths[self.path].parameters + + def args(self, content_type: str = "application/json"): + op = self.operation + parameters = op.parameters + self.root.paths[self.path].parameters + schema = op.requestBody.content[content_type].schema_ + return {"parameters": parameters, "data": schema} + + def return_value(self, http_status: int = 200, content_type: str = "application/json") -> SchemaBase: + return self.operation.responses[str(http_status)].content[content_type].schema_ + + def _prepare_security(self): + security = self.operation.security or self.api._root.security + + if not security: + return + + if not self.security: + if any([{} == i.__root__ for i in security]): + return + else: + options = " or ".join( + sorted(map(lambda x: f"{{{x}}}", [" and ".join(sorted(i.__root__.keys())) for i in security])) + ) + raise ValueError(f"No security requirement satisfied (accepts {options})") + + for s in security: + if frozenset(s.__root__.keys()) - frozenset(self.security.keys()): + continue + for scheme, _ in s.__root__.items(): + value = self.security[scheme] + self._prepare_secschemes(scheme, value) + break + else: + options = " or ".join( + sorted(map(lambda x: f"{{{x}}}", [" and ".join(sorted(i.__root__.keys())) for i in security])) + ) + raise ValueError(f"No security requirement satisfied (accepts {options})") + + def _prepare_secschemes(self, scheme: str, value: List[str]): + ss = self.root.components.securitySchemes[scheme] + + if ss.type == "http" and ss.scheme_ == "basic": + self.req.auth = value + + if ss.type == "http" and ss.scheme_ == "digest": + self.req.auth = httpx.DigestAuth(*value) + + if ss.type == "http" and ss.scheme_ == "bearer": + header = ss.bearerFormat or "Bearer {}" + self.req.headers["Authorization"] = header.format(value) + + if ss.type == "mutualTLS": + # TLS Client certificates (mutualTLS) + self.req.cert = value + + if ss.type == "apiKey": + if ss.in_ == "query": + # apiKey in query parameter + self.req.params[ss.name] = value + + if ss.in_ == "header": + # apiKey in query header data + self.req.headers[ss.name] = value + + if ss.in_ == "cookie": + self.req.cookies = {ss.name: value} + + def _prepare_parameters(self, parameters): + # Parameters + path_parameters = {} + accepted_parameters = {} + p = self.operation.parameters + self.root.paths[self.path].parameters + + for _ in list(p): + # TODO - make this work with $refs - can operations be $refs? + accepted_parameters.update({_.name: _}) + + for name, spec in accepted_parameters.items(): + if parameters is None or name not in parameters: + if spec.required: + raise ValueError(f"Required parameter {name} not provided") + continue + + value = parameters[name] + + if spec.in_ == "path": + # The string method `format` is incapable of partial updates, + # as such we need to collect all the path parameters before + # applying them to the format string. + path_parameters[name] = value + + if spec.in_ == "query": + self.req.params[name] = value + + if spec.in_ == "header": + self.req.headers[name] = value + + if spec.in_ == "cookie": + self.req.cookies[name] = value + + self.req.url = self.req.url.format(**path_parameters) + + def _prepare_body(self, data): + if not self.operation.requestBody: + return + + if data is None and self.operation.requestBody.required: + raise ValueError("Request Body is required but none was provided.") + + if "application/json" in self.operation.requestBody.content: + if isinstance(data, (dict, list)): + pass + elif isinstance(data, pydantic.BaseModel): + data = dict(data._iter(to_dict=True)) + else: + raise TypeError(data) + data = self.api.plugins.message.marshalled( + operationId=self.operation.operationId, marshalled=data + ).marshalled + data = json.dumps(data, default=pydantic.json.pydantic_encoder) + data = data.encode() + data = self.api.plugins.message.sending(operationId=self.operation.operationId, sending=data).sending + self.req.content = data + self.req.headers["Content-Type"] = "application/json" + else: + raise NotImplementedError() + + def _prepare(self, data, parameters): + self._prepare_security() + self._prepare_parameters(parameters) + self._prepare_body(data) + + def _build_req(self, session): + req = session.build_request( + self.method, + str(self.api.url / self.req.url[1:]), + headers=self.req.headers, + cookies=self.req.cookies, + params=self.req.params, + content=self.req.content, + ) + return req + + def _process(self, result): + # spec enforces these are strings + status_code = str(result.status_code) + + # find the response model in spec we received + expected_response = None + if status_code in self.operation.responses: + expected_response = self.operation.responses[status_code] + elif "default" in self.operation.responses: + expected_response = self.operation.responses["default"] + + if expected_response is None: + # TODO - custom exception class that has the response object in it + options = ",".join(self.operation.responses.keys()) + raise HTTPStatusError( + result.status_code, + f"""Unexpected response {result.status_code} from {self.operation.operationId} (expected one of {options}), no default is defined""", + result, + ) + + if len(expected_response.content) == 0: + return None + + content_type = result.headers.get("Content-Type", None) + + if content_type: + content_type, _, encoding = content_type.partition(";") + expected_media = expected_response.content.get(content_type, None) + if expected_media is None and "/" in content_type: + # accept media type ranges in the spec. the most specific matching + # type should always be chosen, but if we do not have a match here + # a generic range should be accepted if one if provided + # https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#response-object + + generic_type = content_type.split("/")[0] + "/*" + expected_media = expected_response.content.get(generic_type, None) + else: + expected_media = None + + if expected_media is None: + options = ",".join(expected_response.content.keys()) + raise ContentTypeError( + content_type, + f"Unexpected Content-Type {content_type} returned for operation {self.operation.operationId} \ + (expected one of {options})", + result, + ) + + if content_type.lower() == "application/json": + data = result.text + data = self.api.plugins.message.received(operationId=self.operation.operationId, received=data).received + data = json.loads(data) + data = self.api.plugins.message.parsed(operationId=self.operation.operationId, parsed=data).parsed + data = expected_media.schema_.model(data) + data = self.api.plugins.message.unmarshalled( + operationId=self.operation.operationId, unmarshalled=data + ).unmarshalled + return data + else: + raise NotImplementedError() + + +class AsyncRequest(Request, AsyncRequestBase): + pass diff --git a/aiopenapi3/v30/info.py b/aiopenapi3/v30/info.py new file mode 100644 index 0000000..0a104c4 --- /dev/null +++ b/aiopenapi3/v30/info.py @@ -0,0 +1,43 @@ +from typing import Optional + +from pydantic import Field + +from ..base import ObjectExtended + + +class Contact(ObjectExtended): + """ + Contact object belonging to an Info object, as described `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#contact-object + """ + + email: str = Field(default=None) + name: str = Field(default=None) + url: str = Field(default=None) + + +class License(ObjectExtended): + """ + License object belonging to an Info object, as described `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#license-object + """ + + name: str = Field(...) + url: Optional[str] = Field(default=None) + + +class Info(ObjectExtended): + """ + An OpenAPI Info object, as defined in `the spec`_. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#info-object + """ + + title: str = Field(...) + description: Optional[str] = Field(default=None) + termsOfService: Optional[str] = Field(default=None) + license: Optional[License] = Field(default=None) + contact: Optional[Contact] = Field(default=None) + version: str = Field(...) diff --git a/aiopenapi3/v30/media.py b/aiopenapi3/v30/media.py new file mode 100644 index 0000000..35b1c64 --- /dev/null +++ b/aiopenapi3/v30/media.py @@ -0,0 +1,42 @@ +from typing import Union, Optional, Dict, Any + +from pydantic import Field + +from ..base import ObjectExtended + +from .example import Example +from .general import Reference +from .schemas import Schema + + +class Encoding(ObjectExtended): + """ + A single encoding definition applied to a single schema property. + + .. _Encoding: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#encoding-object + """ + + contentType: Optional[str] = Field(default=None) + headers: Optional[Dict[str, Union["Header", Reference]]] = Field(default_factory=dict) + style: Optional[str] = Field(default=None) + explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) + + +class MediaType(ObjectExtended): + """ + A `MediaType`_ object provides schema and examples for the media type identified + by its key. These are used in a RequestBody object. + + .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#media-type-object + """ + + schema_: Optional[Union[Schema, Reference]] = Field(required=True, alias="schema") + example: Optional[Any] = Field(default=None) # 'any' type + examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) + encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) + + +from .parameter import Header + +Encoding.update_forward_refs() diff --git a/aiopenapi3/v30/parameter.py b/aiopenapi3/v30/parameter.py new file mode 100644 index 0000000..b90cf70 --- /dev/null +++ b/aiopenapi3/v30/parameter.py @@ -0,0 +1,57 @@ +from typing import Union, Optional, Dict, Any + +from pydantic import Field, root_validator + +from ..base import ObjectExtended + +from .example import Example +from .general import Reference +from .schemas import Schema + + +class ParameterBase(ObjectExtended): + """ + A `Parameter Object`_ defines a single operation parameter. + + .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#external-documentation-object + """ + + description: Optional[str] = Field(default=None) + required: Optional[bool] = Field(default=None) + deprecated: Optional[bool] = Field(default=None) + allowEmptyValue: Optional[bool] = Field(default=None) + + style: Optional[str] = Field(default=None) + explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) + schema_: Optional[Union[Schema, Reference]] = Field(default=None, alias="schema") + example: Optional[Any] = Field(default=None) + examples: Optional[Dict[str, Union["Example", Reference]]] = Field(default_factory=dict) + + content: Optional[Dict[str, "MediaType"]] + + @root_validator + def validate_ParameterBase(cls, values): + # if values["in_"] == + # if self.in_ == "path" and self.required is not True: + # err_msg = 'Parameter {} must be required since it is in the path' + # raise SpecError(err_msg.format(self.get_path()), path=self._path) + return values + + +class Parameter(ParameterBase): + name: str = Field(required=True) + in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] + + +class Header(ParameterBase): + """ + + .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#header-object + """ + + +from .media import MediaType + +Parameter.update_forward_refs() +Header.update_forward_refs() diff --git a/aiopenapi3/v30/paths.py b/aiopenapi3/v30/paths.py new file mode 100644 index 0000000..afbc09b --- /dev/null +++ b/aiopenapi3/v30/paths.py @@ -0,0 +1,148 @@ +from typing import Union, List, Optional, Dict, Any + +from pydantic import Field, root_validator + +from ..base import ObjectBase, ObjectExtended, PathsBase +from ..errors import SpecError +from .general import ExternalDocumentation +from .general import Reference +from .media import MediaType +from .parameter import Header, Parameter +from .servers import Server +from .security import SecurityRequirement + + +class RequestBody(ObjectExtended): + """ + A `RequestBody`_ object describes a single request body. + + .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#request-body-object + """ + + description: Optional[str] = Field(default=None) + content: Dict[str, MediaType] = Field(...) + required: Optional[bool] = Field(default=False) + + +class Link(ObjectExtended): + """ + A `Link Object`_ describes a single Link from an API Operation Response to an API Operation Request + + .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#link-object + """ + + operationRef: Optional[str] = Field(default=None) + operationId: Optional[str] = Field(default=None) + parameters: Optional[Dict[str, Union[str, Any, "RuntimeExpression"]]] = Field(default=None) + requestBody: Optional[Union[str, "RuntimeExpression"]] = Field(default=None) + description: Optional[str] = Field(default=None) + server: Optional[Server] = Field(default=None) + + @root_validator + def validate_Link_operation(cls, values): + operationId, operationRef = (values.get(i, None) for i in ["operationId", "operationRef"]) + assert not ( + operationId != None and operationRef != None + ), "operationId and operationRef are mutually exclusive, only one of them is allowed" + assert not ( + operationId == operationRef == None + ), "operationId and operationRef are mutually exclusive, one of them must be specified" + return values + + +class Response(ObjectExtended): + """ + A `Response Object`_ describes a single response from an API Operation, + including design-time, static links to operations based on the response. + + .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#responses-object + """ + + description: str = Field(...) + headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + content: Optional[Dict[str, MediaType]] = Field(default_factory=dict) + links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) + + +class Operation(ObjectExtended): + """ + An Operation object as defined `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#operation-object + """ + + tags: Optional[List[str]] = Field(default=None) + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) + operationId: Optional[str] = Field(default=None) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + requestBody: Optional[Union[RequestBody, Reference]] = Field(default=None) + responses: Dict[str, Union[Response, Reference]] = Field(...) + callbacks: Optional[Dict[str, Union["Callback", Reference]]] = Field(default_factory=dict) + deprecated: Optional[bool] = Field(default=None) + security: Optional[List[SecurityRequirement]] = Field(default_factory=list) + servers: Optional[List[Server]] = Field(default=None) + + +class PathItem(ObjectExtended): + """ + A Path Item, as defined `here`_. + Describes the operations available on a single path. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#paths-object + """ + + ref: Optional[str] = Field(default=None, alias="$ref") + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + get: Optional[Operation] = Field(default=None) + put: Optional[Operation] = Field(default=None) + post: Optional[Operation] = Field(default=None) + delete: Optional[Operation] = Field(default=None) + options: Optional[Operation] = Field(default=None) + head: Optional[Operation] = Field(default=None) + patch: Optional[Operation] = Field(default=None) + trace: Optional[Operation] = Field(default=None) + servers: Optional[List[Server]] = Field(default=None) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + + +class Paths(PathsBase): + @root_validator(pre=True) + def validate_Paths(cls, values): + assert set(values.keys()) - frozenset(["__root__"]) == set([]) + p = {} + e = {} + for k, v in values.get("__root__", {}).items(): + if k[:2] == "x-": + e[k] = v + else: + p[k] = PathItem(**v) + return {"_paths": p, "_extensions": e} + + +class Callback(ObjectBase): + """ + A map of possible out-of band callbacks related to the parent operation. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#callback-object + + This object MAY be extended with Specification Extensions. + """ + + __root__: Dict[str, PathItem] + + +class RuntimeExpression(ObjectBase): + """ + + + .. Runtime Expression: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#runtime-expressions + """ + + __root__: str = Field(...) + + +Operation.update_forward_refs() +Link.update_forward_refs() diff --git a/aiopenapi3/v30/root.py b/aiopenapi3/v30/root.py new file mode 100644 index 0000000..5f7d552 --- /dev/null +++ b/aiopenapi3/v30/root.py @@ -0,0 +1,37 @@ +from typing import Any, List, Optional, Dict + +from pydantic import Field, validator + +from ..base import ObjectExtended, RootBase + +from .components import Components +from .general import Reference +from .info import Info +from .paths import PathItem, Paths +from .security import SecurityRequirement +from .servers import Server +from .tag import Tag + + +class Root(ObjectExtended, RootBase): + """ + This class represents the root of the OpenAPI schema document, as defined + in `the spec`_ + + .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#openapi-object + """ + + openapi: str = Field(...) + info: Info = Field(...) + servers: Optional[List[Server]] = Field(default_factory=list) + paths: Paths = Field(required=True, default=None) + components: Optional[Components] = Field(default_factory=Components) + security: Optional[List[SecurityRequirement]] = Field(default=None) + tags: Optional[List[Tag]] = Field(default=None) + externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) + + def _resolve_references(self, api): + RootBase.resolve(api, self, self, PathItem, Reference) + + +Root.update_forward_refs() diff --git a/aiopenapi3/v30/schemas.py b/aiopenapi3/v30/schemas.py new file mode 100644 index 0000000..2b89c10 --- /dev/null +++ b/aiopenapi3/v30/schemas.py @@ -0,0 +1,87 @@ +from typing import Union, List, Any, Optional, Dict + +from pydantic import Field, root_validator, Extra + +from ..base import ObjectExtended, SchemaBase, DiscriminatorBase +from .general import Reference +from .xml import XML + + +class Discriminator(ObjectExtended, DiscriminatorBase): + """ + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#discriminator-object + """ + + propertyName: str = Field(...) + mapping: Optional[Dict[str, str]] = Field(default_factory=dict) + + +class Schema(ObjectExtended, SchemaBase): + """ + The `Schema Object`_ allows the definition of input and output data types. + + .. _Schema Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#schema-object + """ + + title: Optional[str] = Field(default=None) + multipleOf: Optional[int] = Field(default=None) + maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better + exclusiveMaximum: Optional[bool] = Field(default=None) + minimum: Optional[float] = Field(default=None) + exclusiveMinimum: Optional[bool] = Field(default=None) + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) + maxProperties: Optional[int] = Field(default=None) + minProperties: Optional[int] = Field(default=None) + required: Optional[List[str]] = Field(default_factory=list) + enum: Optional[list] = Field(default=None) + + type: Optional[str] = Field(default=None) + allOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + oneOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + anyOf: Optional[List[Union["Schema", Reference]]] = Field(default_factory=list) + not_: Optional[Union["Schema", Reference]] = Field(default=None, alias="not") + items: Optional[Union["Schema", Reference]] = Field(default=None) + properties: Optional[Dict[str, Union["Schema", Reference]]] = Field(default_factory=dict) + additionalProperties: Optional[Union[bool, "Schema", Reference]] = Field(default=None) + description: Optional[str] = Field(default=None) + format: Optional[str] = Field(default=None) + default: Optional[Any] = Field(default=None) + nullable: Optional[bool] = Field(default=None) + discriminator: Optional[Discriminator] = Field(default=None) # 'Discriminator' + readOnly: Optional[bool] = Field(default=None) + writeOnly: Optional[bool] = Field(default=None) + xml: Optional[XML] = Field(default=None) # 'XML' + externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' + example: Optional[Any] = Field(default=None) + deprecated: Optional[bool] = Field(default=None) + + _model_type: object + _request_model_type: object + + """ + The _identity attribute is set during OpenAPI.__init__ and used at get_type() + """ + _identity: str + + class Config: + # keep_untouched = (lru_cache,) + extra = Extra.forbid + + @root_validator + def validate_Schema_number_type(cls, values: Dict[str, object]): + conv = ["minimum", "maximum"] + if values.get("type", None) == "integer": + for i in conv: + v = values.get(i, None) + if v is not None: + values[i] = int(v) + return values + + +Schema.update_forward_refs() diff --git a/aiopenapi3/v30/security.py b/aiopenapi3/v30/security.py new file mode 100644 index 0000000..153857b --- /dev/null +++ b/aiopenapi3/v30/security.py @@ -0,0 +1,73 @@ +from typing import Optional, Dict, List + +from pydantic import Field, root_validator, BaseModel + +from ..base import ObjectExtended + + +class OAuthFlow(ObjectExtended): + """ + Configuration details for a supported OAuth Flow + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#oauth-flow-object + """ + + authorizationUrl: Optional[str] = Field(default=None) + tokenUrl: Optional[str] = Field(default=None) + refreshUrl: Optional[str] = Field(default=None) + scopes: Dict[str, str] = Field(default_factory=dict) + + +class OAuthFlows(ObjectExtended): + """ + Allows configuration of the supported OAuth Flows. + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#oauth-flows-object + """ + + implicit: Optional[OAuthFlow] = Field(default=None) + password: Optional[OAuthFlow] = Field(default=None) + clientCredentials: Optional[OAuthFlow] = Field(default=None) + authorizationCode: Optional[OAuthFlow] = Field(default=None) + + +class SecurityScheme(ObjectExtended): + """ + A `Security Scheme`_ defines a security scheme that can be used by the operations. + + .. _Security Scheme: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-scheme-object + """ + + type: str = Field(...) + description: Optional[str] = Field(default=None) + name: Optional[str] = Field(default=None) + in_: Optional[str] = Field(default=None, alias="in") + scheme_: Optional[str] = Field(default=None, alias="scheme") + bearerFormat: Optional[str] = Field(default=None) + flows: Optional[OAuthFlows] = Field(default=None) + openIdConnectUrl: Optional[str] = Field(default=None) + + @root_validator + def validate_SecurityScheme(cls, values): + t = values.get("type", None) + keys = set(map(lambda x: x[0], filter(lambda x: x[1] is not None, values.items()))) + keys -= frozenset(["type", "description", "extensions"]) + if t == "apikey": + assert keys == set(["in_", "name"]) + if t == "http": + assert keys - frozenset(["scheme_", "bearerFormat"]) == set([]) + if t == "oauth2": + assert keys == frozenset(["flows"]) + if t == "openIdConnect": + assert keys - frozenset(["openIdConnectUrl"]) == set([]) + return values + + +class SecurityRequirement(BaseModel): + """ + A `SecurityRequirement`_ object describes security schemes for API access. + + .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-requirement-object + """ + + __root__: Dict[str, List[str]] diff --git a/aiopenapi3/v30/servers.py b/aiopenapi3/v30/servers.py new file mode 100644 index 0000000..c887e7d --- /dev/null +++ b/aiopenapi3/v30/servers.py @@ -0,0 +1,29 @@ +from typing import List, Optional, Dict + +from pydantic import Field + +from ..base import ObjectExtended + + +class ServerVariable(ObjectExtended): + """ + A ServerVariable object as defined `here`_. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-variable-object + """ + + enum: Optional[List[str]] = Field(default=None) + default: str = Field(...) + description: Optional[str] = Field(default=None) + + +class Server(ObjectExtended): + """ + The Server object, as described `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-object + """ + + url: str = Field(...) + description: Optional[str] = Field(default=None) + variables: Optional[Dict[str, ServerVariable]] = Field(default_factory=dict) diff --git a/aiopenapi3/v30/tag.py b/aiopenapi3/v30/tag.py new file mode 100644 index 0000000..0dcbb2f --- /dev/null +++ b/aiopenapi3/v30/tag.py @@ -0,0 +1,19 @@ +from typing import Optional + +from pydantic import Field + +from ..base import ObjectExtended +from .general import ExternalDocumentation + + +class Tag(ObjectExtended): + """ + A `Tag Object`_ holds a reusable set of different aspects of the OAS + spec. + + .. _Tag Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#tag-object + """ + + name: str = Field(...) + description: Optional[str] = Field(default=None) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) diff --git a/aiopenapi3/v30/xml.py b/aiopenapi3/v30/xml.py new file mode 100644 index 0000000..caa8d6d --- /dev/null +++ b/aiopenapi3/v30/xml.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from .general import ObjectExtended + + +class XML(ObjectExtended): + """ + + .. XML Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#xml-object + """ + + name: str = Field(default=None) + namespace: str = Field(default=None) + prefix: str = Field(default=None) + attribute: bool = Field(default=False) + wrapped: bool = Field(default=False) diff --git a/aiopenapi3/v31/__init__.py b/aiopenapi3/v31/__init__.py new file mode 100644 index 0000000..dd88c1f --- /dev/null +++ b/aiopenapi3/v31/__init__.py @@ -0,0 +1,3 @@ +from .schemas import Schema +from .root import Root +from .general import Reference diff --git a/aiopenapi3/v31/components.py b/aiopenapi3/v31/components.py new file mode 100644 index 0000000..208cd79 --- /dev/null +++ b/aiopenapi3/v31/components.py @@ -0,0 +1,35 @@ +from typing import Union, Optional, Dict + +from pydantic import Field + +from ..base import ObjectExtended + +from .example import Example +from .paths import RequestBody, Link, Response, Callback, PathItem +from .general import Reference +from .parameter import Header, Parameter +from .schemas import Schema +from .security import SecurityScheme + + +class Components(ObjectExtended): + """ + A `Components Object`_ holds a reusable set of different aspects of the OAS + spec. + + .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#components-object + """ + + schemas: Optional[Dict[str, Union[Schema, bool]]] = Field(default_factory=dict) + responses: Optional[Dict[str, Union[Response, Reference]]] = Field(default_factory=dict) + parameters: Optional[Dict[str, Union[Parameter, Reference]]] = Field(default_factory=dict) + examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) + requestBodies: Optional[Dict[str, Union[RequestBody, Reference]]] = Field(default_factory=dict) + headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + securitySchemes: Optional[Dict[str, Union[SecurityScheme, Reference]]] = Field(default_factory=dict) + links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) + callbacks: Optional[Dict[str, Union[Callback, Reference]]] = Field(default_factory=dict) + pathItems: Optional[Dict[str, Union[PathItem, Reference]]] = Field(default_factory=dict) # v3.1 + + +Components.update_forward_refs() diff --git a/aiopenapi3/v31/example.py b/aiopenapi3/v31/example.py new file mode 100644 index 0000000..e910156 --- /dev/null +++ b/aiopenapi3/v31/example.py @@ -0,0 +1,19 @@ +from typing import Optional, Any + +from pydantic import Field + +from ..base import ObjectExtended + + +class Example(ObjectExtended): + """ + A `Example Object`_ holds a reusable set of different aspects of the OAS + spec. + + .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#exampleObject + """ + + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + value: Optional[Any] = Field(default=None) + externalValue: Optional[str] = Field(default=None) diff --git a/aiopenapi3/v31/general.py b/aiopenapi3/v31/general.py new file mode 100644 index 0000000..9396a3a --- /dev/null +++ b/aiopenapi3/v31/general.py @@ -0,0 +1,48 @@ +from typing import Optional + +from pydantic import Field, Extra, AnyUrl + +from ..base import ObjectExtended, ObjectBase, ReferenceBase + + +class ExternalDocumentation(ObjectExtended): + """ + An `External Documentation Object`_ references external resources for extended + documentation. + + .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#external-documentation-object + """ + + url: AnyUrl = Field(...) + description: Optional[str] = Field(default=None) + + +class Reference(ObjectBase, ReferenceBase): + """ + A `Reference Object`_ designates a reference to another node in the specification. + + .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#reference-object + """ + + ref: str = Field(alias="$ref") + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + + _target: object = None + + class Config: + """This object cannot be extended with additional properties and any properties added SHALL be ignored.""" + + extra = Extra.ignore + + def __getattr__(self, item): + if item != "_target": + return getattr(self._target, item) + else: + return getattr(self, item) + + def __setattr__(self, item, value): + if item != "_target": + setattr(self._target, item, value) + else: + super().__setattr__(item, value) diff --git a/aiopenapi3/v31/info.py b/aiopenapi3/v31/info.py new file mode 100644 index 0000000..fa1c89d --- /dev/null +++ b/aiopenapi3/v31/info.py @@ -0,0 +1,54 @@ +from typing import Optional + +from pydantic import Field, AnyUrl, EmailStr, root_validator + +from aiopenapi3.base import ObjectExtended + + +class Contact(ObjectExtended): + """ + Contact object belonging to an Info object, as described `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#contactObject + """ + + email: EmailStr = Field(default=None) + name: str = Field(default=None) + url: AnyUrl = Field(default=None) + + +class License(ObjectExtended): + """ + License object belonging to an Info object, as described `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#license-object + """ + + name: str = Field(...) + identifier: Optional[str] = Field(default=None) + url: Optional[AnyUrl] = Field(default=None) + + @root_validator + def validate_License(cls, values): + + """ + A URL to the license used for the API. This MUST be in the form of a URL. The url field is mutually exclusive of the identifier field. + """ + assert not all([values.get(i, None) is not None for i in ["identifier", "url"]]) + return values + + +class Info(ObjectExtended): + """ + An OpenAPI Info object, as defined in `the spec`_. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#info-object + """ + + title: str = Field(...) + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + termsOfService: Optional[str] = Field(default=None) + contact: Optional[Contact] = Field(default=None) + license: Optional[License] = Field(default=None) + version: str = Field(...) diff --git a/aiopenapi3/v31/media.py b/aiopenapi3/v31/media.py new file mode 100644 index 0000000..2dccf89 --- /dev/null +++ b/aiopenapi3/v31/media.py @@ -0,0 +1,42 @@ +from typing import Union, Optional, Dict, Any + +from pydantic import Field + +from ..base import ObjectExtended + +from .example import Example +from .general import Reference +from .schemas import Schema + + +class Encoding(ObjectExtended): + """ + A single encoding definition applied to a single schema property. + + .. _Encoding: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#encodingObject + """ + + contentType: Optional[str] = Field(default=None) + headers: Optional[Dict[str, Union["Header", Reference]]] = Field(default_factory=dict) + style: Optional[str] = Field(default=None) + explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) + + +class MediaType(ObjectExtended): + """ + A `MediaType`_ object provides schema and examples for the media type identified + by its key. These are used in a RequestBody object. + + .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#media-type-object + """ + + schema_: Optional[Schema] = Field(required=True, alias="schema") + example: Optional[Any] = Field(default=None) # 'any' type + examples: Optional[Dict[str, Union[Example, Reference]]] = Field(default_factory=dict) + encoding: Optional[Dict[str, Encoding]] = Field(default_factory=dict) + + +from .parameter import Header + +Encoding.update_forward_refs() diff --git a/aiopenapi3/v31/parameter.py b/aiopenapi3/v31/parameter.py new file mode 100644 index 0000000..a86d49e --- /dev/null +++ b/aiopenapi3/v31/parameter.py @@ -0,0 +1,51 @@ +from typing import Union, Optional, Dict, Any + +from pydantic import Field + +from ..base import ObjectExtended + +from .example import Example +from .general import Reference +from .schemas import Schema + + +class ParameterBase(ObjectExtended): + """ + A `Parameter Object`_ defines a single operation parameter. + + .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#parameterObject + """ + + description: Optional[str] = Field(default=None) + required: Optional[bool] = Field(default=None) + deprecated: Optional[bool] = Field(default=None) + allowEmptyValue: Optional[bool] = Field(default=None) + + style: Optional[str] = Field(default=None) + explode: Optional[bool] = Field(default=None) + allowReserved: Optional[bool] = Field(default=None) + schema_: Optional[Schema] = Field(default=None, alias="schema") + example: Optional[Any] = Field(default=None) + examples: Optional[Dict[str, Union["Example", Reference]]] = Field(default_factory=dict) + + content: Optional[Dict[str, "MediaType"]] + + +class Parameter(ParameterBase): + name: str = Field(required=True) + in_: str = Field(required=True, alias="in") # TODO must be one of ["query","header","path","cookie"] + + +class Header(ParameterBase): + """ + + .. _HeaderObject: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#headerObject + """ + + pass + + +from .media import MediaType + +Parameter.update_forward_refs() +Header.update_forward_refs() diff --git a/aiopenapi3/v31/paths.py b/aiopenapi3/v31/paths.py new file mode 100644 index 0000000..caacf5f --- /dev/null +++ b/aiopenapi3/v31/paths.py @@ -0,0 +1,148 @@ +from typing import Union, List, Optional, Dict, Any + +from pydantic import Field, root_validator, validator + +from ..base import ObjectBase, ObjectExtended, PathsBase +from ..errors import SpecError +from .general import ExternalDocumentation +from .general import Reference +from .media import MediaType +from .parameter import Header, Parameter +from .servers import Server +from .security import SecurityRequirement + + +class RequestBody(ObjectExtended): + """ + A `RequestBody`_ object describes a single request body. + + .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#requestBodyObject + """ + + description: Optional[str] = Field(default=None) + content: Dict[str, MediaType] = Field(...) + required: Optional[bool] = Field(default=False) + + +class Link(ObjectExtended): + """ + A `Link Object`_ describes a single Link from an API Operation Response to an API Operation Request + + .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#link-object + """ + + operationRef: Optional[str] = Field(default=None) + operationId: Optional[str] = Field(default=None) + parameters: Optional[Dict[str, Union[str, Any, "RuntimeExpression"]]] = Field(default=None) + requestBody: Optional[Union[Any, "RuntimeExpression"]] = Field(default=None) + description: Optional[str] = Field(default=None) + server: Optional[Server] = Field(default=None) + + @root_validator + def validate_Link_operation(cls, values): + operationId, operationRef = (values.get(i, None) for i in ["operationId", "operationRef"]) + assert not ( + operationId != None and operationRef != None + ), "operationId and operationRef are mutually exclusive, only one of them is allowed" + assert not ( + operationId == operationRef == None + ), "operationId and operationRef are mutually exclusive, one of them must be specified" + return values + + +class Response(ObjectExtended): + """ + A `Response Object`_ describes a single response from an API Operation, + including design-time, static links to operations based on the response. + + .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#responseObject + """ + + description: str = Field(...) + headers: Optional[Dict[str, Union[Header, Reference]]] = Field(default_factory=dict) + content: Optional[Dict[str, MediaType]] = Field(default_factory=dict) + links: Optional[Dict[str, Union[Link, Reference]]] = Field(default_factory=dict) + + +class Operation(ObjectExtended): + """ + An Operation object as defined `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#operationObject + """ + + tags: Optional[List[str]] = Field(default=None) + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + externalDocs: Optional[ExternalDocumentation] = Field(default=None) + operationId: Optional[str] = Field(default=None) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + requestBody: Optional[Union[RequestBody, Reference]] = Field(default=None) + responses: Dict[str, Union[Response, Reference]] = Field(default_factory=dict) + callbacks: Optional[Dict[str, Union["Callback", Reference]]] = Field(default_factory=dict) + deprecated: Optional[bool] = Field(default=None) + security: Optional[List[SecurityRequirement]] = Field(default_factory=list) + servers: Optional[List[Server]] = Field(default=None) + + +class PathItem(ObjectExtended): + """ + A Path Item, as defined `here`_. + Describes the operations available on a single path. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#pathItemObject + """ + + ref: Optional[str] = Field(default=None, alias="$ref") + summary: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + get: Optional[Operation] = Field(default=None) + put: Optional[Operation] = Field(default=None) + post: Optional[Operation] = Field(default=None) + delete: Optional[Operation] = Field(default=None) + options: Optional[Operation] = Field(default=None) + head: Optional[Operation] = Field(default=None) + patch: Optional[Operation] = Field(default=None) + trace: Optional[Operation] = Field(default=None) + servers: Optional[List[Server]] = Field(default=None) + parameters: Optional[List[Union[Parameter, Reference]]] = Field(default_factory=list) + + +class Paths(PathsBase): + @root_validator(pre=True) + def validate_Paths(cls, values): + assert set(values.keys()) - frozenset(["__root__"]) == set([]) + p = {} + e = {} + for k, v in values.get("__root__", {}).items(): + if k[:2] == "x-": + e[k] = v + else: + p[k] = PathItem(**v) + return {"_paths": p, "_extensions": e} + + +class Callback(ObjectBase): + """ + A map of possible out-of band callbacks related to the parent operation. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#callback-object + + This object MAY be extended with Specification Extensions. + """ + + __root__: Dict[str, PathItem] + + +class RuntimeExpression(ObjectBase): + """ + + + .. Runtime Expression: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#runtimeExpression + """ + + __root__: str = Field(...) + + +Operation.update_forward_refs() +Link.update_forward_refs() diff --git a/aiopenapi3/v31/root.py b/aiopenapi3/v31/root.py new file mode 100644 index 0000000..5cc7c78 --- /dev/null +++ b/aiopenapi3/v31/root.py @@ -0,0 +1,44 @@ +from typing import Any, List, Optional, Dict, Union + +from pydantic import Field, root_validator, validator + +from ..base import ObjectExtended, RootBase + +from .info import Info +from .paths import Paths, PathItem +from .security import SecurityRequirement +from .servers import Server + +from .components import Components +from .general import Reference +from .tag import Tag + + +class Root(ObjectExtended, RootBase): + """ + This class represents the root of the OpenAPI schema document, as defined + in `the spec`_ + + .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object + """ + + openapi: str = Field(...) + info: Info = Field(...) + jsonSchemaDialect: Optional[str] = Field(default=None) # FIXME should be URI + servers: Optional[List[Server]] = Field(default=None) + paths: Paths = Field(default_factory=Paths) + webhooks: Optional[Dict[str, Union[PathItem, Reference]]] = Field(required=False) + components: Optional[Components] = Field(default=None) + security: Optional[List[SecurityRequirement]] = Field(default=None) + tags: Optional[List[Tag]] = Field(default=None) + externalDocs: Optional[Dict[Any, Any]] = Field(default_factory=dict) + + def validate_Root(cls, values): + assert any([values.get(i) is not None for i in ["paths", "components", "webhooks"]]), values + return values + + def _resolve_references(self, api): + RootBase.resolve(api, self, self, PathItem, Reference) + + +Root.update_forward_refs() diff --git a/aiopenapi3/v31/schemas.py b/aiopenapi3/v31/schemas.py new file mode 100644 index 0000000..ec6c4b8 --- /dev/null +++ b/aiopenapi3/v31/schemas.py @@ -0,0 +1,176 @@ +from typing import Union, List, Any, Optional, Dict + +from pydantic import Field, root_validator, Extra + +from ..base import ObjectExtended, SchemaBase, DiscriminatorBase +from .general import Reference +from .xml import XML + + +class Discriminator(ObjectExtended, DiscriminatorBase): + """ + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#discriminator-object + """ + + propertyName: str = Field(...) + mapping: Optional[Dict[str, str]] = Field(default_factory=dict) + + +class Schema(ObjectExtended, SchemaBase): + """ + The `Schema Object`_ allows the definition of input and output data types. + + .. _Schema Object: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#section-6 + """ + + """ + 6.1. Validation Keywords for Any Instance Type + """ + + type: Optional[Union[str, List[str]]] = Field(default=None) + enum: Optional[List[str]] = Field(default=None) + const: Optional[str] = Field(default=None) + + """ + 6.2. Validation Keywords for Numeric Instances (number and integer) + """ + multipleOf: Optional[int] = Field(default=None) + maximum: Optional[float] = Field(default=None) # FIXME Field(discriminator='type') would be better + exclusiveMaximum: Optional[int] = Field(default=None) + minimum: Optional[float] = Field(default=None) + exclusiveMinimum: Optional[int] = Field(default=None) + + """ + 6.3. Validation Keywords for Strings + """ + maxLength: Optional[int] = Field(default=None) + minLength: Optional[int] = Field(default=None) + pattern: Optional[str] = Field(default=None) + + """ + 6.4. Validation Keywords for Arrays + """ + maxItems: Optional[int] = Field(default=None) + minItems: Optional[int] = Field(default=None) + uniqueItems: Optional[bool] = Field(default=None) + maxContains: Optional[int] = Field(default=None) + minContains: Optional[int] = Field(default=None) + + """ + 6.5. Validation Keywords for Objects + """ + maxProperties: Optional[int] = Field(default=None) + minProperties: Optional[int] = Field(default=None) + required: Optional[List[str]] = Field(default_factory=list) + dependentRequired: Dict[str, str] = Field(default_factory=dict) # FIXME + + """ + 7. A Vocabulary for Semantic Content With "format" + """ + format: Optional[str] = Field(default=None) + + """ + 8. A Vocabulary for the Contents of String-Encoded Data + """ + contentEncoding: Optional[str] = Field(default=None) + contentMediaType: Optional[str] = Field(default=None) + contentSchema: Optional[str] = Field(default=None) + + """ + 9. A Vocabulary for Basic Meta-Data Annotations + """ + title: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + default: Optional[Any] = Field(default=None) + deprecated: Optional[bool] = Field(default=None) + readOnly: Optional[bool] = Field(default=None) + writeOnly: Optional[bool] = Field(default=None) + examples: Optional[Any] = Field(default=None) + + """ + https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-02 + """ + + """ + 8. The JSON Schema Core Vocabulary + """ + id: Optional[str] = Field(default=None, alias="$id") + schema_: Optional[str] = Field(default=None, alias="$schema") + anchor: Optional[str] = Field(default=None, alias="$anchor") + + """ + 8.2.4. Schema References + """ + ref: Optional[str] = Field(default=None, alias="$ref") + recursiveRef: Optional[str] = Field(default=None, alias="$recursiveRef") + recursiveAnchor: Optional[bool] = Field(default=None, alias="$recursiveAnchor") + + vocabulary: Optional[Dict[str, bool]] = Field(default=None, alias="$vocabulary") + comment: Optional[str] = Field(default=None, alias="$comment") + defs: Optional[Dict[str, Any]] = Field(default=None, alias="$defs") + + """ + https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-02#section-9.2 + 9.2. Keywords for Applying Subschemas in Place + """ + allOf: Optional[List["Schema"]] = Field(default_factory=list) + oneOf: Optional[List["Schema"]] = Field(default_factory=list) + anyOf: Optional[List["Schema"]] = Field(default_factory=list) + not_: Optional["Schema"] = Field(default=None, alias="not") + + """ + 9.2.2. Keywords for Applying Subschemas Conditionally + """ + if_: Optional["Schema"] = Field(default=None, alias="if") + then_: Optional["Schema"] = Field(default=None, alias="then") + else_: Optional["Schema"] = Field(default=None, alias="else") + dependentSchemas: Optional[Dict[str, "Schema"]] = Field(default_factory=dict) + + """ + 9.3.1. Keywords for Applying Subschemas to Arrays + """ + items: Optional[Union["Schema", List["Schema"]]] = Field(default=None) + additionalItem: Optional["Schema"] = Field(default=None) + unevaluatedItems: Optional["Schema"] = Field(default=None) + contains: Optional["Schema"] = Field(default=None) + + """ + 9.3.2. Keywords for Applying Subschemas to Objects + """ + properties: Optional[Dict[str, "Schema"]] = Field(default_factory=dict) + patternProperties: Optional[Dict[str, str]] = Field(default_factory=dict) + additionalProperties: Optional[Union[bool, "Schema"]] = Field(default=None) + unevaluatedProperties: Optional["Schema"] = Field(default=None) + propertyNames: Optional["Schema"] = Field(default=None) + + """ + The OpenAPI Specification's base vocabulary is comprised of the following keywords: + """ + discriminator: Optional[Discriminator] = Field(default=None) # 'Discriminator' + xml: Optional[XML] = Field(default=None) # 'XML' + externalDocs: Optional[dict] = Field(default=None) # 'ExternalDocs' + example: Optional[Any] = Field(default=None) + + _model_type: object + + """ + The _identity attribute is set during OpenAPI.__init__ and used at get_type() + """ + _identity: str + + class Config: + extra = Extra.allow + + @root_validator + def validate_Schema_number_type(cls, values: Dict[str, object]): + conv = ["minimum", "maximum"] + if values.get("type", None) == "integer": + for i in conv: + v = values.get(i, None) + if v is not None: + values[i] = int(v) + return values + + +Schema.update_forward_refs() diff --git a/aiopenapi3/v31/security.py b/aiopenapi3/v31/security.py new file mode 100644 index 0000000..e2d3ffc --- /dev/null +++ b/aiopenapi3/v31/security.py @@ -0,0 +1,73 @@ +from typing import Optional, Dict, List + +from pydantic import Field, root_validator, BaseModel + +from ..base import ObjectExtended + + +class OAuthFlow(ObjectExtended): + """ + Configuration details for a supported OAuth Flow + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#oauth-flow-object + """ + + authorizationUrl: Optional[str] = Field(default=None) + tokenUrl: Optional[str] = Field(default=None) + refreshUrl: Optional[str] = Field(default=None) + scopes: Dict[str, str] = Field(default_factory=dict) + + +class OAuthFlows(ObjectExtended): + """ + Allows configuration of the supported OAuth Flows. + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#oauth-flows-object + """ + + implicit: Optional[OAuthFlow] = Field(default=None) + password: Optional[OAuthFlow] = Field(default=None) + clientCredentials: Optional[OAuthFlow] = Field(default=None) + authorizationCode: Optional[OAuthFlow] = Field(default=None) + + +class SecurityScheme(ObjectExtended): + """ + A `Security Scheme`_ defines a security scheme that can be used by the operations. + + .. _Security Scheme: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#security-scheme-object + """ + + type: str = Field(...) + description: Optional[str] = Field(default=None) + name: Optional[str] = Field(default=None) + in_: Optional[str] = Field(default=None, alias="in") + scheme_: Optional[str] = Field(default=None, alias="scheme") + bearerFormat: Optional[str] = Field(default=None) + flows: Optional[OAuthFlows] = Field(default=None) + openIdConnectUrl: Optional[str] = Field(default=None) + + @root_validator + def validate_SecurityScheme(cls, values): + t = values.get("type", None) + keys = set(map(lambda x: x[0], filter(lambda x: x[1] is not None, values.items()))) + keys -= frozenset(["type", "description", "extensions"]) + if t == "apikey": + assert keys == set(["in_", "name"]) + if t == "http": + assert keys - frozenset(["scheme_", "bearerFormat"]) == set([]) + if t == "oauth2": + assert keys == frozenset(["flows"]) + if t == "openIdConnect": + assert keys - frozenset(["openIdConnectUrl"]) == set([]) + return values + + +class SecurityRequirement(BaseModel): + """ + A `SecurityRequirement`_ object describes security schemes for API access. + + .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#securityRequirementObject + """ + + __root__: Dict[str, List[str]] diff --git a/aiopenapi3/v31/servers.py b/aiopenapi3/v31/servers.py new file mode 100644 index 0000000..c3faf84 --- /dev/null +++ b/aiopenapi3/v31/servers.py @@ -0,0 +1,39 @@ +from ..v30.servers import Server + +from typing import List, Optional, Dict + +from pydantic import Field, root_validator + +from ..base import ObjectExtended + + +class ServerVariable(ObjectExtended): + """ + A ServerVariable object as defined `here`_. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#server-variable-object + """ + + enum: Optional[List[str]] = Field(default=None) + default: str = Field(...) + description: Optional[str] = Field(default=None) + + @root_validator + def validate_ServerVariable(cls, values): + assert isinstance(values.get("enum", None), (list, None.__class__)) + + # default value must be in enum + assert values.get("default", None) in values.get("enum", [None]) + return values + + +class Server(ObjectExtended): + """ + The Server object, as described `here`_ + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#server-object + """ + + url: str = Field(...) + description: Optional[str] = Field(default=None) + variables: Optional[Dict[str, ServerVariable]] = Field(default_factory=dict) diff --git a/aiopenapi3/v31/tag.py b/aiopenapi3/v31/tag.py new file mode 100644 index 0000000..d5c343c --- /dev/null +++ b/aiopenapi3/v31/tag.py @@ -0,0 +1 @@ +from ..v30.tag import Tag diff --git a/aiopenapi3/v31/xml.py b/aiopenapi3/v31/xml.py new file mode 100644 index 0000000..3c955ad --- /dev/null +++ b/aiopenapi3/v31/xml.py @@ -0,0 +1 @@ +from ..v30.xml import XML diff --git a/aiopenapi3/version.py b/aiopenapi3/version.py new file mode 100644 index 0000000..b3f4756 --- /dev/null +++ b/aiopenapi3/version.py @@ -0,0 +1 @@ +__version__ = "0.1.2" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f6cb415 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version: "3.3" +services: + aiopenapi3-container: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/app diff --git a/openapi3/__init__.py b/openapi3/__init__.py deleted file mode 100644 index 79e2a2a..0000000 --- a/openapi3/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .openapi import OpenAPI - -# these imports appear unused, but in fact load up the subclasses ObjectBase so -# that they may be referenced throughout the schema without issue -from . import info, servers, paths, general, schemas, components, security, tag, example -from .errors import SpecError, ReferenceResolutionError - -__all__ = ["OpenAPI", "SpecError", "ReferenceResolutionError"] diff --git a/openapi3/__main__.py b/openapi3/__main__.py deleted file mode 100644 index a8e2267..0000000 --- a/openapi3/__main__.py +++ /dev/null @@ -1,29 +0,0 @@ -import sys -import yaml - -from .openapi import OpenAPI - - -def main(): - specfile = sys.argv[1] - - with open(specfile) as f: - spec = yaml.safe_load(f.read()) - - o = OpenAPI(spec, validate=True) - - errors = o.errors() - - if errors: - # print errors - for e in errors: - print("{}: {}".format(".".join(e.path), e.message[:300])) - print() - print("{} errors".format(len(errors))) - sys.exit(1) # exit with error status - else: - print("OK") - - -if __name__ == "__main__": - main() diff --git a/openapi3/components.py b/openapi3/components.py deleted file mode 100644 index f489550..0000000 --- a/openapi3/components.py +++ /dev/null @@ -1,36 +0,0 @@ -from .object_base import ObjectBase - - -class Components(ObjectBase): - """ - A `Components Object`_ holds a reusable set of different aspects of the OAS - spec. - - .. _Components Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#componentsObject - """ - - __slots__ = [ - "schemas", - "responses", - "parameters", - "examples", - "headers", - "requestBodies", - "securitySchemes", - "links", - "callback", - ] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.examples = self._get("examples", ["Example", "Reference"], is_map=True) - self.parameters = self._get("parameters", ["Parameter", "Reference"], is_map=True) - self.requestBodies = self._get("requestBody", ["RequestBody", "Reference"], is_map=True) - self.responses = self._get("responses", ["Response", "Reference"], is_map=True) - self.schemas = self._get("schemas", ["Schema", "Reference"], is_map=True) - self.securitySchemes = self._get("securitySchemes", ["SecurityScheme", "Reference"], is_map=True) - # self.headers = self._get('headers', ['Header', 'Reference'], is_map=True) - self.links = self._get("links", ["Link", "Reference"], is_map=True) - # self.callbacks = self._get('callbacks', ['Callback', 'Reference'], is_map=True) diff --git a/openapi3/errors.py b/openapi3/errors.py deleted file mode 100644 index 3edbbab..0000000 --- a/openapi3/errors.py +++ /dev/null @@ -1,21 +0,0 @@ -class SpecError(ValueError): - """ - This error class is used when an invalid format is found while parsing an - object in the spec. - """ - - def __init__(self, message, path=None, element=None): - self.element = element - self.message = message - self.path = path - - -class ReferenceResolutionError(SpecError): - """ - This error class is used when resolving a reference fails, usually because - of a malformed path in the reference. - """ - - -class ModelError(ValueError): - """The data supplied to the Model mismatches the models attributes""" diff --git a/openapi3/example.py b/openapi3/example.py deleted file mode 100644 index ea9a959..0000000 --- a/openapi3/example.py +++ /dev/null @@ -1,21 +0,0 @@ -from .object_base import ObjectBase - - -class Example(ObjectBase): - """ - A `Example Object`_ holds a reusable set of different aspects of the OAS - spec. - - .. _Example Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#exampleObject - """ - - __slots__ = ["summary", "description", "value", "externalValue"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.summary = self._get("summary", str) - self.description = self._get("description", str) - self.value = self._get("value", ["Reference", dict, str]) # 'any' type - self.externalValue = self._get("externalValue", str) diff --git a/openapi3/general.py b/openapi3/general.py deleted file mode 100644 index dd453a6..0000000 --- a/openapi3/general.py +++ /dev/null @@ -1,43 +0,0 @@ -from .object_base import ObjectBase - - -class ExternalDocumentation(ObjectBase): - """ - An `External Documentation Object`_ references external resources for extended - documentation. - - .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#externalDocumentationObject - """ - - __slos__ = ["description", "url"] - required_fields = "url" - - def _parse_data(self): - self.description = self._get("description", str) - self.url = self._get("url", str) - - -class Reference(ObjectBase): - """ - A `Reference Object`_ designates a reference to another node in the specification. - - .. _Reference Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#referenceObject - """ - - # can't start a variable name with a $ - __slots__ = ["ref"] - required_fields = ["$ref"] - - def _parse_data(self): - self.ref = self._get("$ref", str) - - @classmethod - def can_parse(cls, dct): - """ - Override ObjectBase.can_parse because we had to remove the $ from $ref - in __slots__ (since that's not a valid python variable name) - """ - # TODO - can a reference object have spec extensions? - cleaned_keys = [k for k in dct.keys() if not k.startswith("x-")] - - return len(cleaned_keys) == 1 and "$ref" in dct diff --git a/openapi3/info.py b/openapi3/info.py deleted file mode 100644 index 4bc332f..0000000 --- a/openapi3/info.py +++ /dev/null @@ -1,60 +0,0 @@ -from .object_base import ObjectBase - - -class Info(ObjectBase): - """ - An OpenAPI Info object, as defined in `the spec`_. - - .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#infoObject - """ - - __slots__ = ["title", "description", "termsOfService", "contact", "license", "version"] - required_fields = ["title", "version"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.contact = self._get("contact", "Contact") - self.description = self._get("description", str) - self.license = self._get("license", "License") - self.termsOfService = self._get("termsOfService", str) - self.title = self._get("title", str) - self.version = self._get("version", str) - - -class Contact(ObjectBase): - """ - Contact object belonging to an Info object, as described `here`_ - - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#contactObject - """ - - __slots__ = ["name", "url", "email"] - required_fields = ["name", "url", "email"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.email = self._get("email", str) - self.name = self._get("name", str) - self.url = self._get("url", str) - - -class License(ObjectBase): - """ - License object belonging to an Info object, as described `here`_ - - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#license-object - """ - - __slots__ = ["name", "url"] - required_fields = ["name"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.name = self._get("name", str) - self.url = self._get("url", str) diff --git a/openapi3/object_base.py b/openapi3/object_base.py deleted file mode 100644 index af8f1c6..0000000 --- a/openapi3/object_base.py +++ /dev/null @@ -1,684 +0,0 @@ -import sys - -from .errors import SpecError, ReferenceResolutionError - -IS_PYTHON_2 = False -if sys.version_info[0] == 2: - IS_PYTHON_2 = True -else: - # unicode was removed in python3, but we need to support both here, so define - # it in python 3 only - unicode = str - - -def _asdict(x): - if hasattr(x, "__getstate__"): - return x.__getstate__() - elif isinstance(x, dict): - return {k: _asdict(v) for k, v in x.items()} - elif isinstance(x, (list, tuple, set)): - return x.__class__(_asdict(y) for y in x) - else: - return x - - -def raise_on_unknown_type(parent, field, object_types, found): - """ - Raises a SpecError describing a situation where an unknown type was given. - This function attempts to produce as useful an error as possible based on the - type of types that were expected. - - :param parent: The parent element who was attempting to parse the field - :type parent: Subclass of ObjectBase - :param field: The field we were trying to parse - :type field: str - :param object_types: The types allowed for this field - :type object_types: List of str or Class - :param found: The value that was found (and did not match any expected type) - :type found: any - - :raises: A SpecError describing the failure - """ - if len(object_types) == 1: - if isinstance(object_types[0], str): - expected_type = ObjectBase.get_object_type(object_types[0]) - raise SpecError( - "Expected {}.{} to be of type {}, with required fields {}".format( - parent.get_path(), - field, - object_types[0], - expected_type.required_fields, - ), - path=parent.path, - element=parent, - ) - elif ( - len(object_types) == 2 - and len([c for c in object_types if isinstance(c, str)]) == 2 - and "Reference" in object_types - ): - # we can give a similar error here as above - expected_type_str = [c for c in object_types if c != "Reference"][0] - expected_type = ObjectBase.get_object_type(expected_type_str) - raise SpecError( - "Expected {}.{} to be of type {} or Reference, but did not find required fields {} or '$ref'".format( - parent.get_path(), - field, - expected_type_str, - expected_type.required_fields, - ), - path=parent.path, - element=parent, - ) - raise SpecError( - "Expected {}.{} to be one of [{}], got {}".format( - parent.get_path(), - field, - ",".join( - [str(c) for c in object_types], - ), - type(found), - ), - path=parent.path, - element=parent, - ) - - -class ObjectBase(object): - """ - The base class for all schema objects. Includes helpers for common schema- - related functions. - """ - - __slots__ = ["path", "raw_element", "_accessed_members", "strict", "_root", "extensions", "_original_ref"] - required_fields = [] - - def __init__(self, path, raw_element, root): - """ - Creates a new Object for a OpenAPI schema with a reference to its own - path in the schema. - - :param path: The path to this element in the spec. - :type path: list[str] - :param raw_element: The raw element parsed from the spec that this object - is parsing. - :type raw_element: dict - :param root: The root of the spec, for reference - :type root: OpenAPI - """ - # init empty slots - for k in type(self).__slots__: - if k in ("_spec_errors", "validation_mode"): - # allow these two fields to keep their values - continue - setattr(self, k, None) - - self.path = path - self.raw_element = raw_element - self._root = root - - self._accessed_members = [] - self.extensions = {} - - # TODO - add strict mode that errors if all members were not accessed - self.strict = False - - # parse our own element - try: - self._required_fields(*type(self).required_fields) - self._parse_data() - except SpecError as e: - if self._root.validation_mode: - self._root.log_spec_error(e) - else: - raise - - # TODO - this may not be appropriate in all cases - self._parse_spec_extensions() - - # TODO - assert that all keys of raw_element were accessed - - def __repr__(self): - """ - Returns a string representation of the parsed object - """ - # TODO - why? - return "<{} {}>".format(type(self), self.path) - - def __getstate__(self): - """ - Returns this object as a dict, removing all empty keys. This can be used - to serialize a spec. - - Allows pickling objects by returning a dict of all slotted values. - """ - return _asdict({k: getattr(self, k) for k in type(self).__slots__ if hasattr(self, k)}) - - def __setstate__(self, state): - """ - Allows unpickling objects - """ - for k, v in state.items(): - setattr(self, k, v) - - def _required_fields(self, *fields): - """ - Given a list of require fields for this object, raises a SpecError if any - of the fields do not exist. - - :param *fields: A list of fields to ensure exist in this object - :type *fields: str - - :raises SpecError: if any of the required fields are not present. - """ - missing_fields = [] - for field in fields: - if field not in self.raw_element: - missing_fields.append(field) - - if missing_fields: - raise SpecError( - "Missing required fields: {}".format(", ".join(missing_fields)), path=self.path, element=self - ) - - def _parse_data(self): - """ - Parses the raw_element into this object. This is not implemented here, - but is called in the constructor and _must_ be implemented in all - subclasses. - - An implementation of this method should use :any:`_get` to retrieve - values from the raw_element, which has the side-effect of noting that - those members were accessed. After this is executed, spec extensions - are parsed and then an assertion is made that all keys in the - raw_element were accessed - if not, the schema is considered invalid. - """ - raise NotImplementedError("You must implement this method in subclasses!") - - def _get(self, field, object_types, is_list=False, is_map=False): - """ - Retrieves a value from this object's raw element, and returns None if - it is not present. Use :any:`_required_fields` to ensure all required - fields are present before depending on the output of this method. - - :param field: The field name to retrieve - :type field: str - :param object_types: The types of Objects that are accepted. One of - these types will be returned, or the spec will be - considered invalid. If the magic string '*' is - passed in, it must be the only accepted type, and - all types will be accepted. - :type object_types: list[str or Type] - :param is_list: If true, this should return a List of object of the give - types. - :param is_list: bool - :param is_map: If true, this must return a :any:`Map` of object of the given - types - :type is_map: bool - - :returns: object_type if given, otherwise the type parsed from the spec - file - """ - self._accessed_members.append(field) - - ret = self.raw_element.get(field, None) - if ret is None: - return None - - try: - if not isinstance(object_types, list): - # maybe don't accept not-lists - object_types = [object_types] - - if "*" in object_types and len(object_types) != 1: - raise ValueError("Fields that accept any type must not specify any other types!") - - # if yaml loads a value that includes a unicode character in python2, - # that value will come in as a ``unicode`` type instead of a ``str``. - # For the purposes of this library, those are the same thing, so in - # python2 only, we'll include ``unicode`` for any element that - # accepts ``str`` types. - if IS_PYTHON_2: - if str in object_types: - object_types += [unicode] - - if is_list: - if not isinstance(ret, list): - raise SpecError( - "Expected {}.{} to be a list of [{}], got {}".format( - self.get_path, field, ",".join([str(c) for c in object_types]), type(ret) - ), - path=self.path, - element=self, - ) - ret = self.parse_list(ret, object_types, field) - elif is_map: - if not isinstance(ret, dict): - raise SpecError( - "Expected {}.{} to be a Map of string: [{}], got {}".format( - self.get_path, field, ",".join([str(c) for c in object_types]), type(ret) - ), - path=self.path, - element=self, - ) - ret = Map(self.path + [field], ret, object_types, self._root) - else: - accepts_string = str in object_types - found_type = False - - for t in object_types: - if t == "*": - found_type = True - break - - if t == str: - # try to parse everything else first - continue - - if isinstance(t, str): - # we were given the name of a subclass of ObjectBase, - # attempt to parse ret as that type - python_type = ObjectBase.get_object_type(t) - - if python_type.can_parse(ret): - ret = python_type(self.path + [field], ret, self._root) - found_type = True - break - elif isinstance(ret, t): - # it's already the type we need - found_type = True - break - - if not found_type: - if accepts_string and isinstance(ret, str): - found_type = True - - if not found_type: - raise_on_unknown_type(self, field, object_types, ret) - except SpecError as e: - if self._root.validation_mode: - self._root.log_spec_error(e) - ret = None - else: - raise - - return ret - - @classmethod - def key_contained(cls, key, target_list): - """ - Returns whether the key is contained in the given list or not. - We use this specific function as to prevent usage of keywords we add "_" - to several parameters, and we still want to validate those parameters - """ - if key.endswith("_"): - extra_key = key[:-1] - else: - extra_key = key + "_" - return key in target_list or extra_key in target_list - - @classmethod - def can_parse(cls, dct): - """ - Returns True if this class can parse the given dict. This is based on - the __slots__ and required_fields of the class, and the keys of the dict. - This is intended to be used when an element may be one of a number of - allowed Object types - each type should independently consider if it - can parse the given element, and the first to report that it can should - be used. - - :param dct: The dict to consider. - :type dct: dict - - :returns: True if this class can parse dct into an instance of itself, - otherwise False - :rtype: bool - """ - # if this isn't a dict, the spec is dreadfully wrong (and since no type - # will be able to parse this value, an appropriate error is returned) - if not isinstance(dct, dict): - return False - # ensure that the dict's keys are valid in our slots - for key in dct.keys(): - if key.startswith("x-"): - # ignore spec extensions - continue - - if not cls.key_contained(key, cls.__slots__): - # it has something we don't - probably not a match - return False - - # then, ensure that all required fields are present - for key in cls.required_fields: - if not cls.key_contained(key, dct): - # it doesn't have everything we need - probably not a match - return False - - return True - - def _parse_spec_extensions(self): - """ - Examines the keys of this Object's raw_element and collects any `Specification - Extensions`_ into the extensions attribute of this Object. - - .. _Specification Extensions: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#specificationExtensions - """ - for k, v in self.raw_element.items(): - if k.startswith("x-"): - self.extensions[k[2:]] = v - self._accessed_members.append(k) - - def _clone(self): - """ - Returns a copy of this object - """ - cls = self.__class__ - inst = cls.__new__(cls) - - for c in self.__slots__: - val = getattr(self, c) - if issubclass(type(val), ObjectBase) or isinstance(val, Map): - val = val._clone() - elif isinstance(val, list): - new_val = [] - for cur in val: - if issubclass(type(cur), ObjectBase) or isinstance(cur, Map): - new_val.append(cur._clone()) - else: - new_val.append(cur) - val = new_val - - setattr(inst, c, val) - - for c in ObjectBase.__slots__: - if hasattr(self, c): - setattr(inst, c, getattr(self, c)) - - return inst - - @classmethod - def get_object_type(cls, typename): - """ - Introspects the subclasses of this class to decide which to return for - object_type - - :param object_type: The name of a class that inherits from this class. - Must exactly equal type(Class).__name__ - :type object_type: str - - :returns: The Type associated with this name - :raises ValueError: if no Type with that name was found - """ - if not hasattr(cls, "_subclass_map"): - # generate subclass map on first call - setattr(cls, "_subclass_map", {t.__name__: t for t in cls.__subclasses__()}) - - # TODO - why? - if typename not in cls._subclass_map: # pylint: disable=no-member - raise ValueError("ObjectBase has no subclass {}".format(typename)) - - return cls._subclass_map[typename] # pylint: disable=no-member - - def get_path(self): - """ - Get the full path for this element in the spec - - :returns: The path in the spec for this element - :rtype: str - """ - return ".".join(self.path) - - def parse_list(self, raw_list, object_types, field=None): - """ - Given a list of Objects, iterates over the list and creates the relevant - Objects, returning the resulting list. - - :param raw_list: The list to parse - :type raw_list: list[dict] - :param object_types: A list of subclass names to attempt to parse the - objects to. The list does not need to consist of - only one of these types. - :type object_type: list[str] - :param field: The field to append to self.get_path() when determining path - for created objects. - :type field: str - - :returns: A list of parsed objects - :rtype: list[object_type] - """ - if raw_list is None: - return None - - if not isinstance(object_types, list): - object_types = [object_types] - - real_path = self.path[:] - if field: - real_path += [field] - - python_types = [ObjectBase.get_object_type(t) if isinstance(t, str) else t for t in object_types] - - result = [] - for i, cur in enumerate(raw_list): - found_type = False - - for cur_type in python_types: - if issubclass(cur_type, ObjectBase) and cur_type.can_parse(cur): - result.append(cur_type(real_path + [str(i)], cur, self._root)) - found_type = True - continue - elif isinstance(cur, cur_type): - result.append(cur) - found_type = True - continue - - if not found_type: - raise SpecError( - "Could not parse {}.{}, expected to be one of [{}]".format(".".join(real_path), i, object_types), - path=self.path, - element=self, - ) - - return result - - def _resolve_references(self): - """ - Resolves all reference objects below this object and notes their original - value was a reference. - """ - # don't circular import - reference_type = ObjectBase.get_object_type("Reference") - - for slot in self.__slots__: - if slot.startswith("_"): - # don't parse private members - continue - value = getattr(self, slot) - - if isinstance(value, reference_type): - # we found a reference - attempt to resolve it - reference_path = value.ref - if not reference_path.startswith("#/"): - raise ReferenceResolutionError( - "Invalid reference path {}".format(reference_path), path=self.path, element=self - ) - - reference_path = reference_path.split("/")[1:] - - try: - resolved_value = self._root.resolve_path(reference_path) - except ReferenceResolutionError as e: - # add metadata to the error - e.path = self.path - e.element = self - raise - - resolved_value._original_ref = value - - # resolved - setattr(self, slot, resolved_value) - elif issubclass(type(value), ObjectBase) or isinstance(value, Map): - # otherwise, continue resolving down the tree - value._resolve_references() - elif isinstance(value, list): - # if it's a list, resolve its item's references - resolved_list = [] - for item in value: - if isinstance(item, reference_type): - # TODO - this is duplicated code - reference_path = item.ref.split("/")[1:] - - try: - resolved_value = self._root.resolve_path(reference_path) - except ReferenceResolutionError as e: - # add metadata to the error - e.path = self.path - e.element = self - raise - - resolved_value._original_ref = value - resolved_list.append(resolved_value) - else: - if issubclass(type(item), ObjectBase) or isinstance(item, Map): - item._resolve_references() - resolved_list.append(item) - - setattr(self, slot, resolved_list) - - def _resolve_allOfs(self): - """ - Walks object tree calling _resolve_allOf on each type. - - Types can override this to handle allOf handling themselves. Types that - do so should call the parent class' _resolve_allOf when they do - """ - for slot in self.__slots__: - if slot.startswith("_"): - # no need to handle private members - continue - - value = getattr(self, slot) - - if issubclass(type(value), ObjectBase): - value._resolve_allOfs() - elif issubclass(type(value), Map): - for _, c in value.items(): - c._resolve_allOfs() - elif isinstance(value, list): - for c in value: - if issubclass(type(c), ObjectBase) or issubclass(type(c), Map): - c._resolve_allOfs() - - -class Map(dict): - """ - The Map object wraps a python dict and parses its values into the chosen - type or types. - """ - __slots__ = ['path', 'raw_element', '_root'] - - def __init__(self, path, raw_element, object_types, root): - """ - Creates a dict containing the parsed objects from the raw element - - :param path: The path to this Map in the spec. - :type path: list - :param raw_element: The raw spec data for this map. The keys must all - be strings. - :type raw_element: dict - :param object_types: A list of strings accepted by - :any:`ObjectBase.get_object_type`, or the python - types to parse. - :type object_types: list[str or Type] - """ - self.path = path - self.raw_element = raw_element - self._root = root - - python_types = [] - dct = {} - - for t in object_types: - if isinstance(t, str): - python_types.append(ObjectBase.get_object_type(t)) - else: - python_types.append(t) - - for k, v in self.raw_element.items(): - found_type = False - - for t in python_types: - if issubclass(t, ObjectBase) and t.can_parse(v): - dct[k] = t(path + [k], v, self._root) - found_type = True - elif isinstance(v, t): - dct[k] = v - found_type = True - - if not found_type: - raise_on_unknown_type(self, k, object_types, v) - - self.update(dct) - - def _resolve_references(self): - """ - This has been added to allow propagation of reference resolution as defined - in :any:`ObjectBase._resolve_references`. This implementation simply - calls the same on all values in this Map. - """ - reference_type = ObjectBase.get_object_type("Reference") - - for key, value in self.items(): - if isinstance(value, reference_type): - # TODO - this is repeated code - # we found a reference - attempt to resolve it - reference_path = value.ref - if not reference_path.startswith("#/"): - raise ReferenceResolutionError( - "Invalid reference path {}".format(reference_path), path=self.path, element=self - ) - - reference_path = reference_path.split("/")[1:] - - try: - resolved_value = self._root.resolve_path(reference_path) - except ReferenceResolutionError as e: - # add metadata to the error - e.path = self.path - e.element = self - raise - - resolved_value._original_ref = value - - # resolved - self[key] = resolved_value - else: - value._resolve_references() - - def _clone(self): - """ - Returns a copy of this object and all its values - """ - ret = Map.__new__(self.__class__) - - for c in self.__slots__: - setattr(ret, c, getattr(self, c)) - - dct = {} - for k, v in self.items(): - if issubclass(type(v), ObjectBase) or isinstance(v, Map): - dct[k] = v._clone() - else: - dct[k] = v - - ret.update(dct) - return ret - - def get_path(self): - """ - Get the full path for this element in the spec - - :returns: The path in the spec for this element - :rtype: str - """ - return ".".join(self.path) diff --git a/openapi3/openapi.py b/openapi3/openapi.py deleted file mode 100644 index 5b79b14..0000000 --- a/openapi3/openapi.py +++ /dev/null @@ -1,242 +0,0 @@ -import requests - -from .object_base import ObjectBase, Map -from .errors import ReferenceResolutionError, SpecError - - -class OpenAPI(ObjectBase): - """ - This class represents the root of the OpenAPI schema document, as defined - in `the spec`_ - - .. _the spec: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#openapi-object - """ - - __slots__ = [ - "openapi", - "info", - "servers", - "paths", - "components", - "security", - "tags", - "externalDocs", - "_operation_map", - "_security", - "validation_mode", - "_spec_errors", - "_ssl_verify", - "_session", - ] - required_fields = ["openapi", "info", "paths"] - - def __init__( - self, raw_document, validate=False, ssl_verify=None, use_session=False, session_factory=requests.Session - ): - """ - Creates a new OpenAPI document from a loaded spec file. This is - overridden here because we need to specify the path in the parent - class' constructor. - - :param raw_document: The raw OpenAPI file loaded into python - :type raw_document: dct - :param validate: If True, don't fail on errors, but instead capture all - errors, continuing along the spec as best as possible, - and make them available when parsing is complete. - :type validate: bool - :param ssl_verify: Decide if to use ssl verification to the requests or not, - in case an str is passed, will be used as the CA. - :type ssl_verify: bool, str, None - :param use_session: Should we use a consistant session between API calls - :type use_session: bool - """ - # do this first so super().__init__ can see it - self.validation_mode = validate - - if validate: - self._spec_errors = [] - - # as the document root, we have no path - super(OpenAPI, self).__init__([], raw_document, self) - - self._security = {} - - self._ssl_verify = ssl_verify - - self._session = None - if use_session: - self._session = session_factory() - - # public methods - def authenticte(self, security_scheme, value): - """ - Authenticates all subsequent requests with the given arguments. - - TODO - this should support more than just HTTP Auth - """ - - # authentication is optional and can be disabled - if security_scheme is None: - self._security = None - return - - if security_scheme not in self.components.securitySchemes: - raise ValueError("{} does not accept security scheme {}".format(self.info.title, security_scheme)) - - self._security = {security_scheme: value} - - authenticate = authenticte - - def resolve_path(self, path): - """ - Given a $ref path, follows the document tree and returns the given attribute. - - :param path: The path down the spec tree to follow - :type path: list[str] - - :returns: The node requested - :rtype: ObjectBase - :raises ValueError: if the given path is not valid - """ - node = self - - for part in path: - if isinstance(node, Map): - if part not in node: # pylint: disable=unsupported-membership-test - err_msg = "Invalid path {} in Reference".format(path) - raise ReferenceResolutionError(err_msg) - node = node.get(part) - else: - if not hasattr(node, part): - err_msg = "Invalid path {} in Reference".format(path) - raise ReferenceResolutionError(err_msg) - node = getattr(node, part) - - return node - - def log_spec_error(self, error): - """ - In Validation Mode, this method is used when parsing a spec to record an - error that was encountered, for later reporting. This should not be used - outside of Validation Mode. - - :param error: The error encountered. - :type error: SpecError - """ - if not self.validation_mode: - raise RuntimeError("This client is not in Validation Mode, cannot " "record errors!") - self._spec_errors.append(error) - - def errors(self): - """ - In Validation Mode, returns all errors encountered from parsing a spec. - This should not be called if not in Validation Mode. - - :returns: The errors encountered during the parsing of this spec. - :rtype: list[SpecError] - """ - if not self.validation_mode: - raise RuntimeError("This client is not in Validation Mode, cannot " "return errors!") - return self._spec_errors - - # private methods - def _register_operation(self, operation_id, operation): - """ - Adds an Operation to this spec's _operation_map, raising an error if the - OperationId has already been registered. - - :param operation_id: The operation ID to register - :type operation_id: str - :param operation: The operation to register - :type operation: Operation - """ - if operation_id in self._operation_map: - raise SpecError("Duplicate operationId {}".format(operation_id), path=operation.path) - self._operation_map[operation_id] = operation - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self._operation_map = {} - - self.components = self._get("components", ["Components"]) - self.externalDocs = self._get("externalDocs", dict) - self.info = self._get("info", "Info") - self.openapi = self._get("openapi", str) - self.paths = self._get("paths", ["Path"], is_map=True) - self.security = self._get("security", ["SecurityRequirement"], is_list=True) - self.servers = self._get("servers", ["Server"], is_list=True) - self.tags = self._get("tags", ["Tag"], is_list=True) - - # now that we've parsed _all_ the data, resolve all references - self._resolve_references() - self._resolve_allOfs() - - def _get_callable(self, operation): - """ - A helper function to create OperationCallable objects for __getattribute__, - pre-initialized with the required values from this object. - - :param operation: The Operation the callable should call - :type operation: callable (Operation.request) - - :returns: The callable that executes this operation with this object's - configuration. - :rtype: OperationCallable - """ - base_url = self.servers[0].url - - return OperationCallable(operation, base_url, self._security, self._ssl_verify, self._session) - - def __getattribute__(self, attr): - """ - Extended __getattribute__ function to allow resolving dynamic function - names. The purpose of this is to call syntax like this:: - - spec = OpenAPI(raw_spec) - spec.call_operationId() - - This method will intercept the dot notation above (spec.call_operationId) - and look up the requested operation, returning a callable object that - will then immediately be called by the parenthesis. - - :param attr: The attribute we're retrieving - :type attr: str - - :returns: The attribute requested - :rtype: any - :raises AttributeError: if the requested attribute does not exist - """ - if attr.startswith("call_"): - _, operationId = attr.split("_", 1) - if operationId in self._operation_map: - return self._get_callable(self._operation_map[operationId].request) - else: - raise AttributeError("{} has no operation {}".format(self.info.title, operationId)) - - return object.__getattribute__(self, attr) - - -class OperationCallable: - """ - This class is returned by instances of the OpenAPI class when members - formatted like call_operationId are accessed, and a valid Operation is - found, and allows calling the operation directly from the OpenAPI object - with the configured values included. This class is not intended to be used - directly. - """ - - def __init__(self, operation, base_url, security, ssl_verify, session): - self.operation = operation - self.base_url = base_url - self.security = security - self.ssl_verify = ssl_verify - self.session = session - - def __call__(self, *args, **kwargs): - if self.ssl_verify is not None: - kwargs["verify"] = self.ssl_verify - if self.session: - kwargs["session"] = self.session - return self.operation(self.base_url, *args, security=self.security, **kwargs) diff --git a/openapi3/paths.py b/openapi3/paths.py deleted file mode 100644 index 0b31cc1..0000000 --- a/openapi3/paths.py +++ /dev/null @@ -1,520 +0,0 @@ -import json -import re -import requests - -try: - from urllib.parse import urlencode -except ImportError: - from urllib import urlencode - -from .errors import SpecError -from .object_base import ObjectBase -from .schemas import Model - - -def _validate_parameters(instance): - """ - Ensures that all parameters for this path are valid - """ - allowed_path_parameters = re.findall(r"{([a-zA-Z0-9\-\._~]+)}", instance.path[1]) - - for c in instance.parameters: - if c.in_ == "path": - if c.name not in allowed_path_parameters: - raise SpecError("Parameter name not found in path: {}".format(c.name), path=instance.path) - - -class Path(ObjectBase): - """ - A Path object, as defined `here`_. Path objects represent URL paths that - may be accessed by appending them to a Server - - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#paths-object - """ - - __slots__ = [ - "summary", - "description", - "get", - "put", - "post", - "delete", - "options", - "head", - "patch", - "trace", - "servers", - "parameters", - ] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - # TODO - handle possible $ref - self.delete = self._get("delete", "Operation") - self.description = self._get("description", str) - self.get = self._get("get", "Operation") - self.head = self._get("head", "Operation") - self.options = self._get("options", "Operation") - self.parameters = self._get("parameters", ["Parameter", "Reference"], is_list=True) - self.patch = self._get("patch", "Operation") - self.post = self._get("post", "Operation") - self.put = self._get("put", "Operation") - self.servers = self._get("servers", ["Server"], is_list=True) - self.summary = self._get("summary", str) - self.trace = self._get("trace", "Operation") - - if self.parameters is None: - # this will be iterated over later - self.parameters = [] - - def _resolve_references(self): - """ - Overloaded _resolve_references to allow us to verify parameters after - we've got all references settled. - """ - super(self.__class__, self)._resolve_references() - - # this will raise if parameters are invalid - _validate_parameters(self) - - -class Parameter(ObjectBase): - """ - A `Parameter Object`_ defines a single operation parameter. - - .. _Parameter Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#parameterObject - """ - - __slots__ = [ - "name", - "in", - "in_", - "description", - "required", - "deprecated", - "allowEmptyValue", - "style", - "explode", - "allowReserved", - "schema", - "example", - "examples", - ] - required_fields = ["name", "in"] - - def _parse_data(self): - self.deprecated = self._get("deprecated", bool) - self.description = self._get("description", str) - self.example = self._get("example", [str, int, bool, float]) # Spec notes 'Any' but just limited to primitives - self.examples = self._get("examples", dict) # Map[str: ['Example','Reference']] - self.explode = self._get("explode", bool) - self.in_ = self._get("in", str) # TODO must be one of ["query","header","path","cookie"] - self.name = self._get("name", str) - self.required = self._get("required", bool) - self.schema = self._get("schema", ["Schema", "Reference"]) - self.style = self._get("style", str) - - # allow empty or reserved values in Parameter data - self.allowEmptyValue = self._get("allowEmptyValue", bool) - self.allowReserved = self._get("allowReserved", bool) - - # required is required and must be True if this parameter is in the path - if self.in_ == "path" and self.required is not True: - err_msg = "Parameter {} must be required since it is in the path" - raise SpecError(err_msg.format(self.get_path()), path=self.path) - - -class Operation(ObjectBase): - """ - An Operation object as defined `here`_ - - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#operationObject - """ - - __slots__ = [ - "tags", - "summary", - "description", - "externalDocs", - "security", - "operationId", - "parameters", - "requestBody", - "responses", - "callbacks", - "deprecated", - "servers", - "_session", - "_request", - ] - required_fields = ["responses"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - raw_servers = self._get("servers", list) - self.deprecated = self._get("deprecated", bool) - self.description = self._get("description", str) - self.externalDocs = self._get("externalDocs", "ExternalDocumentation") - self.operationId = self._get("operationId", str) - self.parameters = self._get("parameters", ["Parameter", "Reference"], is_list=True) - self.requestBody = self._get("requestBody", ["RequestBody", "Reference"]) - self.responses = self._get("responses", ["Response", "Reference"], is_map=True) - self.security = self._get("security", ["SecurityRequirement"], is_list=True) - self.servers = self._get("servers", ["Server"], is_list=True) - self.summary = self._get("summary", str) - self.tags = self._get("tags", list) - raw_servers = self._get("servers", list) - # self.callbacks = self._get('callbacks', dict) TODO - - # default parameters to an empty list for processing later - if self.parameters is None: - self.parameters = [] - - # gather all operations into the spec object - if self.operationId is not None: - # TODO - how to store without an operationId? - formatted_operation_id = self.operationId.replace(" ", "_") - self._root._register_operation(formatted_operation_id, self) - - # TODO - maybe make this generic - if self.security is None: - self.security = self._root._get("security", ["SecurityRequirement"], is_list=True) or [] - - # Store session object - self._session = requests.Session() - - # Store request object - self._request = requests.Request() - - def _resolve_references(self): - """ - Overloaded _resolve_references to allow us to verify parameters after - we've got all references settled. - """ - super(self.__class__, self)._resolve_references() - - # this will raise if parameters are invalid - _validate_parameters(self) - - def _request_handle_secschemes(self, security_requirement, value): - ss = self._root.components.securitySchemes[security_requirement.name] - - if ss.type == "http" and ss.scheme == "basic": - self._request.auth = requests.auth.HTTPBasicAuth(*value) - - if ss.type == "http" and ss.scheme == "digest": - self._request.auth = requests.auth.HTTPDigestAuth(*value) - - if ss.type == "http" and ss.scheme == "bearer": - header = ss.bearerFormat or "Bearer {}" - self._request.headers["Authorization"] = header.format(value) - - if ss.type == "mutualTLS": - # TLS Client certificates (mutualTLS) - self._request.cert = value - - if ss.type == "apiKey": - if ss.in_ == "query": - # apiKey in query parameter - self._request.params[ss.name] = value - - if ss.in_ == "header": - # apiKey in query header data - self._request.headers[ss.name] = value - - if ss.in_ == "cookie": - self._request.cookies = {ss.name: value} - - def _request_handle_parameters(self, parameters={}): - # Parameters - path_parameters = {} - accepted_parameters = {} - p = self.parameters + self._root.paths[self.path[-2]].parameters - - for _ in list(p): - # TODO - make this work with $refs - can operations be $refs? - accepted_parameters.update({_.name: _}) - - for name, spec in accepted_parameters.items(): - try: - value = parameters[name] - except KeyError: - if spec.required and name not in parameters: - err_msg = "Required parameter {} not provided".format(name) - raise ValueError(err_msg) - - continue - - if spec.in_ == "path": - # The string method `format` is incapable of partial updates, - # as such we need to collect all the path parameters before - # applying them to the format string. - path_parameters[name] = value - - if spec.in_ == "query": - self._request.params[name] = value - - if spec.in_ == "header": - self._request.headers[name] = value - - if spec.in_ == "cookie": - self._request.cookies[name] = value - - self._request.url = self._request.url.format(**path_parameters) - - def _request_handle_body(self, data): - if "application/json" in self.requestBody.content: - if isinstance(data, dict) or isinstance(data, list): - body = json.dumps(data) - - if issubclass(type(data), Model): - # serialize models as dicts - converter = lambda c: dict(c) - data_dict = {k: v for k, v in data if v is not None} - - body = json.dumps(data_dict, default=converter) - - self._request.data = body - self._request.headers["Content-Type"] = "application/json" - else: - raise NotImplementedError() - - def request(self, base_url, security={}, data=None, parameters={}, verify=True, session=None, raw_response=False): - """ - Sends an HTTP request as described by this Path - - :param base_url: The URL to append this operation's path to when making - the call. - :type base_url: str - :param security: The security scheme to use, and the values it needs to - process successfully. - :type security: dict{str: str} - :param data: The request body to send. - :type data: any, should match content/type - :param parameters: The parameters used to create the path - :type parameters: dict{str: str} - :param verify: Should we do an ssl verification on the request or not, - In case str was provided, will use that as the CA. - :type verify: bool/str - :param session: a persistent request session - :type session: None, requests.Session - :param raw_response: If true, return the raw response instead of validating - and exterpolating it. - :type raw_response: bool - """ - # Set request method (e.g. 'GET') - self._request = requests.Request(self.path[-1]) - - # Set self._request.url to base_url w/ path - self._request.url = base_url + self.path[-2] - - if security and self.security: - security_requirement = None - for scheme, value in security.items(): - security_requirement = None - for r in self.security: - if r.name == scheme: - security_requirement = r - self._request_handle_secschemes(r, value) - - if security_requirement is None: - err_msg = """No security requirement satisfied (accepts {}) \ - """.format( - ", ".join(self.security.keys()) - ) - raise ValueError(err_msg) - - if self.requestBody: - if self.requestBody.required and data is None: - err_msg = "Request Body is required but none was provided." - raise ValueError(err_msg) - - self._request_handle_body(data) - - self._request_handle_parameters(parameters) - - if session is None: - session = self._session - - # send the prepared request - result = session.send(self._request.prepare()) - - # spec enforces these are strings - status_code = str(result.status_code) - - # find the response model in spec we received - expected_response = None - if status_code in self.responses: - expected_response = self.responses[status_code] - elif "default" in self.responses: - expected_response = self.responses["default"] - - if expected_response is None: - # TODO - custom exception class that has the response object in it - err_msg = """Unexpected response {} from {} (expected one of {}, \ - no default is defined""" - err_var = result.status_code, self.operationId, ",".join(self.responses.keys()) - - raise RuntimeError(err_msg.format(*err_var)) - - # if we got back a valid response code (or there was a default) and no - # response content was expected, return None - if expected_response.content is None: - return - - content_type = result.headers["Content-Type"] - if ';' in content_type: - # if the content type that came in included an encoding, we'll ignore - # it for now (requests has already parsed it for us) and only look at - # the MIME type when determining if an expected content type was returned. - content_type = content_type.split(';')[0].strip() - - expected_media = expected_response.content.get(content_type, None) - - if expected_media is None and "/" in content_type: - # accept media type ranges in the spec. the most specific matching - # type should always be chosen, but if we do not have a match here - # a generic range should be accepted if one if provided - # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object - - generic_type = content_type.split("/")[0] + "/*" - expected_media = expected_response.content.get(generic_type, None) - - if expected_media is None: - err_msg = """Unexpected Content-Type {} returned for operation {} \ - (expected one of {})""" - err_var = result.headers["Content-Type"], self.operationId, ",".join(expected_response.content.keys()) - - raise RuntimeError(err_msg.format(*err_var)) - - response_data = None - - if content_type.lower() == "application/json": - return expected_media.schema.model(result.json()) - else: - raise NotImplementedError() - - -class SecurityRequirement(ObjectBase): - """ - A `SecurityRequirement`_ object describes security schemes for API access. - - .. _SecurityRequirement: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securityRequirementObject - """ - - ___slots__ = ["name", "types"] - required_fields = [] - - def _parse_data(self): - """ """ - # usually these only ever have one key - if len(self.raw_element.keys()) == 1: - self.name = [c for c in self.raw_element.keys()][0] - self.types = self._get(self.name, str, is_list=True) - elif len(self.raw_element.keys()) == 0: - # optional - self.name = self.types = None - - @classmethod - def can_parse(cls, dct): - """ - This needs to ignore can_parse since the objects it's parsing are not - regular - they must always have only one key though or be empty for Optional Security Requirements - """ - return len(dct.keys()) == 1 and isinstance([c for c in dct.values()][0], list) or len(dct.keys()) == 0 - - def __getstate__(self): - return {self.name: self.types} - - -class RequestBody(ObjectBase): - """ - A `RequestBody`_ object describes a single request body. - - .. _RequestBody: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#requestBodyObject - """ - - __slots__ = ["description", "content", "required"] - required_fields = ["content"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.description = self._get("description", str) - self.content = self._get("content", ["MediaType"], is_map=True) - raw_content = self._get("content", dict) - self.required = self._get("required", bool) - - -class MediaType(ObjectBase): - """ - A `MediaType`_ object provides schema and examples for the media type identified - by its key. These are used in a RequestBody object. - - .. _MediaType: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#mediaTypeObject - """ - - __slots__ = ["schema", "example", "examples", "encoding"] - required_fields = [] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.schema = self._get("schema", ["Schema", "Reference"]) - self.example = self._get("example", str) # 'any' type - self.examples = self._get("examples", ["Example", "Reference"], is_map=True) - self.encoding = self._get("encoding", dict) # Map['Encoding'] - - -class Response(ObjectBase): - """ - A `Response Object`_ describes a single response from an API Operation, - including design-time, static links to operations based on the response. - - .. _Response Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#response-object - """ - - __slots__ = ["description", "headers", "content", "links"] - required_fields = ["description"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.content = self._get("content", ["MediaType"], is_map=True) - self.description = self._get("description", str) - raw_content = self._get("content", dict) - raw_headers = self._get("headers", dict) - self.links = self._get("links", ["Link", "Reference"], is_map=True) - - -class Link(ObjectBase): - """ - A `Link Object`_ describes a single Link from an API Operation Response to an API Operation Request - - .. _Link Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#linkObject - """ - - __slots__ = ["operationId", "operationRef", "description", "parameters", "requestBody", "server"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.operationId = self._get("operationId", str) - self.operationRef = self._get("operationRef", str) - self.description = self._get("description", str) - self.parameters = self._get("parameters", dict) - self.requestBody = self._get("requestBody", dict) - self.server = self._get("server", ["Server"]) - - if self.operationId and self.operationRef: - raise SpecError("operationId and operationRef are mutually exclusive, only one of them is allowed") - if not (self.operationId or self.operationRef): - raise SpecError("operationId and operationRef are mutually exclusive, one of them must be specified") diff --git a/openapi3/schemas.py b/openapi3/schemas.py deleted file mode 100644 index 5d341cf..0000000 --- a/openapi3/schemas.py +++ /dev/null @@ -1,310 +0,0 @@ -from .errors import SpecError, ModelError -from .general import Reference # need this for Model below -from .object_base import ObjectBase, Map - -TYPE_LOOKUP = { - "array": list, - "integer": int, - "object": dict, - "string": str, - "boolean": bool, -} - - -class Schema(ObjectBase): - """ - The `Schema Object`_ allows the definition of input and output data types. - - .. _Schema Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#schemaObject - """ - - __slots__ = [ - "title", - "multipleOf", - "maximum", - "exclusiveMaximum", - "minimum", - "exclusiveMinimum", - "maxLength", - "minLength", - "pattern", - "maxItems", - "minItems", - "uniqueItems", - "maxProperties", - "minProperties", - "required", - "enum", - "type", - "allOf", - "oneOf", - "anyOf", - "not", - "items", - "properties", - "additionalProperties", - "description", - "format", - "default", - "nullable", - "discriminator", - "readOnly", - "writeOnly", - "xml", - "externalDocs", - "example", - "deprecated", - "contentEncoding", - "contentMediaType", - "contentSchema", - "_model_type", - "_request_model_type", - "_resolved_allOfs", - ] - required_fields = [] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.title = self._get("title", str) - self.maximum = self._get("maximum", [int, float]) - self.minimum = self._get("minimum", [int, float]) - self.maxLength = self._get("maxLength", int) - self.minLength = self._get("minLength", int) - self.pattern = self._get("pattern", str) - self.maxItems = self._get("maxItems", int) - self.minItems = self._get("minItmes", int) - self.required = self._get("required", list) - self.enum = self._get("enum", list) - self.type = self._get("type", str) - self.allOf = self._get("allOf", ["Schema", "Reference"], is_list=True) - self.oneOf = self._get("oneOf", list) - self.anyOf = self._get("anyOf", list) - self.items = self._get("items", ["Schema", "Reference"]) - self.properties = self._get("properties", ["Schema", "Reference"], is_map=True) - self.additionalProperties = self._get("additionalProperties", [bool, dict]) - self.description = self._get("description", str) - self.format = self._get("format", str) - self.default = self._get("default", TYPE_LOOKUP.get(self.type, str)) # TODO - str as a default? - self.nullable = self._get("nullable", bool) - self.discriminator = self._get("discriminator", dict) # 'Discriminator' - self.readOnly = self._get("readOnly", bool) - self.writeOnly = self._get("writeOnly", bool) - self.xml = self._get("xml", dict) # 'XML' - self.externalDocs = self._get("externalDocs", dict) # 'ExternalDocs' - self.deprecated = self._get("deprecated", bool) - self.example = self._get("example", "*") - self.contentEncoding = self._get("contentEncoding", str) - self.contentMediaType = self._get("contentMediaType", str) - self.contentSchema = self._get("contentSchema", str) - - # TODO - Implement the following properties: - # self.multipleOf - # self.not - # self.uniqueItems - # self.maxProperties - # self.minProperties - # self.exclusiveMinimum - # self.exclusiveMaximum - - self._resolved_allOfs = False - - if self.type == "array" and self.items is None: - raise SpecError('{}: items is required when type is "array"'.format(self.get_path())) - - def get_type(self): - """ - Returns the Type that this schema represents. This Type is created once - per Schema and cached so that all instances of the same schema are the - same Type. For example:: - - object1 = example_schema.model({"some":"json"}) - object2 = example_schema.model({"other":"json"}) - - isinstance(object1, example._schema.get_type()) # true - type(object1) == type(object2) # true - """ - # this is defined in ObjectBase.__init__ as all slots are - if self._model_type is None: # pylint: disable=access-member-before-definition - type_name = self.title or self.path[-1] - self._model_type = type( - type_name, - (Model,), - {"__slots__": self.properties.keys()}, # pylint: disable=attribute-defined-outside-init - ) - - return self._model_type - - def model(self, data): - """ - Generates a model representing this schema from the given data. - - :param data: The data to create the model from. Should match this schema. - :type data: dict - - :returns: A new :any:`Model` created in this Schema's type from the data. - :rtype: self.get_type() - """ - if self.properties is None and self.type in ("string", "number"): # more simple types - # if this schema represents a simple type, simply return the data - # TODO - perhaps assert that the type of data matches the type we - # expected - return data - elif self.type == "array": - return [self.items.get_type()(i, self.items) for i in data] - else: - return self.get_type()(data, self) - - def get_request_type(self): - """ - Similar to :any:`get_type`, but the resulting type does not accept readOnly - fields - """ - # this is defined in ObjectBase.__init__ as all slots are - if self._request_model_type is None: # pylint: disable=access-member-before-definition - type_name = self.title or self.path[-1] - self._request_model_type = type( - type_name + "Request", - (Model,), - { # pylint: disable=attribute-defined-outside-init - "__slots__": [k for k, v in self.properties.items() if not v.readOnly] - }, - ) - - return self._request_model_type - - def request_model(self, **kwargs): - """ - Converts the kwargs passed into a model of writeable fields of this - schema - """ - # TODO - this doesn't get nested schemas - return self.get_request_type()(kwargs, self) - - def _resolve_allOfs(self): - """ - Handles merging properties for allOfs - """ - if self._resolved_allOfs: - return - - self._resolved_allOfs = True - - if self.allOf: - for c in self.allOf: - if isinstance(c, Schema): - self._merge(c) - - def _merge(self, other): - """ - Merges ``other`` into this schema, preferring to use the values in ``other`` - """ - # Clone the other object so that we're never merging a referenced object. - # This will ensure that an allOf like this: - # - # allOf: - # - $ref: '#/components/schema/Example' - # - type: object - # properties: - # foo: - # type string - # - # Does not add or modify "foo" on components.schemas['Example'] - other = other._clone() - - for slot in self.__slots__: - if slot.startswith("_"): - # skip private members - continue - - my_value = getattr(self, slot) - other_value = getattr(other, slot) - - if other_value: - # we got a value to merge - if isinstance(other_value, Schema): - # if it's another schema, merge them - if my_value is not None: - my_value._merge(other_value) - else: - setattr(self, slot, other_value) - elif isinstance(other_value, list): - # we got a list, combine them - if my_value is None: - my_value = [] - setattr(self, slot, my_value + other_value) - elif isinstance(other_value, dict) or isinstance(other_value, Map): - if my_value: - for k, v in my_value.items(): - if k in other_value: - if isinstance(v, Schema): - v._merge(other_value[k]) - continue - else: - my_value[k] = other_value[k] - for ok, ov in other_value.items(): - if ok not in my_value: - my_value[ok] = ov - else: - setattr(self, slot, other_value) - else: - setattr(self, slot, other_value) - - -class Model: - """ - A Model is a representation of a Schema as a request or response. Models - are generated from Schema objects by called :any:`Schema.model` with the - contents of a response. - """ - - __slots__ = ["_raw_data", "_schema"] - - def __init__(self, data, schema): - """ - Creates a new Model from data. This should never be called directly, - but instead should be called through :any:`Schema.model` to generate a - Model from a defined Schema. - - :param data: The data to create this Model with - :type data: dict - """ - self._raw_data = data - self._schema = schema - - for s in self.__slots__: - # initialize all slots to None - setattr(self, s, None) - - keys = set(data.keys()) - frozenset(self.__slots__) - if keys: - raise ModelError("Schema {} got unexpected attribute keys {}".format(self.__class__.__name__, keys)) - - # collect the data into this model - for k, v in data.items(): - prop = schema.properties[k] - - if prop.type == "array": - # handle arrays - item_schema = prop.items - setattr(self, k, [item_schema.model(c) for c in v]) - elif prop.type == "object": - # handle nested objects - object_schema = prop - setattr(self, k, object_schema.model(v)) - else: - setattr(self, k, v) - - def __repr__(self): - """ - A generic representation of this model - """ - return str(dict(self)) - - def __iter__(self): - for s in self.__slots__: - if s.startswith("_"): - continue - yield s, getattr(self, s) - return diff --git a/openapi3/security.py b/openapi3/security.py deleted file mode 100644 index a10977d..0000000 --- a/openapi3/security.py +++ /dev/null @@ -1,26 +0,0 @@ -from .errors import SpecError -from .object_base import ObjectBase, Map - - -class SecurityScheme(ObjectBase): - """ - A `Security Scheme`_ defines a security scheme that can be used by the operations. - - .. _Security Scheme: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#securitySchemeObject - """ - - __slots__ = ["type", "description", "name", "in", "in_", "scheme", "bearerFormat", "flows", "openIdConnectUrl"] - required_fields = ["type"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.bearerFormat = self._get("bearerFormat", [str]) - self.description = self._get("description", [str]) - self.flows = self._get("flows", dict) # ['OAuthFlows']) TODO - self.in_ = self._get("in", str) - self.name = self._get("name", [str]) - self.openIdConnectUrl = self._get("openIdConnectUrl", [str]) - self.scheme = self._get("scheme", [str]) - self.type = self._get("type", [str]) diff --git a/openapi3/servers.py b/openapi3/servers.py deleted file mode 100644 index 2b687a0..0000000 --- a/openapi3/servers.py +++ /dev/null @@ -1,39 +0,0 @@ -from .object_base import ObjectBase - - -class Server(ObjectBase): - """ - The Server object, as described `here`_ - - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#serverObject - """ - - __slots__ = ["url", "description", "variables"] - required_fields = ["url"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.description = self._get("description", str) - self.url = self._get("url", str) - self.variables = self._get("variables", ["ServerVariable"], is_map=True) - - -class ServerVariable(ObjectBase): - """ - A ServerVariable object as defined `here`_. - - .. _here: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#server-variable-object - """ - - __slots__ = ["enum", "default", "description"] - required_fields = ["default"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.default = self._get("default", str) - self.description = self._get("description", str) - self.enum = self._get("enum", [str], is_list=True) diff --git a/openapi3/tag.py b/openapi3/tag.py deleted file mode 100644 index c333aaa..0000000 --- a/openapi3/tag.py +++ /dev/null @@ -1,20 +0,0 @@ -from .object_base import ObjectBase - - -class Tag(ObjectBase): - """ - A `Tag Object`_ holds a reusable set of different aspects of the OAS - spec. - - .. _Tag Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#tagObject - """ - - __slots__ = ["name", "description", "externalDocs"] - - def _parse_data(self): - """ - Implementation of :any:`ObjectBase._parse_data` - """ - self.name = self._get("name", str) - self.description = self._get("description", str) - self.externalDocs = self._get("externalDocs", str) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..08576f6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +filterwarnings = [ + "error", + "ignore::UserWarning", + # note the use of single quote below to denote "raw" strings in TOML + 'ignore:function ham\(\) is deprecated:DeprecationWarning', +] +asyncio_mode = "strict" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af55b5c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi~=0.70.1 +httpx~=0.21.1 +hypercorn~=0.13.0 +pydantic~=1.9.0a1 +pytest~=6.2.5 +PyYAML~=6.0 +starlette~=0.16.0 +uvloop~=0.16.0 +yarl~=1.7.2 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..66da5b0 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,63 @@ +[metadata] +name = aiopenapi3 +version = attr: aiopenapi3.version.__version__ +url = https://github.com/commonism/aiopenapi3 +description = OpenAPI3 3.0.3 client / validator based on pydantic & httpx +long_description = file: README.md +long_description_content_type = text/markdown +keywords = openapi openapi3 +license = +classifiers = + Development Status :: 3 - Alpha + Environment :: Web Environment + Framework :: AsyncIO + Intended Audience :: Developers + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Topic :: Internet + Topic :: Internet :: WWW/HTTP + Topic :: Software Development + Topic :: Software Development :: Libraries + Topic :: Software Development :: Libraries :: Application Frameworks + Topic :: Software Development :: Libraries :: Python Modules + Typing :: Typed + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + + +[options] +packages = + aiopenapi3 + aiopenapi3.v20 + aiopenapi3.v30 + aiopenapi3.v31 + +install_requires = + PyYaml + pydantic + pydantic[email] + yarl + httpx + +[options.extras_require] +tests = + pytest + pytest-asyncio + pytest-httpx + pytest-coverage + fastapi-versioning + hypercorn + uvloop + +compat = + typing_extensions + pathlib3x + +socks = + httpx_socks diff --git a/setup.py b/setup.py deleted file mode 100755 index b92c558..0000000 --- a/setup.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -from io import open -from setuptools import setup -from os import path - - -here = path.abspath(path.dirname(__file__)) - - -# get the long description from the README.rst -with open(path.join(here, "README.rst"), encoding="utf-8") as f: - long_description = f.read() - - -setup( - name="openapi3", - version="1.6.2", - description="Client and Validator of OpenAPI 3 Specifications", - long_description=long_description, - author="dorthu", - url="https://github.com/dorthu/openapi3", - packages=["openapi3"], - license="BSD 3-Clause License", - install_requires=["PyYaml", "requests"], - tests_require=["pytest", "pytest-asyncio", "uvloop", "hypercorn", "pydantic", "fastapi"], -) diff --git a/tests/api/main.py b/tests/api/main.py index 9f37f9d..5caa91d 100644 --- a/tests/api/main.py +++ b/tests/api/main.py @@ -1,86 +1,17 @@ from __future__ import annotations -import errno -from typing import Optional +from fastapi import FastAPI +from fastapi_versioning import VersionedFastAPI, version -import starlette.status -from fastapi import FastAPI, Query, Body, Response -from fastapi.responses import JSONResponse +from api.v1.main import router as v1 +from api.v2.main import router as v2 -from .schema import Pets, Pet, PetCreate, Error +app = FastAPI( + version="1.0.0", title="Dorthu's Petstore", servers=[{"url": "/", "description": "Default, relative server"}] +) -app = FastAPI(version="1.0.0", - title="Dorthu's Petstore", - servers=[{"url": "/", "description": "Default, relative server"}]) -ZOO = dict() +app.include_router(v1) +app.include_router(v2) -def _idx(l): - for i in range(l): - yield i - -idx = _idx(100) - - -@app.post('/pet', - operation_id="createPet", - response_model=Pet, - responses={201: {"model": Pet}, - 409: {"model": Error}} - ) -def createPet(response: Response, - pet: PetCreate = Body(..., embed=True), - ) -> None: - if pet.name in ZOO: - return JSONResponse(status_code=starlette.status.HTTP_409_CONFLICT, - content=Error(code=errno.EEXIST, - message=f"{pet.name} already exists" - ).dict() - ) - ZOO[pet.name] = r = Pet(id=next(idx), **pet.dict()) - response.status_code = starlette.status.HTTP_201_CREATED - return r - - -@app.get('/pet', - operation_id="listPet", - response_model=Pets) -def listPet(limit: Optional[int] = None) -> Pets: - return list(ZOO.values()) - - -@app.get('/pets/{pet_id}', - operation_id="getPet", - response_model=Pet, - responses={ - 404: {"model": Error} - } - ) -def getPet(pet_id: int = Query(..., alias='petId')) -> Pets: - for k, v in ZOO.items(): - if pet_id == v.id: - return v - else: - # media_type included here is to ensure that content encodings do not break - # expected response type handling for requests - return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, - content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), - media_type="application/json; utf-8") - - -@app.delete('/pets/{pet_id}', - operation_id="deletePet", - responses={ - 204: {"model": None}, - 404: {"model": Error} - }) -def deletePet(response: Response, - pet_id: int = Query(..., alias='petId')) -> Pets: - for k, v in ZOO.items(): - if pet_id == v.id: - del ZOO[k] - response.status_code = starlette.status.HTTP_204_NO_CONTENT - return response - else: - return JSONResponse(status_code=starlette.status.HTTP_404_NOT_FOUND, - content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict()) +app = VersionedFastAPI(app, version_format="{major}", prefix_format="/v{major}") diff --git a/tests/api/v1/main.py b/tests/api/v1/main.py new file mode 100644 index 0000000..5718506 --- /dev/null +++ b/tests/api/v1/main.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import errno +from typing import Optional + +import starlette.status +from fastapi import FastAPI, APIRouter, Query, Body, Response +from fastapi.responses import JSONResponse + +from .schema import Pets, Pet, PetCreate, Error + + +from fastapi_versioning import versioned_api_route, version + +router = APIRouter(route_class=versioned_api_route(1)) + + +ZOO = dict() + + +def _idx(l): + for i in range(l): + yield i + + +idx = _idx(100) + + +@router.post( + "/pet", operation_id="createPet", response_model=Pet, responses={201: {"model": Pet}, 409: {"model": Error}} +) +def createPet( + response: Response, + pet: PetCreate = Body(..., embed=True), +) -> None: + if pet.name in ZOO: + return JSONResponse( + status_code=starlette.status.HTTP_409_CONFLICT, + content=Error(code=errno.EEXIST, message=f"{pet.name} already exists").dict(), + ) + ZOO[pet.name] = r = Pet(id=next(idx), **pet.dict()) + response.status_code = starlette.status.HTTP_201_CREATED + return r + + +@router.get("/pet", operation_id="listPet", response_model=Pets) +def listPet(limit: Optional[int] = None) -> Pets: + return list(ZOO.values()) + + +@router.get("/pets/{pet_id}", operation_id="getPet", response_model=Pet, responses={404: {"model": Error}}) +def getPet(pet_id: int = Query(..., alias="petId")) -> Pets: + for k, v in ZOO.items(): + if pet_id == v.id: + return v + else: + return JSONResponse( + status_code=starlette.status.HTTP_404_NOT_FOUND, + content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), + ) + + +@router.delete("/pets/{pet_id}", operation_id="deletePet", responses={204: {"model": None}, 404: {"model": Error}}) +def deletePet(response: Response, pet_id: int = Query(..., alias="petId")) -> Pets: + for k, v in ZOO.items(): + if pet_id == v.id: + del ZOO[k] + response.status_code = starlette.status.HTTP_204_NO_CONTENT + return response + else: + return JSONResponse( + status_code=starlette.status.HTTP_404_NOT_FOUND, + content=Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), + media_type="application/json; utf-8", + ) diff --git a/tests/api/v1/schema.py b/tests/api/v1/schema.py new file mode 100644 index 0000000..a1f7d25 --- /dev/null +++ b/tests/api/v1/schema.py @@ -0,0 +1,25 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class PetBase(BaseModel): + name: str + tag: Optional[str] = Field(default=None) + + +class PetCreate(PetBase): + pass + + +class Pet(PetBase): + id: int + + +class Pets(BaseModel): + __root__: List[Pet] = Field(..., description="list of pet") + + +class Error(BaseModel): + code: int + message: str diff --git a/tests/api/v2/main.py b/tests/api/v2/main.py new file mode 100644 index 0000000..a1d683c --- /dev/null +++ b/tests/api/v2/main.py @@ -0,0 +1,84 @@ +import errno +import uuid +from typing import Optional + +import starlette.status +from fastapi import Query, Body, Response, APIRouter +from fastapi.responses import JSONResponse +from fastapi_versioning import version + +from . import schema + +from fastapi_versioning import versioned_api_route + +router = APIRouter(route_class=versioned_api_route(2)) + +ZOO = dict() + + +def _idx(l): + for i in range(l): + yield i + + +idx = _idx(100) + + +@router.post( + "/pet", + operation_id="createPet", + response_model=schema.Pet, + responses={201: {"model": schema.Pet}, 409: {"model": schema.Error}}, +) +def createPet( + response: Response, + pet: schema.Pet = Body(..., embed=True), +) -> None: + # if isinstance(pet, Cat): + # pet = pet.__root__ + # elif isinstance(pet, Dog): + # pass + if pet.name in ZOO: + return JSONResponse( + status_code=starlette.status.HTTP_409_CONFLICT, + content=schema.Error(code=errno.EEXIST, message=f"{pet.name} already exists").dict(), + ) + pet.identifier = str(uuid.uuid4()) + ZOO[pet.name] = r = pet + response.status_code = starlette.status.HTTP_201_CREATED + return r + + +@router.get("/pet", operation_id="listPet", response_model=schema.Pets) +def listPet(limit: Optional[int] = None) -> schema.Pets: + return list(ZOO.values()) + + +@router.get( + "/pets/{pet_id}", operation_id="getPet", response_model=schema.Pet, responses={404: {"model": schema.Error}} +) +def getPet(pet_id: str = Query(..., alias="petId")) -> schema.Pets: + for k, v in ZOO.items(): + if pet_id == v.identifier: + return v + else: + return JSONResponse( + status_code=starlette.status.HTTP_404_NOT_FOUND, + content=schema.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), + ) + + +@router.delete( + "/pets/{pet_id}", operation_id="deletePet", responses={204: {"model": None}, 404: {"model": schema.Error}} +) +def deletePet(response: Response, pet_id: int = Query(..., alias="petId")) -> schema.Pets: + for k, v in ZOO.items(): + if pet_id == v.identifier: + del ZOO[k] + response.status_code = starlette.status.HTTP_204_NO_CONTENT + return response + else: + return JSONResponse( + status_code=starlette.status.HTTP_404_NOT_FOUND, + content=schema.Error(code=errno.ENOENT, message=f"{pet_id} not found").dict(), + ) diff --git a/tests/api/v2/schema.py b/tests/api/v2/schema.py new file mode 100644 index 0000000..ffa0c4c --- /dev/null +++ b/tests/api/v2/schema.py @@ -0,0 +1,76 @@ +from datetime import timedelta +import uuid + +import sys + +if sys.version_info >= (3, 9): + from typing import List, Optional, Literal, Union, Annotated +else: + from typing import List, Optional, Union + from typing_extensions import Annotated, Literal + + +import pydantic +from pydantic import BaseModel, Field +from pydantic.fields import Undefined + + +class PetBase(BaseModel): + identifier: Optional[str] = Field(default_factory=lambda: str(uuid.uuid4())) + name: str + tags: Optional[List[str]] # = Field(default_factory=list) + + +class BlackCat(PetBase): + pet_type: Literal["cat"] = "cat" + color: Literal["black"] = "black" + black_name: str + + +class WhiteCat(PetBase): + pet_type: Literal["cat"] = "cat" + color: Literal["white"] = "white" + white_name: str + + +# Can also be written with a custom root type +# +class Cat(BaseModel): + __root__: Annotated[Union[BlackCat, WhiteCat], Field(discriminator="color")] + + def __getattr__(self, item): + return getattr(self.__root__, item) + + def __setattr__(self, item, value): + return setattr(self.__root__, item, value) + + +# Cat = Annotated[Union[BlackCat, WhiteCat], Field(default=Undefined, discriminator='color')] + + +class Dog(PetBase): + pet_type: Literal["dog"] = "dog" + name: str + age: timedelta + + +# Pet = Annotated[Union[Cat, Dog], Field(default=Undefined, discriminator='pet_type')] + + +class Pet(BaseModel): + __root__: Annotated[Union[Cat, Dog], Field(discriminator="pet_type")] + + def __getattr__(self, item): + return getattr(self.__root__, item) + + def __setattr__(self, item, value): + return setattr(self.__root__, item, value) + + +class Pets(BaseModel): + __root__: List[Pet] = Field(..., description="list of pet") + + +class Error(BaseModel): + code: int + message: str diff --git a/tests/conftest.py b/tests/conftest.py index 1db9424..661e166 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,10 @@ import pytest from yaml import safe_load -from openapi3 import OpenAPI +from aiopenapi3 import OpenAPI LOADED_FILES = {} +URLBASE = "/" def _get_parsed_yaml(filename): @@ -36,7 +37,7 @@ def _get_parsed_spec(filename): if "spec:" + filename not in LOADED_FILES: parsed = _get_parsed_yaml(filename) - spec = OpenAPI(parsed) + spec = OpenAPI(URLBASE, parsed) LOADED_FILES["spec:" + filename] = spec @@ -138,15 +139,6 @@ def with_broken_links(): yield _get_parsed_yaml("with-broken-links.yaml") -@pytest.fixture -def with_param_types(): - """ - Provides a spec with multiple parameter types and typed examples - """ - # JSON file to allow specific typing of bool example (bool is a subclass of int in Python) - yield _get_parsed_yaml("parameter-types.json") - - @pytest.fixture def with_securityparameters(): """ @@ -156,17 +148,21 @@ def with_securityparameters(): @pytest.fixture -def with_nested_allof_ref(): +def with_parameters(): """ - Provides a spec with a $ref under a schema defined in an allOf + Provides a spec with parameters """ - yield _get_parsed_yaml("nested-allOf.yaml") + yield _get_parsed_yaml("with-parameters.yaml") @pytest.fixture -def with_ref_allof(): +def with_callback(): """ - Provides a spec that includes a reference to a component schema in and out of - an allOf + Provides a spec with callback """ - yield _get_parsed_yaml("ref-allof.yaml") + yield _get_parsed_yaml("callback-example.yaml") + + +@pytest.fixture +def with_swagger(): + yield _get_parsed_yaml("swagger-example.yaml") diff --git a/tests/fastapi_test.py b/tests/fastapi_test.py index e8eb85f..7965095 100644 --- a/tests/fastapi_test.py +++ b/tests/fastapi_test.py @@ -1,25 +1,26 @@ import asyncio -import random import uuid +import sys import pytest - -import requests +import pytest_asyncio import uvloop from hypercorn.asyncio import serve from hypercorn.config import Config -import openapi3 +import aiopenapi3 from api.main import app + @pytest.fixture(scope="session") def config(unused_tcp_port_factory): c = Config() c.bind = [f"localhost:{unused_tcp_port_factory()}"] return c + @pytest.fixture(scope="session") def event_loop(request): loop = asyncio.get_event_loop_policy().new_event_loop() @@ -27,7 +28,7 @@ def event_loop(request): loop.close() -@pytest.fixture(scope="session") +@pytest_asyncio.fixture(scope="session") async def server(event_loop, config): uvloop.install() try: @@ -38,49 +39,54 @@ async def server(event_loop, config): sd.set() await task -@pytest.fixture(scope="session") + +@pytest_asyncio.fixture(scope="session") async def client(event_loop, server): - data = await asyncio.to_thread(requests.get, f"http://{server.bind[0]}/openapi.json") - data = data.json() - data["servers"][0]["url"] = f"http://{server.bind[0]}" - api = openapi3.OpenAPI(data) + api = await asyncio.to_thread(aiopenapi3.OpenAPI.load_sync, f"http://{server.bind[0]}/v1/openapi.json") return api + def randomPet(name=None): - return {"data":{"pet":{"name":str(name) or random.choice(["dog","cat","mouse","eagle"])}}} + return {"data": {"pet": {"name": str(name or uuid.uuid4()), "pet_type": "dog"}}} + @pytest.mark.asyncio +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires asyncio.to_thread") async def test_createPet(event_loop, server, client): - r = await asyncio.to_thread(client.call_createPet, **randomPet()) - assert type(r) == client.components.schemas["Pet"].get_type() + r = await asyncio.to_thread(client._.createPet, **randomPet()) + assert type(r).schema() == client.components.schemas["Pet"].get_type().schema() - r = await asyncio.to_thread(client.call_createPet, data={"pet":{"name":r.name}}) - assert type(r) == client.components.schemas["Error"].get_type() + r = await asyncio.to_thread(client._.createPet, data={"pet": {"name": r.name}}) + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() @pytest.mark.asyncio +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires asyncio.to_thread") async def test_listPet(event_loop, server, client): - r = await asyncio.to_thread(client.call_createPet, **randomPet(uuid.uuid4())) - l = await asyncio.to_thread(client.call_listPet) + r = await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) + l = await asyncio.to_thread(client._.listPet) assert len(l) > 0 + @pytest.mark.asyncio +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires asyncio.to_thread") async def test_getPet(event_loop, server, client): - pet = await asyncio.to_thread(client.call_createPet, **randomPet(uuid.uuid4())) - r = await asyncio.to_thread(client.call_getPet, parameters={"pet_id":pet.id}) - assert type(r) == type(pet) + pet = await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) + r = await asyncio.to_thread(client._.getPet, parameters={"pet_id": pet.id}) + assert type(r).schema() == type(pet).schema() assert r.id == pet.id - r = await asyncio.to_thread(client.call_getPet, parameters={"pet_id":-1}) - assert type(r) == client.components.schemas["Error"].get_type() + r = await asyncio.to_thread(client._.getPet, parameters={"pet_id": -1}) + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() + @pytest.mark.asyncio +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires asyncio.to_thread") async def test_deletePet(event_loop, server, client): - r = await asyncio.to_thread(client.call_deletePet, parameters={"pet_id":-1}) - assert type(r) == client.components.schemas["Error"].get_type() + r = await asyncio.to_thread(client._.deletePet, parameters={"pet_id": -1}) + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() - await asyncio.to_thread(client.call_createPet, **randomPet(uuid.uuid4())) - zoo = await asyncio.to_thread(client.call_listPet) + await asyncio.to_thread(client._.createPet, **randomPet(uuid.uuid4())) + zoo = await asyncio.to_thread(client._.listPet) for pet in zoo: - await asyncio.to_thread(client.call_deletePet, parameters={"pet_id":pet.id}) - + await asyncio.to_thread(client._.deletePet, parameters={"pet_id": pet.id}) diff --git a/tests/fixtures/callback-example.yaml b/tests/fixtures/callback-example.yaml new file mode 100644 index 0000000..262b8df --- /dev/null +++ b/tests/fixtures/callback-example.yaml @@ -0,0 +1,61 @@ +openapi: 3.0.0 +info: + title: Callback Example + version: 1.0.0 +paths: + /streams: + post: + description: subscribes a client to receive out-of-band data + parameters: + - name: callbackUrl + in: query + required: true + description: | + the location where data will be sent. Must be network accessible + by the source server + schema: + type: string + format: uri + example: https://tonys-server.com + responses: + '201': + description: subscription successfully created + content: + application/json: + schema: + description: subscription information + required: + - subscriptionId + properties: + subscriptionId: + description: this unique identifier allows management of the subscription + type: string + example: 2531329f-fb09-4ef7-887e-84e648214436 + callbacks: + # the name `onData` is a convenience locator + onData: + # when data is sent, it will be sent to the `callbackUrl` provided + # when making the subscription PLUS the suffix `/data` + '{$request.query.callbackUrl}/data': + post: + requestBody: + description: subscription payload + content: + application/json: + schema: + type: object + properties: + timestamp: + type: string + format: date-time + userData: + type: string + responses: + '202': + description: | + Your server implementation should return this HTTP status code + if the data was received successfully + '204': + description: | + Your server should return this HTTP status code if no longer interested + in further updates diff --git a/tests/fixtures/petstore-expanded.yaml b/tests/fixtures/petstore-expanded.yaml index acd46d9..9037f74 100644 --- a/tests/fixtures/petstore-expanded.yaml +++ b/tests/fixtures/petstore-expanded.yaml @@ -138,12 +138,12 @@ components: NewPet: type: object required: - - name + - name properties: name: type: string tag: - type: string + type: string Error: type: object diff --git a/tests/fixtures/swagger-example.yaml b/tests/fixtures/swagger-example.yaml new file mode 100644 index 0000000..df8740c --- /dev/null +++ b/tests/fixtures/swagger-example.yaml @@ -0,0 +1,124 @@ +swagger: "2.0" +info: + title: Sample API + description: API description in Markdown. + version: 1.0.0 +host: api.example.com +basePath: /v1 +schemes: + - https + +consumes: + - application/json +produces: + - application/json + +securityDefinitions: + BasicAuth: + type: basic + HeaderAuth: + type: apiKey + in: header + name: Authorization + QueryAuth: + type: apiKey + in: query + name: auth + user: + type: apiKey + in: header + name: x-user + token: + type: apiKey + in: header + name: x-token + + +security: + - BasicAuth: [] + +paths: + /combined: + get: + operationId: combinedSecurity + responses: + 200: + description: '' + schema: + type: str + security: + - user: [] + token: [] + + /users/{userId}: + get: + operationId: getUser + summary: Returns a user by ID. + parameters: + - in: path + name: userId + required: true + type: integer + responses: + 200: + description: OK + schema: + $ref: '#/definitions/User' + 400: + description: The specified user ID is invalid (e.g. not a number). + 404: + description: A user with the specified ID was not found. + default: + description: Unexpected error + /users: + get: + security: + - {} + operationId: listUsers + summary: Returns a list of users. + description: Optional extended description in Markdown. + parameters: + - in: header + name: inHeader + type: string + - in: query + name: inQuery + type: string + produces: + - application/json + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/User' + post: + security: + - QueryAuth: [] + - HeaderAuth: [] + operationId: createUser + summary: Creates a new user. + parameters: + - in: body + name: user + required: true + schema: + $ref: '#/definitions/User' + responses: + 200: + description: OK + schema: + $ref: '#/definitions/User' + +definitions: + User: + properties: + id: + type: integer + name: + type: string + # Both properties are required + required: + - id + - name diff --git a/tests/fixtures/with-broken-links.yaml b/tests/fixtures/with-broken-links.yaml index e393969..c3dcc5c 100644 --- a/tests/fixtures/with-broken-links.yaml +++ b/tests/fixtures/with-broken-links.yaml @@ -24,22 +24,6 @@ paths: operationRef: "/with-links" parameters: param: baz - /with-links-two: - get: - operationId: withLinksTwo - responses: - '200': - description: This has links too - content: - applicaton/json: - schema: - type: object - properties: - test: - type: string - description: A test response fields - example: foobar - links: exampleWithNeither: parameters: param: baz diff --git a/tests/fixtures/with-parameters.yaml b/tests/fixtures/with-parameters.yaml new file mode 100644 index 0000000..346af47 --- /dev/null +++ b/tests/fixtures/with-parameters.yaml @@ -0,0 +1,58 @@ +openapi: 3.0.3 +info: + title: '' + version: 0.0.0 +servers: + - url: http://127.0.0.1/api + +security: + - {} + +paths: + /test/{Path}: + get: + operationId: getTest + parameters: + - $ref: "#/components/parameters/Cookie" + - name: Query + in: query + description: "" + required: True + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Test' + description: '' + parameters: + - $ref: "#/components/parameters/Path" + - name: Header + in: header + description: "" + required: True + schema: + type: array + items: + type: integer + + +components: + schemas: + Test: + type: string + parameters: + Path: + name: Path + in: path + required: true + schema: + type: string + Cookie: + name: Cookie + in: cookie + required: True + schema: + type: string diff --git a/tests/fixtures/with-securityparameters.yaml b/tests/fixtures/with-securityparameters.yaml index a77befa..0831f3c 100644 --- a/tests/fixtures/with-securityparameters.yaml +++ b/tests/fixtures/with-securityparameters.yaml @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Login' description: '' - + /api/v1/auth/login/: post: operationId: api_v1_auth_login_create @@ -56,6 +56,30 @@ paths: - bearerAuth: [] - {} + /api/v1/auth/combined/: + post: + operationId: api_v1_auth_login_combined + description: '' + tags: + - api + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Login' + description: '' + security: + - user: [] + token: [] + + components: schemas: Login: @@ -85,4 +109,11 @@ components: bearerAuth: type: http scheme: bearer - + user: + type: apiKey + in: header + name: x-user + token: + type: apiKey + in: header + name: x-token diff --git a/tests/linode_test.py b/tests/linode_test.py new file mode 100644 index 0000000..6c77815 --- /dev/null +++ b/tests/linode_test.py @@ -0,0 +1,44 @@ +import os +import asyncio + +from aiopenapi3 import OpenAPI +import pytest + +# downloading the description document in the github CI fails due to the cloudflare captcha +noci = pytest.mark.skipif(os.environ.get("GITHUB_ACTIONS", None) is not None, reason="fails on github") + + +@pytest.fixture(scope="session") +def event_loop(request): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="session") +async def api(): + from aiopenapi3.loader import NullLoader, YAMLCompatibilityLoader + + return await OpenAPI.load_async( + "https://www.linode.com/docs/api/openapi.yaml", loader=NullLoader(YAMLCompatibilityLoader) + ) + + +@pytest.mark.asyncio +@noci +async def test_linode_components_schemas(api): + for name, schema in api.components.schemas.items(): + schema.get_type().construct() + + +@pytest.mark.asyncio +@noci +async def test_linode_return_values(api): + for i in api._: + call = getattr(api._, i) + try: + a = call.return_value() + except KeyError: + pass + else: + a.get_type().construct() diff --git a/tests/loader_test.py b/tests/loader_test.py new file mode 100644 index 0000000..3678390 --- /dev/null +++ b/tests/loader_test.py @@ -0,0 +1,80 @@ +import json +import sys + +if sys.version_info >= (3, 9): + from pathlib import Path +else: + from pathlib3x import Path + + +import pytest +from aiopenapi3 import OpenAPI, FileSystemLoader, ReferenceResolutionError +from aiopenapi3.loader import Loader, Plugins, NullLoader + +SPECTPL = """ +openapi: "3.0.0" +info: + title: spec01 + version: 1.0.0 + description: | + {description} + +paths: + /load: + get: + responses: + '200': + $ref: {jsonref} +components: + schemas: + Example: + type: str + Object: + type: object + properties: + name: + type: string + value: + type: boolean +""" + +data = [ + ("petstore-expanded.yaml#/components/schemas/Pet", None), + ("no-such.file.yaml#/components/schemas/Pet", FileNotFoundError), + ("petstore-expanded.yaml#/components/schemas/NoSuchPet", ReferenceResolutionError), +] + + +@pytest.mark.parametrize("jsonref, exception", data) +def test_loader_jsonref(jsonref, exception): + loader = FileSystemLoader(Path("tests/fixtures")) + values = {"jsonref": jsonref, "description": ""} + if exception is None: + api = OpenAPI.loads("loader.yaml", SPECTPL.format(**values), loader=loader) + else: + with pytest.raises(exception): + api = OpenAPI.loads("loader.yaml", SPECTPL.format(**values), loader=loader) + + +def test_loader_decode(): + with pytest.raises(ValueError, match="encoding"): + Loader.decode(b"rvice.\r\n \xa9 2020, 3GPP Organ", codec="utf-8") + + +def test_loader_format(): + values = {"jsonref": "'#/components/schemas/Example'", "description": ""} + spec = SPECTPL.format(**values) + api = OpenAPI.loads("loader.yaml", spec) + loader = NullLoader() + spec = loader.parse(Plugins([]), Path("loader.yaml"), spec) + spec = json.dumps(spec) + api = OpenAPI.loads("loader.json", spec) + + +def test_webload(): + name = "https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/network/resource-manager/Microsoft.Network/stable/2018-10-01/serviceEndpointPolicy.json" + from aiopenapi3.loader import WebLoader + import yarl + + loader = WebLoader(yarl.URL(name)) + api = OpenAPI.load_sync(name, loader=loader) diff --git a/tests/model_test.py b/tests/model_test.py new file mode 100644 index 0000000..3839e46 --- /dev/null +++ b/tests/model_test.py @@ -0,0 +1,169 @@ +import datetime +import random +import sys +import asyncio +import uuid + +import pydantic + +from tests.api.v2.schema import Dog + +import pytest +import pytest_asyncio + +import uvloop +from hypercorn.asyncio import serve +from hypercorn.config import Config + +import aiopenapi3 +from aiopenapi3.v30.schemas import Schema + +from tests.api.main import app + + +@pytest.fixture(scope="session") +def config(unused_tcp_port_factory): + c = Config() + c.bind = [f"localhost:{unused_tcp_port_factory()}"] + return c + + +@pytest.fixture(scope="session") +def event_loop(request): + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope="session") +async def server(event_loop, config): + uvloop.install() + try: + sd = asyncio.Event() + task = event_loop.create_task(serve(app, config, shutdown_trigger=sd.wait)) + yield config + finally: + sd.set() + await task + + +@pytest.fixture(scope="session", params=[2]) +def version(request): + return f"v{request.param}" + + +@pytest_asyncio.fixture(scope="session") +async def client(event_loop, server, version): + url = f"http://{server.bind[0]}/{version}/openapi.json" + api = await aiopenapi3.OpenAPI.load_async(url) + return api + + +def test_Pet(): + data = Dog.schema() + shma = Schema.parse_obj(data) + shma._identity = "Dog" + assert shma.get_type().schema() == data + + +@pytest.mark.asyncio +@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires asyncio.to_thread") +async def test_sync(event_loop, server, version): + url = f"http://{server.bind[0]}/{version}/openapi.json" + api = await asyncio.to_thread(aiopenapi3.OpenAPI.load_sync, url) + return api + + +@pytest.mark.asyncio +async def test_model(event_loop, server, client): + orig = client.components.schemas["WhiteCat"].dict(exclude_unset=True) + crea = client.components.schemas["WhiteCat"].get_type().schema() + assert orig == crea + + orig = client.components.schemas["Cat"].dict(exclude_unset=True, by_alias=True) + crea = ( + client.components.schemas["Cat"].get_type().schema(ref_template="#/components/schemas/{model}", by_alias=True) + ) + if "definitions" in crea: + del crea["definitions"] + assert crea == orig + + orig = client.components.schemas["Pet"].dict(exclude_unset=True, by_alias=True) + crea = ( + client.components.schemas["Pet"].get_type().schema(ref_template="#/components/schemas/{model}", by_alias=True) + ) + if "definitions" in crea: + del crea["definitions"] + assert crea == orig + + +def randomPet(client, name=None): + if name: + return client._.createPet.data.get_type().construct( + pet=client.components.schemas["Dog"] + .get_type() + .construct(name=name, age=datetime.timedelta(seconds=random.randint(1, 2 ** 32))) + ) + else: + return { + "pet": client.components.schemas["WhiteCat"] + .model({"name": str(uuid.uuid4()), "white_name": str(uuid.uuid4())}) + .dict() + } + + +@pytest.mark.asyncio +async def test_Request(event_loop, server, client): + client._.createPet.data + client._.createPet.parameters + client._.createPet.args() + client._.createPet.return_value() + + +@pytest.mark.asyncio +async def test_createPet(event_loop, server, client): + data = { + "pet": client.components.schemas["WhiteCat"] + .model({"name": str(uuid.uuid4()), "white_name": str(uuid.uuid4())}) + .dict() + } + # r = await client._.createPet( data=data) + r = await client._.createPet(data=data) + assert type(r.__root__.__root__).schema() == client.components.schemas["WhiteCat"].get_type().schema() + + r = await client._.createPet(data=randomPet(client, name=r.__root__.__root__.name)) + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() + + with pytest.raises(pydantic.ValidationError): + cls = client._.createPet.data.get_type() + cls() + + +@pytest.mark.asyncio +async def test_listPet(event_loop, server, client): + r = await client._.createPet(data=randomPet(client, str(uuid.uuid4()))) + l = await client._.listPet() + assert len(l) > 0 + + +@pytest.mark.asyncio +async def test_getPet(event_loop, server, client): + pet = await client._.createPet(data=randomPet(client, str(uuid.uuid4()))) + r = await client._.getPet(parameters={"pet_id": pet.__root__.identifier}) + assert type(r.__root__).schema() == type(pet.__root__).schema() + + r = await client._.getPet(parameters={"pet_id": "-1"}) + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() + + +@pytest.mark.asyncio +async def test_deletePet(event_loop, server, client): + r = await client._.deletePet(parameters={"pet_id": -1}) + assert type(r).schema() == client.components.schemas["Error"].get_type().schema() + + await client._.createPet(data=randomPet(client, str(uuid.uuid4()))) + zoo = await client._.listPet() + for pet in zoo: + while hasattr(pet, "__root__"): + pet = pet.__root__ + await client._.deletePet(parameters={"pet_id": pet.identifier}) diff --git a/tests/parse_data_test.py b/tests/parse_data_test.py new file mode 100644 index 0000000..9251b34 --- /dev/null +++ b/tests/parse_data_test.py @@ -0,0 +1,43 @@ +import pytest +import sys + +if sys.version_info >= (3, 9): + import pathlib +else: + import pathlib3x as pathlib + +import yarl + +import aiopenapi3.loader +from aiopenapi3 import FileSystemLoader, OpenAPI + +URLBASE = yarl.URL("http://127.1.1.1/open5gs/") + + +def pytest_generate_tests(metafunc): + argnames, dir, filterfn = metafunc.cls.params[metafunc.function.__name__] + dir = pathlib.Path(dir).expanduser() + metafunc.parametrize( + argnames, + [[dir, i.name] for i in sorted(filter(filterfn, dir.iterdir() if dir.exists() else []), key=lambda x: x.name)], + ) + + +class TestParseData: + # a map specifying multiple argument sets for a test method + params = { + "test_data": [("dir", "file"), "tests/data", lambda x: x.is_file() and x.suffix in (".json", ".yaml")], + "test_data_open5gs": [ + ("dir", "file"), + "tests/data/open5gs/", + lambda x: x.is_file() + and x.suffix in (".json", ".yaml") + and x.name.split("_")[0] not in ("TS29520", "TS29509", "TS29544", "TS29517"), + ], + } + + def test_data(self, dir, file): + OpenAPI.load_file(str(URLBASE / file), pathlib.Path(file), loader=FileSystemLoader(pathlib.Path(dir))) + + def test_data_open5gs(self, dir, file): + OpenAPI.load_file(str(URLBASE / file), pathlib.Path(file), loader=FileSystemLoader(pathlib.Path(dir))) diff --git a/tests/parsing_test.py b/tests/parsing_test.py index 74bcda3..9799e3a 100644 --- a/tests/parsing_test.py +++ b/tests/parsing_test.py @@ -1,26 +1,35 @@ """ Tests parsing specs """ +import dataclasses +import sys + +if sys.version_info >= (3, 9): + pass +else: + import pathlib3x as pathlib + import pytest -from openapi3 import OpenAPI, SpecError, ReferenceResolutionError +from pydantic import ValidationError +from aiopenapi3 import OpenAPI, SpecError, ReferenceResolutionError, FileSystemLoader + +URLBASE = "/" def test_parse_from_yaml(petstore_expanded): """ Tests that we can parse a valid yaml file """ - spec = OpenAPI(petstore_expanded) + spec = OpenAPI(URLBASE, petstore_expanded) def test_parsing_fails(broken): """ Tests that broken specs fail to parse """ - with pytest.raises( - SpecError, match=r"Expected .info to be of type Info, with required fields \['title', 'version'\]" - ): - spec = OpenAPI(broken) + with pytest.raises(ValidationError) as e: + spec = OpenAPI(URLBASE, broken) def test_parsing_broken_refernece(broken_reference): @@ -28,7 +37,7 @@ def test_parsing_broken_refernece(broken_reference): Tests that parsing fails correctly when a reference is broken """ with pytest.raises(ReferenceResolutionError): - spec = OpenAPI(broken_reference) + spec = OpenAPI(URLBASE, broken_reference) def test_parsing_wrong_parameter_name(has_bad_parameter_name): @@ -37,7 +46,7 @@ def test_parsing_wrong_parameter_name(has_bad_parameter_name): actually in the path. """ with pytest.raises(SpecError, match="Parameter name not found in path: different"): - spec = OpenAPI(has_bad_parameter_name) + spec = OpenAPI(URLBASE, has_bad_parameter_name) def test_parsing_dupe_operation_id(dupe_op_id): @@ -45,22 +54,22 @@ def test_parsing_dupe_operation_id(dupe_op_id): Tests that duplicate operation Ids are an error """ with pytest.raises(SpecError, match="Duplicate operationId dupe"): - spec = OpenAPI(dupe_op_id) + spec = OpenAPI(URLBASE, dupe_op_id) def test_parsing_parameter_name_with_underscores(parameter_with_underscores): """ Tests that path parameters with underscores in them are accepted """ - spec = OpenAPI(parameter_with_underscores) + spec = OpenAPI(URLBASE, parameter_with_underscores) def test_object_example(obj_example_expanded): """ Tests that `example` exists. """ - spec = OpenAPI(obj_example_expanded) - schema = spec.paths["/check-dict"].get.responses["200"].content["application/json"].schema + spec = OpenAPI(URLBASE, obj_example_expanded) + schema = spec.paths["/check-dict"].get.responses["200"].content["application/json"].schema_ assert isinstance(schema.example, dict) assert isinstance(schema.example["real"], float) @@ -72,8 +81,8 @@ def test_parsing_float_validation(float_validation_expanded): """ Tests that `minimum` and similar validators work with floats. """ - spec = OpenAPI(float_validation_expanded) - properties = spec.paths["/foo"].get.responses["200"].content["application/json"].schema.properties + spec = OpenAPI(URLBASE, float_validation_expanded) + properties = spec.paths["/foo"].get.responses["200"].content["application/json"].schema_.properties assert isinstance(properties["integer"].minimum, int) assert isinstance(properties["integer"].maximum, int) @@ -85,7 +94,7 @@ def test_parsing_with_links(with_links): """ Tests that "links" parses correctly """ - spec = OpenAPI(with_links) + spec = OpenAPI(URLBASE, with_links) assert "exampleWithOperationRef" in spec.components.links assert spec.components.links["exampleWithOperationRef"].operationRef == "/with-links" @@ -96,39 +105,60 @@ def test_parsing_with_links(with_links): response_b = spec.paths["/with-links-two/{param}"].get.responses["200"] assert "exampleWithRef" in response_b.links - assert response_b.links["exampleWithRef"] == spec.components.links["exampleWithOperationRef"] - - -def test_param_types(with_param_types): - spec = OpenAPI(with_param_types, validate=True) - - errors = spec.errors() - - assert len(errors) == 0 + assert response_b.links["exampleWithRef"]._target == spec.components.links["exampleWithOperationRef"] def test_parsing_broken_links(with_broken_links): """ Tests that broken "links" values error properly """ - spec = OpenAPI(with_broken_links, validate=True) + with pytest.raises(ValidationError) as e: + spec = OpenAPI(URLBASE, with_broken_links) - errors = spec.errors() - - assert len(errors) == 2 - error_strs = [str(e) for e in errors] - assert ( - sorted( - [ + assert all( + [ + i in str(e.value) + for i in [ "operationId and operationRef are mutually exclusive, only one of them is allowed", "operationId and operationRef are mutually exclusive, one of them must be specified", ] - ) - == sorted(error_strs) + ] ) def test_securityparameters(with_securityparameters): - spec = OpenAPI(with_securityparameters, validate=True) - errors = spec.errors() - assert len(errors) == 0 + spec = OpenAPI(URLBASE, with_securityparameters) + + +def test_callback(with_callback): + spec = OpenAPI(URLBASE, with_callback) + + +@dataclasses.dataclass +class _Version: + major: int + minor: int + patch: int = 0 + + def __str__(self): + if self.major == 3: + return f'openapi: "{self.major}.{self.minor}.{self.patch}"' + else: + return f'swagger: "{self.major}.{self.minor}"' + + +@pytest.fixture(scope="session", params=[_Version(2, 0), _Version(3, 0, 3), _Version(3, 1, 0)]) +def openapi_version(request): + return request.param + + +def test_extended_paths(openapi_version): + DOC = f"""{openapi_version} +info: + title: '' + version: 0.0.0 +paths: + x-codegen-contextRoot: /apis/registry/v2 +""" + api = OpenAPI.loads("test.yaml", DOC) + print(api) diff --git a/tests/path_test.py b/tests/path_test.py index 94b8dd3..7e5c429 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -3,24 +3,26 @@ """ import base64 import uuid - -from unittest.mock import patch, MagicMock -from urllib.parse import urlparse +import pathlib import pytest -import requests.auth +import httpx +import yarl + +from aiopenapi3 import OpenAPI +from aiopenapi3.v30.schemas import Schema + -from openapi3 import OpenAPI -from openapi3.schemas import Schema +URLBASE = "/" def test_paths_exist(petstore_expanded_spec): """ Tests that paths are parsed correctly """ - assert "/pets" in petstore_expanded_spec.paths - assert "/pets/{id}" in petstore_expanded_spec.paths - assert len(petstore_expanded_spec.paths) == 2 + assert "/pets" in petstore_expanded_spec.paths._paths + assert "/pets/{id}" in petstore_expanded_spec.paths._paths + assert len(petstore_expanded_spec.paths._paths) == 2 def test_operations_exist(petstore_expanded_spec): @@ -39,6 +41,9 @@ def test_operations_exist(petstore_expanded_spec): assert pets_id_path.put is None assert pets_id_path.delete is not None + for operation in petstore_expanded_spec._: + continue + def test_operation_populated(petstore_expanded_spec): """ @@ -60,18 +65,18 @@ def test_operation_populated(petstore_expanded_spec): assert param1.description == "tags to filter by" assert param1.required == False assert param1.style == "form" - assert param1.schema is not None - assert param1.schema.type == "array" - assert param1.schema.items.type == "string" + assert param1.schema_ is not None + assert param1.schema_.type == "array" + assert param1.schema_.items.type == "string" param2 = op.parameters[1] assert param2.name == "limit" assert param2.in_ == "query" assert param2.description == "maximum number of results to return" assert param2.required == False - assert param2.schema is not None - assert param2.schema.type == "integer" - assert param2.schema.format == "int32" + assert param2.schema_ is not None + assert param2.schema_.type == "integer" + assert param2.schema_.format == "int32" # check that responses populated correctly assert "200" in op.responses @@ -83,76 +88,113 @@ def test_operation_populated(petstore_expanded_spec): assert len(resp1.content) == 1 assert "application/json" in resp1.content con1 = resp1.content["application/json"] - assert con1.schema is not None - assert con1.schema.type == "array" + assert con1.schema_ is not None + assert con1.schema_.type == "array" # we're not going to test that the ref resolved correctly here - that's a separate test - assert type(con1.schema.items) == Schema + assert type(con1.schema_.items._target) == Schema resp2 = op.responses["default"] assert resp2.description == "unexpected error" assert len(resp2.content) == 1 assert "application/json" in resp2.content con2 = resp2.content["application/json"] - assert con2.schema is not None + assert con2.schema_ is not None # again, test ref resolution elsewhere - assert type(con2.schema) == Schema + assert type(con2.schema_._target) == Schema -def test_securityparameters(with_securityparameters): - api = OpenAPI(with_securityparameters) - r = patch("requests.sessions.Session.send") +def test_securityparameters(httpx_mock, with_securityparameters): + api = OpenAPI(URLBASE, with_securityparameters, session_factory=httpx.Client) + httpx_mock.add_response(headers={"Content-Type": "application/json"}, content=b"[]") auth = str(uuid.uuid4()) + for i in api.paths.values(): + if not i.post or not i.post.security: + continue + s = i.post.security[0] + # assert type(s.name) == str + # assert type(s.types) == list + break + else: + assert False + + with pytest.raises(ValueError, match=r"does not accept security schemes \['xAuth'\]"): + api.authenticate(xAuth=auth) + api._.api_v1_auth_login_info(data={}, parameters={}) + # global security - api.authenticate("cookieAuth", auth) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) + api.authenticate(None, cookieAuth=auth) + api._.api_v1_auth_login_info(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] # path - api.authenticate("tokenAuth", auth) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - assert r.call_args.args[0].headers["Authorization"] == auth - - api.authenticate("paramAuth", auth) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - - parsed_url = urlparse(r.call_args.args[0].url) - parsed_url.query == auth - - api.authenticate("cookieAuth", auth) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}, json=lambda: []) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - assert r.call_args.args[0].headers["Cookie"] == "Session=%s" % (auth,) - - api.authenticate("basicAuth", (auth, auth)) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}, json=lambda: []) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - r.call_args.args[0].headers["Authorization"].split(" ")[1] == base64.b64encode( - (auth + ":" + auth).encode() - ).decode() - - api.authenticate("digestAuth", (auth, auth)) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}, json=lambda: []) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - assert requests.auth.HTTPDigestAuth.handle_401 == r.call_args.args[0].hooks["response"][0].__func__ - - api.authenticate("bearerAuth", auth) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}, json=lambda: []) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - assert r.call_args.args[0].headers["Authorization"] == "Bearer %s" % (auth,) - - api.authenticate(None, None) - resp = MagicMock(status_code=200, headers={"Content-Type": "application/json"}) - with patch("requests.sessions.Session.send", return_value=resp) as r: - api.call_api_v1_auth_login_create(data={}, parameters={}) - api.call_api_v1_auth_login_create(data={}, parameters={}) + api.authenticate(None, tokenAuth=auth) + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.headers["Authorization"] == auth + + api.authenticate(None, paramAuth=auth) + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert yarl.URL(str(request.url)).query["auth"] == auth + + api.authenticate(None, cookieAuth=auth) + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.headers["Cookie"] == "Session=%s" % (auth,) + + api.authenticate(None, basicAuth=(auth, auth)) + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.headers["Authorization"].split(" ")[1] == base64.b64encode((auth + ":" + auth).encode()).decode() + + api.authenticate(None, digestAuth=(auth, auth)) + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + # can't test? + + api.authenticate(None, bearerAuth=auth) + api._.api_v1_auth_login_create(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.headers["Authorization"] == "Bearer %s" % (auth,) + + # null session + api.authenticate(None) + api._.api_v1_auth_login_info(data={}, parameters={}) + + +def test_combined_security(httpx_mock, with_securityparameters): + api = OpenAPI(URLBASE, with_securityparameters, session_factory=httpx.Client) + httpx_mock.add_response(headers={"Content-Type": "application/json"}, content=b"[]") + + auth = str(uuid.uuid4()) + + # combined + api.authenticate(user="test") + with pytest.raises(ValueError, match="No security requirement satisfied"): + r = api._.api_v1_auth_login_combined(data={}, parameters={}) + + api.authenticate(**{"user": "theuser", "token": "thetoken"}) + r = api._.api_v1_auth_login_combined(data={}, parameters={}) + + api.authenticate(None) + with pytest.raises(ValueError, match="No security requirement satisfied"): + r = api._.api_v1_auth_login_combined(data={}, parameters={}) + + +def test_parameters(httpx_mock, with_parameters): + httpx_mock.add_response(headers={"Content-Type": "application/json"}, content=b"[]") + api = OpenAPI(URLBASE, with_parameters, session_factory=httpx.Client) + + with pytest.raises(ValueError, match=r"Required parameter \w+ not provided"): + api._.getTest(data={}, parameters={}) + + Header = str([i ** i for i in range(3)]) + api._.getTest(data={}, parameters={"Cookie": "Cookie", "Path": "Path", "Header": Header, "Query": "Query"}) + request = httpx_mock.get_requests()[-1] + + assert request.headers["Header"] == Header + assert request.headers["Cookie"] == "Cookie=Cookie" + assert pathlib.Path(request.url.path).name == "Path" + assert yarl.URL(str(request.url)).query["Query"] == "Query" diff --git a/tests/plugin_test.py b/tests/plugin_test.py new file mode 100644 index 0000000..5e81af0 --- /dev/null +++ b/tests/plugin_test.py @@ -0,0 +1,115 @@ +import httpx +import sys + +if sys.version_info >= (3, 9): + from pathlib import Path +else: + from pathlib3x import Path + +import yarl + +from aiopenapi3 import FileSystemLoader, OpenAPI +from aiopenapi3.plugin import Init, Message, Document + + +class OnInit(Init): + def initialized(self, ctx): + ctx.initialized.paths["/pets"].get.operationId = "listPets" + return ctx + + +class OnDocument(Document): + def loaded(self, ctx): + return ctx + + def parsed(self, ctx): + if ctx.url == "test.yaml": + ctx.document["components"] = { + "schemas": {"Pet": {"$ref": "petstore-expanded.yaml#/components/schemas/Pet"}} + } + ctx.document["servers"] = [{"url": "/"}] + elif ctx.url == "petstore-expanded.yaml": + + ctx.document["components"]["schemas"]["Pet"]["allOf"].append( + { + "type": "object", + "required": ["color"], + "properties": { + "color": {"type": "string", "default": "blue"}, + "weight": {"type": "integer", "default": 10}, + }, + } + ) + else: + raise ValueError(ctx.url) + + return ctx + + +class OnMessage(Message): + def marshalled(self, ctx): + return ctx + + def sending(self, ctx): + return ctx + + def received(self, ctx): + ctx.received = """[{"id":1,"name":"theanimal"}]""" + return ctx + + def parsed(self, ctx): + if ctx.operationId == "listPets": + if ctx.parsed[0].get("color", None) is None: + ctx.parsed[0]["color"] = "red" + + if ctx.parsed[0]["id"] == 1: + ctx.parsed[0]["id"] = 2 + return ctx + + def unmarshalled(self, ctx): + if ctx.operationId == "listPets": + if ctx.unmarshalled[0].id == 2: + ctx.unmarshalled[0].id = 3 + return ctx + + +SPEC = """ +openapi: 3.0.3 +info: + title: '' + version: 0.0.0 +paths: + /pets: + get: + description: '' + operationId: xPets + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' +""" + + +def test_Plugins(httpx_mock): + httpx_mock.add_response(headers={"Content-Type": "application/json"}, content=b"[]") + plugins = [OnInit(), OnDocument(), OnMessage()] + api = OpenAPI.loads( + "test.yaml", + SPEC, + plugins=plugins, + loader=FileSystemLoader(Path().cwd() / "tests/fixtures"), + session_factory=httpx.Client, + ) + api._base_url = yarl.URL("http://127.0.0.1:80") + r = api._.listPets() + assert r + + item = r[0] + assert item.id == 3 + assert item.weight == None # default does not apply as it it unsed + assert item.color == "red" # default does not apply diff --git a/tests/ref_test.py b/tests/ref_test.py index f7999be..96ce7cc 100644 --- a/tests/ref_test.py +++ b/tests/ref_test.py @@ -1,20 +1,34 @@ +from __future__ import annotations +import sys + """ This file tests that $ref resolution works as expected, and that allOfs are populated as expected as well. """ + +if sys.version_info >= (3, 8): + import typing +else: + # fot typing.get_origin + import typing_extensions as typing + + +import dataclasses import pytest +from aiopenapi3 import OpenAPI -from openapi3 import OpenAPI -from openapi3.schemas import Schema +from pydantic.main import ModelMetaclass def test_ref_resolution(petstore_expanded_spec): """ Tests that $refs are resolved as we expect them to be """ - ref = petstore_expanded_spec.paths["/pets"].get.responses["default"].content["application/json"].schema + from aiopenapi3.v30.schemas import Schema + + ref = petstore_expanded_spec.paths["/pets"].get.responses["default"].content["application/json"].schema_ - assert type(ref) == Schema + assert type(ref._target) == Schema assert ref.type == "object" assert len(ref.properties) == 2 assert "code" in ref.properties @@ -33,62 +47,67 @@ def test_allOf_resolution(petstore_expanded_spec): """ Tests that allOfs are resolved correctly """ - ref = petstore_expanded_spec.paths["/pets"].get.responses["200"].content["application/json"].schema - ref = petstore_expanded_spec.paths["/pets"].get.responses["200"].content["application/json"].schema + ref = petstore_expanded_spec.paths["/pets"].get.responses["200"].content["application/json"].schema_.get_type() - assert type(ref) == Schema - assert ref.type == "array" - assert ref.items is not None + assert type(ref) == ModelMetaclass + assert typing.get_origin(ref.__fields__["__root__"].outer_type_) == list - items = ref.items - assert type(items) == Schema - assert sorted(items.required) == sorted(["id", "name"]) - assert len(items.properties) == 3 - assert "id" in items.properties - assert "name" in items.properties - assert "tag" in items.properties + items = typing.get_args(ref.__fields__["__root__"].outer_type_)[0].__fields__ - id_prop = items.properties["id"] - id_prop = items.properties["id"] - assert id_prop.type == "integer" - assert id_prop.format == "int64" + assert sorted(map(lambda x: x.name, filter(lambda y: y.required == True, items.values()))) == sorted(["id", "name"]) - name = items.properties["name"] - name = items.properties["name"] - assert name.type == "string" + assert sorted(map(lambda x: x.name, items.values())) == ["id", "name", "tag"] - tag = items.properties["tag"] - assert tag.type == "string" + assert items["id"].outer_type_ == int + assert items["name"].outer_type_ == str + assert items["tag"].outer_type_ == str -def test_resolving_nested_allof_ref(with_nested_allof_ref): - """ - Tests that a schema with a $ref nested within a schema defined in an allOf - parses correctly - """ - spec = OpenAPI(with_nested_allof_ref) +@dataclasses.dataclass +class _Version: + major: int + minor: int + patch: int - schema = spec.paths['/example'].get.responses['200'].content['application/json'].schema - assert type(schema.properties['other']) == Schema - assert schema.properties['other'].type == 'string' + def __str__(self): + return f"{self.major}.{self.minor}.{self.patch}" - assert type(schema.properties['data'].items) == Schema - assert 'bar' in schema.properties['data'].items.properties +@pytest.fixture(scope="session", params=[_Version(3, 0, 3), _Version(3, 1, 0)]) +def openapi_version(request): + return request.param -def test_ref_allof_handling(with_ref_allof): - """ - Tests that allOfs do not modify the originally loaded value of a $ref they - includes (which would cause all references to that schema to be modified) + +def test_schemaref(openapi_version): + import aiopenapi3.v30.general + import aiopenapi3.v31.general + + expected = {0: aiopenapi3.v30.general.Reference, 1: aiopenapi3.v31.general.Reference}[openapi_version.minor] + + SPEC = f"""openapi: {openapi_version} +info: + title: API + version: 1.0.0 +paths: + /pets: + get: + description: yes + operationId: findPets + responses: + '200': + description: pet response + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' +components: + schemas: + Pet: + type: str """ - spec = OpenAPI(with_ref_allof) - referenced_schema = spec.components.schemas['Example'] - - # this should have only one property; the allOf from - # paths['/allof-example']get.responses['200'].content['application/json'].schema - # should not modify the component - assert len(referenced_schema.properties) == 1, \ - "Unexpectedly found {} properties on componenets.schemas['Example']: {}".format( - len(referenced_schema.properties), - ", ".join(referenced_schema.properties.keys()), - ) + api = OpenAPI.loads("test.yaml", SPEC) + print(api) + + assert api.paths["/pets"].get.responses["200"].content["application/json"].schema_.items.__class__ == expected diff --git a/tests/schema_test.py b/tests/schema_test.py index 5da2df7..f99f0a7 100644 --- a/tests/schema_test.py +++ b/tests/schema_test.py @@ -1,15 +1,13 @@ +import httpx import pytest +from pydantic import ValidationError -from openapi3 import OpenAPI -from openapi3.errors import ModelError +from aiopenapi3 import OpenAPI -from unittest.mock import patch, MagicMock +def test_invalid_response(httpx_mock, petstore_expanded): + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json={"foo": 1}) + api = OpenAPI("test.yaml", petstore_expanded, session_factory=httpx.Client) -def test_invalid_response(petstore_expanded): - api = OpenAPI(petstore_expanded) - resp = MagicMock(status_code=200, headers={"Content-Type":"application/json"}, json=lambda: {'foo':1}) - with patch("requests.sessions.Session.send", return_value=resp) as s: - with pytest.raises(ModelError, match="Schema Pet got unexpected attribute keys {'foo'}") as r: - api.call_find_pet_by_id(data={}, parameters={"id":1}) - print(r) \ No newline at end of file + with pytest.raises(ValidationError, match="2 validation errors for Pet") as r: + p = api._.find_pet_by_id(data={}, parameters={"id": 1}) diff --git a/tests/swagger_test.py b/tests/swagger_test.py new file mode 100644 index 0000000..3f66f8b --- /dev/null +++ b/tests/swagger_test.py @@ -0,0 +1,102 @@ +import uuid + +import yarl +import httpx +import pytest + +from aiopenapi3 import OpenAPI + +URLBASE = "/" + + +def test_parse_swagger(with_swagger): + api = OpenAPI(URLBASE, with_swagger) + + +def test_swagger_url(with_swagger): + api = OpenAPI(URLBASE, with_swagger) + assert str(api.url) == "https://api.example.com/v1" + + +def test_securityparameters(httpx_mock, with_swagger): + api = OpenAPI(URLBASE, with_swagger, session_factory=httpx.Client) + user = api._.createUser.return_value().get_type().construct(name="test", id=1) + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=user.dict()) + + auth = str(uuid.uuid4()) + + with pytest.raises(ValueError, match=r"does not accept security schemes \['xAuth'\]"): + api.authenticate(xAuth=auth) + api._.createUser(data=user, parameters={}) + + # global security + api.authenticate(None, BasicAuth=(auth, auth)) + api._.getUser(data={}, parameters={"userId": 1}) + request = httpx_mock.get_requests()[-1] + + # path + api.authenticate(None, QueryAuth=auth) + api._.createUser(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.url.params["auth"] == auth + + # header + api.authenticate(None, HeaderAuth="Bearer %s" % (auth,)) + api._.createUser(data={}, parameters={}) + request = httpx_mock.get_requests()[-1] + assert request.headers["Authorization"] == "Bearer %s" % (auth,) + + # null session + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=[user.dict()]) + api.authenticate(None) + api._.listUsers(data={}, parameters={}) + + +def test_combined_securityparameters(httpx_mock, with_swagger): + api = OpenAPI(URLBASE, with_swagger, session_factory=httpx.Client) + user = api._.createUser.return_value().get_type().construct(name="test", id=1) + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=user.dict()) + + api.authenticate(user="u") + with pytest.raises(ValueError, match="No security requirement satisfied"): + api._.combinedSecurity(data={}, parameters={}) + + api.authenticate(**{"user": "u", "token": "t"}) + api._.combinedSecurity(data={}, parameters={}) + + api.authenticate(None) + with pytest.raises(ValueError, match="No security requirement satisfied"): + api._.combinedSecurity(data={}, parameters={}) + + +def test_post_body(httpx_mock, with_swagger): + + auth = str(uuid.uuid4()) + api = OpenAPI(URLBASE, with_swagger, session_factory=httpx.Client) + user = api._.createUser.return_value().get_type().construct(name="test", id=1) + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=user.dict()) + + api.authenticate(HeaderAuth="Bearer %s" % (auth,)) + with pytest.raises(ValueError, match="Request Body is required but none was provided."): + api._.createUser(data=None, parameters={}) + api._.createUser(data={}, parameters={}) + api._.createUser(data=user, parameters={}) + + +def test_parameters(httpx_mock, with_swagger): + api = OpenAPI(URLBASE, with_swagger, session_factory=httpx.Client) + user = api._.createUser.return_value().get_type().construct(name="test", id=1) + + auth = str(uuid.uuid4()) + api.authenticate(BasicAuth=(auth, auth)) + + with pytest.raises(ValueError, match=r"Required parameter \w+ not provided"): + api._.getUser(data={}, parameters={}) + + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json=[user.dict()]) + api.authenticate(None) + api._.listUsers(data={}, parameters={"inQuery": "Q", "inHeader": "H"}) + + request = httpx_mock.get_requests()[-1] + assert request.headers["inHeader"] == "H" + assert yarl.URL(str(request.url)).query["inQuery"] == "Q"