├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .semaphore └── semaphore.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── docs ├── client.md ├── index.md └── server.md ├── mkdocs.yml ├── pyproject.toml ├── setup.cfg ├── src ├── examples │ ├── __init__.py │ ├── client.py │ ├── petstore.yaml │ └── server │ │ ├── __init__.py │ │ ├── pets.py │ │ └── server.py ├── pyotr │ ├── client │ │ ├── __init__.py │ │ └── validation.py │ ├── server │ │ ├── __init__.py │ │ └── validation.py │ └── utils.py └── tests │ ├── __init__.py │ ├── conftest.py │ ├── endpoints.py │ ├── openapi.json │ ├── openapi.unknown │ ├── openapi.yaml │ ├── test_client.py │ └── test_server.py └── tox.ini /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | Pipfile 96 | Pipfile.lock 97 | 98 | # poetry 99 | poetry.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # Cython debug symbols 142 | cython_debug/ 143 | 144 | # various 145 | .mutmut-cache 146 | .hammett-db 147 | 148 | 149 | # PyCharm 150 | .idea/ -------------------------------------------------------------------------------- /.semaphore/semaphore.yml: -------------------------------------------------------------------------------- 1 | version: v1.0 2 | name: Python 3 | agent: 4 | machine: 5 | type: e1-standard-2 6 | os_image: ubuntu1804 7 | blocks: 8 | - name: Code checks 9 | dependencies: [] 10 | task: 11 | jobs: 12 | - name: checks 13 | commands: 14 | - sem-version python 3.8 15 | - checkout 16 | - python -m pip install -U pip poetry tox-poetry 17 | - python -m tox -e checks 18 | - name: Unit tests 19 | dependencies: ["Code checks"] 20 | task: 21 | jobs: 22 | - name: tests 23 | matrix: 24 | - env_var: PY_VERSION 25 | values: [ "3.8", "3.9" ] 26 | commands: 27 | - sem-version python $PY_VERSION 28 | - checkout 29 | - python -m pip install -U pip poetry tox-poetry 30 | - python -m tox -e py"${PY_VERSION//.}" 31 | - name: Documentation 32 | dependencies: ["Code checks"] 33 | task: 34 | jobs: 35 | - name: docs 36 | commands: 37 | - sem-version python 3.8 38 | - checkout 39 | - python -m pip install -U pip mkdocs "jinja2<3.1" 40 | - python -m mkdocs build 41 | epilogue: 42 | on_pass: 43 | commands: 44 | - artifact push job site -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at pyotr@jotme.net. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Berislav Lopac 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Pyotr 2 | ===== 3 | 4 | [![Documentation Status](https://readthedocs.org/projects/pyotr/badge/?version=latest)](https://pyotr.readthedocs.io/en/latest/) 5 | [![CI builds](https://b11c.semaphoreci.com/badges/pyotr.svg?style=shields)](https://b11c.semaphoreci.com/projects/pyotr) 6 | 7 | **Pyotr** is a Python library for serving and consuming REST APIs based on 8 | [OpenAPI](https://swagger.io/resources/open-api/) specifications. Its name is acronym of "Python OpenAPI to REST". 9 | 10 | The project consists of two separate libraries that can be used independently: 11 | 12 | * `pyotr.server` is a [Starlette](https://www.starlette.io)-based framework for serving OpenAPI-based services. 13 | It is functionally very similar to [connexion](https://connexion.readthedocs.io), except that it aims to be fully 14 | [ASGI](https://asgi.readthedocs.io)-compliant. 15 | * `pyotr.client` is a HTTP client for consuming OpenAPI-based services. 16 | 17 | **WARNING:** This is still very much work in progress and not quite ready for production usage. Until version 1.0 is 18 | released, any version can be expected to break backward compatibility. 19 | 20 | 21 | Quick Start 22 | ----------- 23 | 24 | ### Server 25 | 26 | from pyotr.server import Application 27 | from some.path import endpoints 28 | 29 | app = Application.from_file("path/to/openapi.yaml", module=endpoints) 30 | 31 | ### Client 32 | 33 | from pyotr.client import Client 34 | 35 | client = Client.from_file("path/to/openapi.yaml") 36 | result = client.some_endpoint_id("path", "variables", "query_var"="example") 37 | -------------------------------------------------------------------------------- /docs/client.md: -------------------------------------------------------------------------------- 1 | # Pyotr Client 2 | 3 | Pyotr client has kind of an opposite approach from the server: it allows a user to call an `operationId` as if it was a Python method. 4 | 5 | Basic Usage 6 | ----------- 7 | 8 | Pyotr client is a class that takes a dictionary or a `Spec` object; there is also a helper class method that will load the spec from a provided file: 9 | 10 | from pyotr.client import Client 11 | client = Client.from_file("path/to/openapi.yaml") 12 | 13 | On instantiation, the client adds a number of methods to itself, each using a snake-case version of an `operationId` as a name. To make a request to the API, call the corresponding method; e.g. if the spec contains an `operationId` named `someEndpointId`, it can be called as: 14 | 15 | result = client.some_endpoint_id("foo", "bar", query_var="example") 16 | 17 | If the corresponding API endpoint accepts any path variables (e.g. `/root/{id}/{name}`), they can be passed in the form of positional arguments to the method call. Similarly, any query or body parameters can be passed as keyword arguments. 18 | 19 | Pyotr client will validate both an outgoing request before sending it, as well as the response after receiving, to confirm that they conform to the API specification. 20 | 21 | Advanced Usage 22 | -------------- 23 | 24 | The `Client` class accepts four optional, keyword-only arguments on instantiation: 25 | 26 | * `server_url`: A server hostname that will be used to make the actual requests. If it is not present in the `servers` list of the specification it will be appended, and if none is specified the first from the `servers` list will be used. 27 | * `client`: The HTTP client implementation used to make actual requests. By default Pyotr uses [`httpx`](https://www.encode.io/httpx/), but it can be replaced by any object compatible with the `Requests` client. 28 | * `request_class`: The class an outgoing request will be wrapped into, before validation. By default it is the built-in `ClientOpenAPIRequest`; if additional functionality is needed it can be substituted by its subclass. 29 | * `response_factory`: A callable used to construct the instance of `OpenAPIResponse` an incoming response will be wrapped into, before validation. By default it is the built-in `ClientOpenAPIResponse`; if additional functionality is needed it can be substituted by its subclass. 30 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction to Pyotr 2 | 3 | [![Documentation Status](https://readthedocs.org/projects/pyotr/badge/?version=latest)](https://pyotr.readthedocs.io/en/latest/?badge=latest) 4 | [![CI builds](https://b11c.semaphoreci.com/badges/pyotr.svg?style=shields)](https://b11c.semaphoreci.com/badges/pyotr.svg?style=shields) 5 | 6 | **Pyotr** is a Python library for serving and consuming REST APIs based on [OpenAPI](https://swagger.io/resources/open-api/) specifications. Its name is acronym of "Python OpenAPI to REST". 7 | 8 | The project consists of two separate libraries that can be used independently: 9 | 10 | * `pyotr.server` is a [Starlette](https://www.starlette.io)-based framework for serving OpenAPI-based services. It is functionally very similar to [connexion](https://connexion.readthedocs.io), except that it aims to be fully [ASGI](https://asgi.readthedocs.io)-compliant. 11 | * `pyotr.client` is a HTTP client for consuming OpenAPI-based services. It is a spiritual descendent of the 12 | [Bravado](https://github.com/Yelp/bravado) library, which currently supports only Swagger 2.0. 13 | 14 | **WARNING:** This is still very much work in progress and not nearly ready for any kind of production. 15 | 16 | Why Pyotr? 17 | ---------- 18 | 19 | The main advantage of Pyotr -- both as a server and as a client -- is that it uses the OpenAPI specification to both prepare and validate requests and responses. 20 | 21 | Specifically, when `pyotr.server` receives a request it validates it against the URLs defined in the specification 22 | , raising an error in case of an invalid request. On a valid request it looks for an "endpoint function" matching the 23 | request URL's `operationId`; this function is supposed to process a request and return a response, which is then 24 | also validated based on the specification rules before being sent back to the client. 25 | 26 | On the other hand, `pyotr.client` uses the `operationId` to generate a request based on the rules defined in the 27 | specification. The user needs to call the `operationId` as if it was a method of a client instance 28 | , optionally passing any specified arguments. 29 | -------------------------------------------------------------------------------- /docs/server.md: -------------------------------------------------------------------------------- 1 | Pyotr Server 2 | ============ 3 | 4 | 5 | Example Setup 6 | ------------- 7 | 8 | The minimal setup consists of three files: 9 | 10 | * a REST(ful) API specification in the form of an OpenAPI file, usually in YAML or JSON format 11 | * a Python file, e.g. `server.py`, which initiates your application 12 | * another Python file containing the individual endpoint functions 13 | 14 | The directory `src/examples/server/` contains a working example Pyotr server application, using the specification at 15 | `src/examples/petstore.yaml` -- which is a copy of the standard OpenAPI 16 | [example specification](https://editor.swagger.io/). 17 | 18 | To run the example, follow these steps inside a Python `virtualenv`: 19 | 20 | 1. Install [`poetry`](https://poetry.eustace.io/docs/#installation) 21 | 2. Update `pyotr` dependencies: `poetry update` 22 | 3. Start the API server: `uvicorn examples.server.server:app --reload --host 0.0.0.0 --port 5000 --log-level debug` 23 | 24 | 25 | Application 26 | ----------- 27 | 28 | A Pyotr application is an instance of the `pyotr.server.Application` class, which is a subclass of 29 | `starlette.applications.Starlette`; this means it is fully ASGI-compatible and can be used as any other ASGI app. 30 | 31 | When initialising a Pyotr server app, it is necessary to provide an OpenAPI specification file. 32 | 33 | For example: 34 | 35 | from pyotr.server import Application 36 | app = Application(spec=api_spec) 37 | 38 | The value of `spec` is either a Python dictionary of the OpenAPI spec, or an `openapi-core` `Spec` object. There 39 | is a helper class method which will load the spec provided a path in a specification file: 40 | 41 | app = Application.from_file('myserver/spec.yaml') 42 | 43 | Optionally, a module containing endpoint functions (see below) can be added as a keyword argument. It can be specified 44 | as the dot-separated path to the module location; in the above example, it might be the file `myserver/endpoints.py` 45 | or the directory `myserver/endpoints/`. Alternatively, `module` can be the actual imported module: 46 | 47 | from myserver import endpoints 48 | app = Application(api_spec, module=endpoints) 49 | 50 | The `Application` constructor also accepts the following keyword arguments: 51 | 52 | * `validate_responses`: Boolean (defaults to `True`) If `True`, each response will be validated against the spec 53 | before being sent back to the caller. 54 | * `enforce_case`: Boolean (defaults to `True`). If `true`, the `operationId` values will be normalized to snake case 55 | when setting endpoint functions. For example, `operationId` `fooBar` will expect the function named `foo_bar`. 56 | 57 | Any other keyword arguments provided to the `Application` constructor will be passed directly into the `Starlette` 58 | application class. 59 | 60 | 61 | Endpoints 62 | --------- 63 | 64 | ### Endpoint Functions 65 | 66 | An endpoint is a standard Python function, which needs to conform to the following requirements: 67 | 68 | 1. It needs to accept a single positional argument, a request object compatible with the Starlette 69 | [`Request`](https://www.starlette.io/requests/). 70 | 2. It has to return either a Python dictionary, or an object compatible with the Starlette 71 | [`Response`](https://www.starlette.io/responses/). If it is a dictionary, Pyotr will convert it into a 72 | `JSONResponse`. 73 | 3. It doesn't have to be a coroutine function (defined using `async def` syntax), but it is highly recommended, 74 | especially if it needs to perform any asynchronous operations itself (e.g. if it makes a call to an external API). 75 | 76 | A basic example of an endpoint function: 77 | 78 | async def get_pet_by_id(request): 79 | return { 80 | "id": request.path_params['id'], 81 | "species": "cat" 82 | "name": "Lady Athena", 83 | } 84 | 85 | ### Setting Endpoints on Application 86 | 87 | The OpenAPI spec defines the endpoints ("paths") that the API handles, as well as the requests and responses it can 88 | recognise. Each endpoint has a [field](https://swagger.io/specification/#operation-object) called `operationId`, 89 | which is supposed to be globally unique; Pyotr takes advantage of this field to find the corresponding endpoint 90 | function. 91 | 92 | Endpoint functions can be defined in several ways: 93 | 94 | 95 | #### A Python Module 96 | 97 | The first way is as a Python module that contains the endpoint functions. For example, assume that we have the module 98 | `server/endpoints.py`, looking something like this: 99 | 100 | async def foo_endpoint(request): 101 | return {"foo": "bar"} 102 | 103 | async def bar_endpoint(request): 104 | return {"bar": "foo"} 105 | 106 | We can then define our application in the following way: 107 | 108 | from myserver import endpoints 109 | 110 | app = Application(spec=api_spec, module=endpoints) 111 | 112 | Assuming, of course, that our OpenAPI spec contains `operationId`s named `fooEndpoint` and `barEndpoint`. 113 | 114 | 115 | #### A Python Module Path 116 | 117 | Alternatively, instead of an imported module we can pass a string in the form of a dot-separated path; for example, 118 | `myserver.endpoints`. The equivalent of the example above would now be: 119 | 120 | app = Application(spec=api_spec, module="myserver.endpoints") 121 | 122 | `pyotr` will try to locate the endpoint module by combining the `module` argument and the `operationId` value, 123 | converting the function name to snake case if necessary. E.g. if the base is `myserver.endpoints` and the 124 | `operationId` is `fooEndpoint`, it will import the `foo_endpoint` function located in either `myserver/endpoints.py` 125 | (or `myserver/endpoints/__init__.py`). Also, if the `operationId` value itself contains dots it will try to build 126 | the full path, so `some.extra.levels.fooBar` will look for the module `myserver/endpoints/some/extra/levels.py`. 127 | 128 | 129 | #### Setting Individual Endpoints 130 | 131 | The endpoints can also be set individually, using the `set_endpoint` method: 132 | 133 | from pyotr.server import Application 134 | from myserver.endpoints import some_endpoint, another_endpoint 135 | 136 | app = Application(spec=api_spec) 137 | 138 | app.set_endpoint(some_endpoint) 139 | 140 | Pyotr uses the function name to determine the operation. In the example above it would be set on the `operationId` 141 | named `someEndpoint`. Alternatively, the `operationId` can be provided explicitly: 142 | 143 | app.set_endpoint(another_endpoint, operation_id="someOtherOperationId") 144 | 145 | 146 | #### The Endpoint Decorator 147 | 148 | It is also possible to define endpoints as they are defines, using the `endpoint` decorator, which works analogous 149 | to the `set_endpoint` method: 150 | 151 | app = Application(spec=api_spec) 152 | 153 | @app.endpoint 154 | def some_endpoint(request): 155 | ... 156 | 157 | @app.endpoint(operation_id="someOtherOperationId"): 158 | def another_endpoint(request): 159 | ... 160 | 161 | @app.endpoint("aCompletelyDifferentOperationId"): 162 | def yet_another_endpoint(request): 163 | ... 164 | 165 | ### Additional Server Configuration 166 | 167 | The server instance can be additionally configured with a few OpenAPI-specific custom handlers: 168 | 169 | * **custom_formatters** is a dict of custom [formatter](https://github.com/p1c2u/openapi-core#formats) objects that will be applied both to requests and responses. 170 | * **custom_media_type_deserializers** are [functions](https://github.com/p1c2u/openapi-core#deserializers) that can deserialise custom media types. 171 | 172 | Example: 173 | 174 | app = Application(spec=api_spec) 175 | api.custom_formatters = { 176 | "email": EmailFormatter, 177 | } 178 | api.custom_media_type_deserializers = { 179 | "application/protobuf": protobuf_deserializer, 180 | } 181 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Pyotr Documentation 2 | site_url: https://pyotr.readthedocs.io 3 | repo_url: https://github.com/berislavlopac/pyotr 4 | site_description: Pyotr is a Python library for serving and consuming REST APIs based on OpenAPI specifications. 5 | site_author: Berislav Lopac 6 | theme: 7 | name: readthedocs 8 | nav: 9 | - Introduction: 'index.md' 10 | - Server: 'server.md' 11 | - Client: 'client.md' 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pyotr" 3 | version = "0.8.0" 4 | description = "Python OpenAPI-to-REST (and back) framework " 5 | authors = ["Berislav Lopac "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://pyotr.readthedocs.io" 9 | repository = "https://github.com/berislavlopac/pyotr" 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.8" 13 | PyYAML = "^6.0" 14 | stringcase = "^1.2.0" 15 | typing-extensions = "^4.1.1" 16 | uvicorn = "^0.17.6" 17 | httpx = "^0.22.0" 18 | starlette = "^0.19.0" 19 | openapi-core = "^0.14.2" 20 | 21 | [tool.poetry.extras] 22 | uvicorn = ["uvicorn"] 23 | 24 | [tool.poetry.dev-dependencies] 25 | mkdocs = "^1.2.3" 26 | pytest-asyncio = "^0.18.3" 27 | pytest-cov = "^3.0.0" 28 | pytest-flake8 = "^1.1.1" 29 | pytest-mypy = "^0.9.1" 30 | requests = "^2.27.1" 31 | pytest = "^7.1.1" 32 | black = "^22.1.0" 33 | pydocstyle = "^6.1.1" 34 | mypy = "^0.942" 35 | tox = "^3.24.5" 36 | toml = "^0.10.2" 37 | pytest-spec = "^3.2.0" 38 | tox-poetry = "^0.4.1" 39 | Jinja2 = "<3.1" 40 | 41 | [tool.pytest.ini_options] 42 | asyncio_mode = "auto" 43 | 44 | [tool.coverage.run] 45 | source = [ "src/pyotr/", ] 46 | omit = [ "*/tests/*", "src/tests/*", ] 47 | 48 | [tool.coverage.report] 49 | skip_covered = true 50 | show_missing = true 51 | fail_under = 90 52 | exclude_lines = [ "pragma: no cover", "@abstract",] 53 | 54 | [tool.black] 55 | line-length = 96 56 | target-version = ['py37', 'py38', 'py39'] 57 | verbose = false 58 | skip-string-normalization = false 59 | 60 | [tool.pydocstyle] 61 | add-ignore = "D104, D107, D212, D401" 62 | convention = "google" 63 | match-dir = "^(?!tests|examples).*" 64 | 65 | [tool.mypy] 66 | mypy_path = "src/" 67 | ignore_missing_imports = true 68 | 69 | [build-system] 70 | requires = ["poetry>=1.0"] 71 | build-backend = "poetry.masonry.api" 72 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 96 3 | per-file-ignores = 4 | # ignore line length in tests 5 | */tests/*:E501 6 | # ignore unused import in __init__.py 7 | */__init__.py:F401 8 | -------------------------------------------------------------------------------- /src/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berislavlopac/pyotr/d6cdebf540f1c734d979cc0fbd039b81ffbca099/src/examples/__init__.py -------------------------------------------------------------------------------- /src/examples/client.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pyotr.client import Client 4 | 5 | SPEC_PATH = Path(__file__).parent / "petstore.yaml" 6 | 7 | client = Client.from_file(SPEC_PATH) 8 | 9 | assert client.find_pets_by_status(status="available").payload == { 10 | "pets": [{"name": "Athena", "photoUrls": ["sdfsdfasdf", "asdasdasdasd"]}] 11 | } 12 | -------------------------------------------------------------------------------- /src/examples/petstore.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: Swagger Petstore 4 | description: >- 5 | This is a sample server Petstore server. You can find out more about 6 | Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, 7 | #swagger](http://swagger.io/irc/). For this sample, you can use the api 8 | key `special-key` to test the authorization filters. 9 | termsOfService: 'http://swagger.io/terms/' 10 | contact: 11 | email: apiteam@swagger.io 12 | license: 13 | name: Apache 2.0 14 | url: 'http://www.apache.org/licenses/LICENSE-2.0.html' 15 | version: 1.0.0 16 | externalDocs: 17 | description: Find out more about Swagger 18 | url: 'http://swagger.io' 19 | servers: 20 | - url: 'http://localhost:5000' 21 | - url: 'https://localhost:5000' 22 | tags: 23 | - name: pet 24 | description: Everything about your Pets 25 | externalDocs: 26 | description: Find out more 27 | url: 'http://swagger.io' 28 | - name: store 29 | description: Access to Petstore orders 30 | - name: user 31 | description: Operations about user 32 | externalDocs: 33 | description: Find out more about our store 34 | url: 'http://swagger.io' 35 | paths: 36 | /pet: 37 | put: 38 | tags: 39 | - pet 40 | summary: Update an existing pet 41 | operationId: updatePet 42 | requestBody: 43 | description: Pet object that needs to be added to the store 44 | content: 45 | application/json: 46 | schema: 47 | $ref: '#/components/schemas/Pet' 48 | application/xml: 49 | schema: 50 | $ref: '#/components/schemas/Pet' 51 | required: true 52 | responses: 53 | '400': 54 | description: Invalid ID supplied 55 | content: {} 56 | '404': 57 | description: Pet not found 58 | content: {} 59 | '405': 60 | description: Validation exception 61 | content: {} 62 | security: 63 | - petstore_auth: 64 | - 'write:pets' 65 | - 'read:pets' 66 | post: 67 | tags: 68 | - pet 69 | summary: Add a new pet to the store 70 | operationId: addPet 71 | requestBody: 72 | description: Pet object that needs to be added to the store 73 | content: 74 | application/json: 75 | schema: 76 | $ref: '#/components/schemas/Pet' 77 | application/xml: 78 | schema: 79 | $ref: '#/components/schemas/Pet' 80 | required: true 81 | responses: 82 | '405': 83 | description: Invalid input 84 | content: {} 85 | security: 86 | - petstore_auth: 87 | - 'write:pets' 88 | - 'read:pets' 89 | /pet/findByStatus: 90 | get: 91 | tags: 92 | - pet 93 | summary: Finds Pets by status 94 | description: Multiple status values can be provided with comma separated strings 95 | operationId: findPetsByStatus 96 | parameters: 97 | - name: status 98 | in: query 99 | description: Status values that need to be considered for filter 100 | required: true 101 | style: form 102 | explode: true 103 | schema: 104 | type: array 105 | items: 106 | type: string 107 | default: available 108 | enum: 109 | - available 110 | - pending 111 | - sold 112 | responses: 113 | '200': 114 | description: successful operation 115 | content: 116 | application/xml: 117 | schema: 118 | type: array 119 | items: 120 | $ref: '#/components/schemas/Pet' 121 | application/json: 122 | schema: 123 | type: array 124 | items: 125 | $ref: '#/components/schemas/Pet' 126 | '400': 127 | description: Invalid status value 128 | content: {} 129 | security: 130 | - petstore_auth: 131 | - 'write:pets' 132 | - 'read:pets' 133 | /pet/findByTags: 134 | get: 135 | tags: 136 | - pet 137 | summary: Finds Pets by tags 138 | description: >- 139 | Muliple tags can be provided with comma separated strings. Use 140 | tag1, tag2, tag3 for testing. 141 | operationId: findPetsByTags 142 | parameters: 143 | - name: tags 144 | in: query 145 | description: Tags to filter by 146 | required: true 147 | style: form 148 | explode: true 149 | schema: 150 | type: array 151 | items: 152 | type: string 153 | responses: 154 | '200': 155 | description: successful operation 156 | content: 157 | application/xml: 158 | schema: 159 | type: array 160 | items: 161 | $ref: '#/components/schemas/Pet' 162 | application/json: 163 | schema: 164 | type: array 165 | items: 166 | $ref: '#/components/schemas/Pet' 167 | '400': 168 | description: Invalid tag value 169 | content: {} 170 | deprecated: true 171 | security: 172 | - petstore_auth: 173 | - 'write:pets' 174 | - 'read:pets' 175 | '/pet/{petId}': 176 | get: 177 | tags: 178 | - pet 179 | summary: Find pet by ID 180 | description: Returns a single pet 181 | operationId: getPetById 182 | parameters: 183 | - name: petId 184 | in: path 185 | description: ID of pet to return 186 | required: true 187 | schema: 188 | type: integer 189 | format: int64 190 | responses: 191 | '200': 192 | description: successful operation 193 | content: 194 | application/xml: 195 | schema: 196 | $ref: '#/components/schemas/Pet' 197 | application/json: 198 | schema: 199 | $ref: '#/components/schemas/Pet' 200 | '400': 201 | description: Invalid ID supplied 202 | content: {} 203 | '404': 204 | description: Pet not found 205 | content: {} 206 | security: 207 | - api_key: [] 208 | post: 209 | tags: 210 | - pet 211 | summary: Updates a pet in the store with form data 212 | operationId: updatePetWithForm 213 | parameters: 214 | - name: petId 215 | in: path 216 | description: ID of pet that needs to be updated 217 | required: true 218 | schema: 219 | type: integer 220 | format: int64 221 | requestBody: 222 | content: 223 | application/x-www-form-urlencoded: 224 | schema: 225 | properties: 226 | name: 227 | type: string 228 | description: Updated name of the pet 229 | status: 230 | type: string 231 | description: Updated status of the pet 232 | responses: 233 | '405': 234 | description: Invalid input 235 | content: {} 236 | security: 237 | - petstore_auth: 238 | - 'write:pets' 239 | - 'read:pets' 240 | delete: 241 | tags: 242 | - pet 243 | summary: Deletes a pet 244 | operationId: deletePet 245 | parameters: 246 | - name: api_key 247 | in: header 248 | schema: 249 | type: string 250 | - name: petId 251 | in: path 252 | description: Pet id to delete 253 | required: true 254 | schema: 255 | type: integer 256 | format: int64 257 | responses: 258 | '400': 259 | description: Invalid ID supplied 260 | content: {} 261 | '404': 262 | description: Pet not found 263 | content: {} 264 | security: 265 | - petstore_auth: 266 | - 'write:pets' 267 | - 'read:pets' 268 | '/pet/{petId}/uploadImage': 269 | post: 270 | tags: 271 | - pet 272 | summary: uploads an image 273 | operationId: uploadFile 274 | parameters: 275 | - name: petId 276 | in: path 277 | description: ID of pet to update 278 | required: true 279 | schema: 280 | type: integer 281 | format: int64 282 | requestBody: 283 | content: 284 | multipart/form-data: 285 | schema: 286 | properties: 287 | additionalMetadata: 288 | type: string 289 | description: Additional data to pass to server 290 | file: 291 | type: string 292 | description: file to upload 293 | format: binary 294 | responses: 295 | '200': 296 | description: successful operation 297 | content: 298 | application/json: 299 | schema: 300 | $ref: '#/components/schemas/ApiResponse' 301 | security: 302 | - petstore_auth: 303 | - 'write:pets' 304 | - 'read:pets' 305 | /store/inventory: 306 | get: 307 | tags: 308 | - store 309 | summary: Returns pet inventories by status 310 | description: Returns a map of status codes to quantities 311 | operationId: getInventory 312 | responses: 313 | '200': 314 | description: successful operation 315 | content: 316 | application/json: 317 | schema: 318 | type: object 319 | additionalProperties: 320 | type: integer 321 | format: int32 322 | security: 323 | - api_key: [] 324 | /store/order: 325 | post: 326 | tags: 327 | - store 328 | summary: Place an order for a pet 329 | operationId: placeOrder 330 | requestBody: 331 | description: order placed for purchasing the pet 332 | content: 333 | '*/*': 334 | schema: 335 | $ref: '#/components/schemas/Order' 336 | required: true 337 | responses: 338 | '200': 339 | description: successful operation 340 | content: 341 | application/xml: 342 | schema: 343 | $ref: '#/components/schemas/Order' 344 | application/json: 345 | schema: 346 | $ref: '#/components/schemas/Order' 347 | '400': 348 | description: Invalid Order 349 | content: {} 350 | '/store/order/{orderId}': 351 | get: 352 | tags: 353 | - store 354 | summary: Find purchase order by ID 355 | description: >- 356 | For valid response try integer IDs with value >= 1 and <= 10. 357 | Other values will generated exceptions 358 | operationId: getOrderById 359 | parameters: 360 | - name: orderId 361 | in: path 362 | description: ID of pet that needs to be fetched 363 | required: true 364 | schema: 365 | maximum: 10 366 | minimum: 1 367 | type: integer 368 | format: int64 369 | responses: 370 | '200': 371 | description: successful operation 372 | content: 373 | application/xml: 374 | schema: 375 | $ref: '#/components/schemas/Order' 376 | application/json: 377 | schema: 378 | $ref: '#/components/schemas/Order' 379 | '400': 380 | description: Invalid ID supplied 381 | content: {} 382 | '404': 383 | description: Order not found 384 | content: {} 385 | delete: 386 | tags: 387 | - store 388 | summary: Delete purchase order by ID 389 | description: >- 390 | For valid response try integer IDs with positive integer value. 391 | Negative or non-integer values will generate API errors 392 | operationId: deleteOrder 393 | parameters: 394 | - name: orderId 395 | in: path 396 | description: ID of the order that needs to be deleted 397 | required: true 398 | schema: 399 | minimum: 1 400 | type: integer 401 | format: int64 402 | responses: 403 | '400': 404 | description: Invalid ID supplied 405 | content: {} 406 | '404': 407 | description: Order not found 408 | content: {} 409 | /user: 410 | post: 411 | tags: 412 | - user 413 | summary: Create user 414 | description: This can only be done by the logged in user. 415 | operationId: createUser 416 | requestBody: 417 | description: Created user object 418 | content: 419 | '*/*': 420 | schema: 421 | $ref: '#/components/schemas/User' 422 | required: true 423 | responses: 424 | default: 425 | description: successful operation 426 | content: {} 427 | /user/createWithArray: 428 | post: 429 | tags: 430 | - user 431 | summary: Creates list of users with given input array 432 | operationId: createUsersWithArrayInput 433 | requestBody: 434 | description: List of user object 435 | content: 436 | '*/*': 437 | schema: 438 | type: array 439 | items: 440 | $ref: '#/components/schemas/User' 441 | required: true 442 | responses: 443 | default: 444 | description: successful operation 445 | content: {} 446 | /user/createWithList: 447 | post: 448 | tags: 449 | - user 450 | summary: Creates list of users with given input array 451 | operationId: createUsersWithListInput 452 | requestBody: 453 | description: List of user object 454 | content: 455 | '*/*': 456 | schema: 457 | type: array 458 | items: 459 | $ref: '#/components/schemas/User' 460 | required: true 461 | responses: 462 | default: 463 | description: successful operation 464 | content: {} 465 | /user/login: 466 | get: 467 | tags: 468 | - user 469 | summary: Logs user into the system 470 | operationId: loginUser 471 | parameters: 472 | - name: username 473 | in: query 474 | description: The user name for login 475 | required: true 476 | schema: 477 | type: string 478 | - name: password 479 | in: query 480 | description: The password for login in clear text 481 | required: true 482 | schema: 483 | type: string 484 | responses: 485 | '200': 486 | description: successful operation 487 | headers: 488 | X-Rate-Limit: 489 | description: calls per hour allowed by the user 490 | schema: 491 | type: integer 492 | format: int32 493 | X-Expires-After: 494 | description: date in UTC when token expires 495 | schema: 496 | type: string 497 | format: date-time 498 | content: 499 | application/xml: 500 | schema: 501 | type: string 502 | application/json: 503 | schema: 504 | type: string 505 | '400': 506 | description: Invalid username/password supplied 507 | content: {} 508 | /user/logout: 509 | get: 510 | tags: 511 | - user 512 | summary: Logs out current logged in user session 513 | operationId: logoutUser 514 | responses: 515 | default: 516 | description: successful operation 517 | content: {} 518 | '/user/{username}': 519 | get: 520 | tags: 521 | - user 522 | summary: Get user by user name 523 | operationId: getUserByName 524 | parameters: 525 | - name: username 526 | in: path 527 | description: 'The name that needs to be fetched. Use user1 for testing. ' 528 | required: true 529 | schema: 530 | type: string 531 | responses: 532 | '200': 533 | description: successful operation 534 | content: 535 | application/xml: 536 | schema: 537 | $ref: '#/components/schemas/User' 538 | application/json: 539 | schema: 540 | $ref: '#/components/schemas/User' 541 | '400': 542 | description: Invalid username supplied 543 | content: {} 544 | '404': 545 | description: User not found 546 | content: {} 547 | put: 548 | tags: 549 | - user 550 | summary: Updated user 551 | description: This can only be done by the logged in user. 552 | operationId: updateUser 553 | parameters: 554 | - name: username 555 | in: path 556 | description: name that need to be updated 557 | required: true 558 | schema: 559 | type: string 560 | requestBody: 561 | description: Updated user object 562 | content: 563 | '*/*': 564 | schema: 565 | $ref: '#/components/schemas/User' 566 | required: true 567 | responses: 568 | '400': 569 | description: Invalid user supplied 570 | content: {} 571 | '404': 572 | description: User not found 573 | content: {} 574 | delete: 575 | tags: 576 | - user 577 | summary: Delete user 578 | description: This can only be done by the logged in user. 579 | operationId: deleteUser 580 | parameters: 581 | - name: username 582 | in: path 583 | description: The name that needs to be deleted 584 | required: true 585 | schema: 586 | type: string 587 | responses: 588 | '400': 589 | description: Invalid username supplied 590 | content: {} 591 | '404': 592 | description: User not found 593 | content: {} 594 | components: 595 | schemas: 596 | Order: 597 | type: object 598 | properties: 599 | id: 600 | type: integer 601 | format: int64 602 | petId: 603 | type: integer 604 | format: int64 605 | quantity: 606 | type: integer 607 | format: int32 608 | shipDate: 609 | type: string 610 | format: date-time 611 | status: 612 | type: string 613 | description: Order Status 614 | enum: 615 | - placed 616 | - approved 617 | - delivered 618 | complete: 619 | type: boolean 620 | default: false 621 | xml: 622 | name: Order 623 | Category: 624 | type: object 625 | properties: 626 | id: 627 | type: integer 628 | format: int64 629 | name: 630 | type: string 631 | xml: 632 | name: Category 633 | User: 634 | type: object 635 | properties: 636 | id: 637 | type: integer 638 | format: int64 639 | username: 640 | type: string 641 | firstName: 642 | type: string 643 | lastName: 644 | type: string 645 | email: 646 | type: string 647 | password: 648 | type: string 649 | phone: 650 | type: string 651 | userStatus: 652 | type: integer 653 | description: User Status 654 | format: int32 655 | xml: 656 | name: User 657 | Tag: 658 | type: object 659 | properties: 660 | id: 661 | type: integer 662 | format: int64 663 | name: 664 | type: string 665 | xml: 666 | name: Tag 667 | Pet: 668 | required: 669 | - name 670 | - photoUrls 671 | type: object 672 | properties: 673 | id: 674 | type: integer 675 | format: int64 676 | category: 677 | $ref: '#/components/schemas/Category' 678 | name: 679 | type: string 680 | example: doggie 681 | photoUrls: 682 | type: array 683 | xml: 684 | name: photoUrl 685 | wrapped: true 686 | items: 687 | type: string 688 | tags: 689 | type: array 690 | xml: 691 | name: tag 692 | wrapped: true 693 | items: 694 | $ref: '#/components/schemas/Tag' 695 | status: 696 | type: string 697 | description: pet status in the store 698 | enum: 699 | - available 700 | - pending 701 | - sold 702 | xml: 703 | name: Pet 704 | ApiResponse: 705 | type: object 706 | properties: 707 | code: 708 | type: integer 709 | format: int32 710 | type: 711 | type: string 712 | message: 713 | type: string 714 | securitySchemes: 715 | petstore_auth: 716 | type: oauth2 717 | flows: 718 | implicit: 719 | authorizationUrl: 'http://localhost:5000/oauth/dialog' 720 | scopes: 721 | 'write:pets': modify pets in your account 722 | 'read:pets': read your pets 723 | api_key: 724 | type: apiKey 725 | name: api_key 726 | in: header 727 | -------------------------------------------------------------------------------- /src/examples/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berislavlopac/pyotr/d6cdebf540f1c734d979cc0fbd039b81ffbca099/src/examples/server/__init__.py -------------------------------------------------------------------------------- /src/examples/server/pets.py: -------------------------------------------------------------------------------- 1 | async def find_pets_by_status(request): 2 | return {"pets": [{"name": "Lady Athena", "photoUrls": ["sdfsdfasdf", "asdasdasdasd"]}]} 3 | 4 | 5 | async def update_pet(request): 6 | pass 7 | 8 | 9 | async def add_pet(request): 10 | pass 11 | 12 | 13 | async def find_pets_by_tags(request): 14 | pass 15 | 16 | 17 | async def get_pet_by_id(request): 18 | pass 19 | 20 | 21 | async def update_pet_with_form(request): 22 | pass 23 | 24 | 25 | async def delete_pet(request): 26 | pass 27 | 28 | 29 | async def upload_file(request): 30 | pass 31 | 32 | 33 | async def get_inventory(request): 34 | pass 35 | 36 | 37 | async def place_order(request): 38 | pass 39 | 40 | 41 | async def get_order_by_id(request): 42 | pass 43 | 44 | 45 | async def delete_order(request): 46 | pass 47 | 48 | 49 | async def create_user(request): 50 | pass 51 | 52 | 53 | async def create_users_with_array_input(request): 54 | pass 55 | 56 | 57 | async def create_users_with_list_input(request): 58 | pass 59 | 60 | 61 | async def login_user(request): 62 | pass 63 | 64 | 65 | async def logout_user(request): 66 | pass 67 | 68 | 69 | async def get_user_by_name(request): 70 | pass 71 | 72 | 73 | async def update_user(request): 74 | pass 75 | 76 | 77 | async def delete_user(request): 78 | pass 79 | -------------------------------------------------------------------------------- /src/examples/server/server.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pyotr.server import Application 4 | 5 | SPEC_PATH = Path(__file__).parent.parent / "petstore.yaml" 6 | ENDPOINTS_MODULE = "examples.server.pets" 7 | 8 | app = Application.from_file(SPEC_PATH, module=ENDPOINTS_MODULE, debug=True) 9 | -------------------------------------------------------------------------------- /src/pyotr/client/__init__.py: -------------------------------------------------------------------------------- 1 | """Pyotr client.""" 2 | from pathlib import Path 3 | from types import ModuleType 4 | from typing import Any, Callable, Optional, Protocol, Type, Union 5 | 6 | import httpx 7 | from openapi_core import create_spec 8 | from openapi_core.shortcuts import ResponseValidator 9 | from openapi_core.spec.paths import SpecPath 10 | from openapi_core.validation.response.datatypes import OpenAPIResponse 11 | from stringcase import snakecase 12 | 13 | from pyotr.utils import get_spec_from_file, OperationSpec 14 | from .validation import client_response_factory, ClientOpenAPIRequest 15 | 16 | 17 | class Requestable(Protocol): # pragma: no cover 18 | """Defines the `request` method compatible with the `requests` library.""" 19 | 20 | def request(self, method: str, url: str, **kwargs) -> Any: 21 | """Construct and send a `Request`.""" 22 | ... 23 | 24 | 25 | class Client: 26 | """Pyotr client class.""" 27 | 28 | def __init__( 29 | self, 30 | spec: Union[SpecPath, dict], 31 | *, 32 | server_url: Optional[str] = None, 33 | client: Union[ModuleType, Requestable] = httpx, 34 | request_class: Type[ClientOpenAPIRequest] = ClientOpenAPIRequest, 35 | response_factory: Callable[[Any], OpenAPIResponse] = client_response_factory, 36 | headers: Optional[dict] = None, 37 | ): 38 | if not isinstance(spec, SpecPath): 39 | spec = create_spec(spec) 40 | self.spec = spec 41 | self.client = client 42 | self.request_class = request_class 43 | self.response_factory = response_factory 44 | self.common_headers = headers or {} 45 | 46 | if server_url is None: 47 | server_url = self.spec["servers"][0]["url"] 48 | else: 49 | server_url = server_url.rstrip("/") 50 | for server in self.spec["servers"]: 51 | if server_url == server["url"]: 52 | break 53 | else: 54 | self.spec["servers"].append({"url": server_url}) 55 | self.server_url = server_url 56 | self.validator = ResponseValidator(self.spec) 57 | 58 | for operation_id, op_spec in OperationSpec.get_all(spec).items(): 59 | setattr( 60 | self, 61 | snakecase(operation_id), 62 | self._get_operation(op_spec).__get__(self), 63 | ) 64 | 65 | @staticmethod 66 | def _get_operation(op_spec: OperationSpec): 67 | # TODO: extract args and kwargs from operation parameters 68 | def operation( 69 | self, 70 | *args, 71 | body_: Optional[Union[dict, list]] = None, 72 | headers_: Optional[dict] = None, 73 | **kwargs, 74 | ): 75 | request_headers = self.common_headers.copy() 76 | request_headers.update(headers_ or {}) 77 | request = self.request_class(self.server_url, op_spec) 78 | request.prepare(*args, body_=body_, headers_=request_headers, **kwargs) 79 | request_params = { 80 | "method": request.method, 81 | "url": request.url, 82 | "headers": request.headers, 83 | } 84 | if request.body: 85 | request_params["json" if "json" in request.mimetype else "data"] = request.body 86 | api_response = self.client.request(**request_params) 87 | api_response.raise_for_status() 88 | response = self.response_factory(api_response) 89 | self.validator.validate(request, response).raise_for_errors() 90 | return response 91 | 92 | operation.__doc__ = op_spec.spec.get("summary") or op_spec.operation_id 93 | if description := op_spec.spec.get("description"): 94 | operation.__doc__ = f"{ operation.__doc__ }\n\n{ description }" 95 | return operation 96 | 97 | @classmethod 98 | def from_file(cls, path: Union[Path, str], **kwargs): 99 | """Creates an instance of the class by loading the spec from a local file.""" 100 | spec = get_spec_from_file(path) 101 | return cls(spec, **kwargs) 102 | -------------------------------------------------------------------------------- /src/pyotr/client/validation.py: -------------------------------------------------------------------------------- 1 | """Pyotr API client.""" 2 | from __future__ import annotations 3 | 4 | from string import Formatter 5 | from typing import Mapping, Optional 6 | from urllib.parse import parse_qs, urlencode, urljoin, urlsplit, urlunsplit 7 | 8 | from openapi_core.validation.request.datatypes import OpenAPIRequest, RequestParameters 9 | from openapi_core.validation.response.datatypes import OpenAPIResponse 10 | from requests import Response 11 | 12 | 13 | class ClientOpenAPIRequest(OpenAPIRequest): 14 | """Client request.""" 15 | 16 | def __init__(self, host_url: str, op_spec): 17 | self.spec = op_spec 18 | self._url_parts = urlsplit(host_url) 19 | self.mimetype = None 20 | 21 | formatter = Formatter() 22 | self.url_vars = [ 23 | var for _, var, _, _ in formatter.parse(op_spec.path) if var is not None 24 | ] 25 | self._path_pattern = self._url_parts.path + op_spec.path 26 | 27 | self.full_url_pattern = urljoin(host_url, self._path_pattern) 28 | self.method = op_spec.method.lower() 29 | self.body: Mapping = {} 30 | self.parameters = RequestParameters( 31 | path={}, 32 | query=parse_qs(self._url_parts.query), 33 | header={}, 34 | cookie={}, 35 | ) 36 | content = getattr(op_spec, "request_body", {}).get("content", {}) 37 | default_mimetipe = "application/json" 38 | if content: 39 | self.mimetype = ( 40 | default_mimetipe if default_mimetipe in content else list(content)[0] 41 | ) 42 | 43 | @property 44 | def url(self): 45 | """Request URL.""" 46 | url_parts = self._url_parts._asdict() 47 | url_parts["path"] = self._path_pattern.format(**self.parameters.path) 48 | url_parts["query"] = urlencode(self.parameters.query) 49 | return urlunsplit(tuple(url_parts.values())) 50 | 51 | def prepare( 52 | self, 53 | *args, 54 | body_: Optional[Mapping] = None, 55 | headers_: Optional[Mapping] = None, 56 | **kwargs, 57 | ) -> ClientOpenAPIRequest: 58 | """ 59 | Prepare request. 60 | 61 | Arguments: 62 | *args: Positional arguments are inserted into the URL. 63 | body_: Optional request body. 64 | headers_: Optional request headers. 65 | **kwargs: The keyword arguments are converted to query arguments. 66 | """ 67 | self._set_path_params(*args) 68 | if headers_ is not None: 69 | self.parameters.header.update(headers_) 70 | self.parameters.query = kwargs 71 | if body_ is not None: 72 | self.body = body_ 73 | content_type_header = self.parameters.header.pop("content-type", None) 74 | if content_type_header: 75 | self.mimetype = content_type_header 76 | return self 77 | 78 | def _set_path_params(self, *args): 79 | len_vars = len(self.url_vars) 80 | if len(args) != len_vars: 81 | error_message = f"Incorrect arguments: {self.spec.operation_id} accepts" 82 | if len_vars: 83 | error_message += ( 84 | f" {len_vars} positional argument{'s' if len_vars > 1 else ''}:" 85 | f" {', '.join(self.url_vars)}" 86 | ) 87 | else: 88 | error_message += " no positional arguments" 89 | raise RuntimeError(error_message) 90 | self.parameters.path = dict(zip(self.url_vars, args)) 91 | 92 | @property 93 | def headers(self): 94 | """Request headers.""" 95 | return self.parameters.header 96 | 97 | 98 | def client_response_factory(response: Response) -> OpenAPIResponse: 99 | """Create client response.""" 100 | return OpenAPIResponse( 101 | data=response.content, 102 | status_code=response.status_code, 103 | mimetype=response.headers.get("content-type"), 104 | ) 105 | -------------------------------------------------------------------------------- /src/pyotr/server/__init__.py: -------------------------------------------------------------------------------- 1 | """Pyotr server.""" 2 | from functools import wraps 3 | from http import HTTPStatus 4 | from importlib import import_module 5 | from inspect import iscoroutine 6 | from pathlib import Path 7 | from types import ModuleType 8 | from typing import Callable, Optional, Union 9 | from urllib.parse import urlsplit 10 | 11 | from openapi_core import create_spec 12 | from openapi_core.exceptions import OpenAPIError 13 | from openapi_core.shortcuts import RequestValidator, ResponseValidator 14 | from openapi_core.spec.paths import SpecPath 15 | from openapi_core.validation.exceptions import InvalidSecurity 16 | from starlette.applications import Starlette 17 | from starlette.exceptions import HTTPException 18 | from starlette.requests import Request 19 | from starlette.responses import JSONResponse, Response 20 | from stringcase import snakecase 21 | 22 | from pyotr.utils import get_spec_from_file, OperationSpec 23 | from .validation import request_factory, response_factory 24 | 25 | 26 | class Application(Starlette): 27 | """Pyotr server application.""" 28 | 29 | def __init__( 30 | self, 31 | spec: Union[SpecPath, dict], 32 | *, 33 | module: Optional[Union[str, ModuleType]] = None, 34 | validate_responses: bool = True, 35 | enforce_case: bool = True, 36 | **kwargs, 37 | ): 38 | super().__init__(**kwargs) 39 | if not isinstance(spec, SpecPath): 40 | spec = create_spec(spec) 41 | self.spec = spec 42 | self.validate_responses = validate_responses 43 | self.enforce_case = enforce_case 44 | self.custom_formatters = None 45 | self.custom_media_type_deserializers = None 46 | 47 | self._operations = OperationSpec.get_all(self.spec) 48 | self._server_paths = {urlsplit(server["url"]).path for server in self.spec["servers"]} 49 | 50 | if module is not None: 51 | if isinstance(module, str): 52 | module = _load_module(module) 53 | 54 | for operation_id, operation in self._operations.items(): 55 | name = operation_id 56 | if "." in name: 57 | base, name = name.rsplit(".", 1) 58 | base_module = _load_module(f"{module.__name__}.{base}") 59 | else: 60 | base_module = module 61 | if self.enforce_case: 62 | name = snakecase(name) 63 | try: 64 | endpoint_fn = getattr(base_module, name) 65 | except AttributeError as e: 66 | raise RuntimeError( 67 | f"The function `{base_module}.{name}` does not exist!" 68 | ) from e 69 | self.set_endpoint(endpoint_fn, operation_id=operation_id) 70 | 71 | def set_endpoint(self, endpoint_fn: Callable, *, operation_id: Optional[str] = None): 72 | """Sets endpoint function for a given `operationId`. 73 | 74 | If the `operation_id` is not given, it will try to determine it 75 | based on the function name. 76 | """ 77 | if operation_id is None: 78 | operation_id = endpoint_fn.__name__ 79 | if self.enforce_case and operation_id not in self._operations: 80 | operation_id_key = {snakecase(op_id): op_id for op_id in self._operations}.get( 81 | operation_id 82 | ) 83 | else: 84 | operation_id_key = operation_id 85 | try: 86 | operation = self._operations[operation_id_key] 87 | except KeyError as ex: 88 | raise ValueError(f"Unknown operationId: {operation_id}.") from ex 89 | 90 | @wraps(endpoint_fn) 91 | async def wrapper(request: Request, **kwargs) -> Response: 92 | openapi_request = await request_factory(request) 93 | validated_request = RequestValidator( 94 | self.spec, 95 | custom_formatters=self.custom_formatters, 96 | custom_media_type_deserializers=self.custom_media_type_deserializers, 97 | ).validate(openapi_request) 98 | try: 99 | validated_request.raise_for_errors() 100 | except InvalidSecurity as ex: 101 | raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid security.") from ex 102 | except OpenAPIError as ex: 103 | raise HTTPException(HTTPStatus.BAD_REQUEST, "Bad request") from ex 104 | 105 | response = endpoint_fn(request, **kwargs) 106 | if iscoroutine(response): 107 | response = await response 108 | if isinstance(response, dict): 109 | response = JSONResponse(response) 110 | elif not isinstance(response, Response): 111 | raise ValueError( 112 | f"The endpoint function `{endpoint_fn.__name__}` must return" 113 | " either a dict or a Starlette Response instance." 114 | ) 115 | 116 | # TODO: pass a list of operation IDs to specify which responses not to validate 117 | if self.validate_responses: 118 | ResponseValidator( 119 | self.spec, 120 | custom_formatters=self.custom_formatters, 121 | custom_media_type_deserializers=self.custom_media_type_deserializers, 122 | ).validate(openapi_request, response_factory(response)).raise_for_errors() 123 | return response 124 | 125 | for server_path in self._server_paths: 126 | self.add_route( 127 | server_path + operation.path, wrapper, [operation.method], name=operation_id 128 | ) 129 | 130 | def endpoint(self, operation_id: Union[Callable, str]): 131 | """Decorator for setting endpoints. 132 | 133 | If used without arguments, it will try to determine the `operationId` based on the 134 | decorated function name: 135 | 136 | @app.endpoint 137 | def foo_bar(request): 138 | # sets the endpoint for operationId fooBar 139 | 140 | Otherwise, the `operationId` can be set explicitly: 141 | 142 | @app.endpoint('fooBar'): 143 | def my_endpoint(): 144 | ... 145 | """ 146 | if callable(operation_id): 147 | self.set_endpoint(operation_id) 148 | return operation_id 149 | else: 150 | 151 | def decorator(fn): 152 | self.set_endpoint(fn, operation_id=operation_id) 153 | return fn 154 | 155 | return decorator 156 | 157 | @classmethod 158 | def from_file(cls, path: Union[Path, str], *args, **kwargs) -> "Application": 159 | """Creates an instance of the class by loading the spec from a local file.""" 160 | spec = get_spec_from_file(path) 161 | return cls(spec, *args, **kwargs) 162 | 163 | 164 | def _load_module(name: str) -> ModuleType: 165 | """Helper function to load a module based on its dotted-string name.""" 166 | try: 167 | module = import_module(name) 168 | except ModuleNotFoundError as e: 169 | raise RuntimeError(f"The module `{name}` does not exist!") from e 170 | else: 171 | return module 172 | -------------------------------------------------------------------------------- /src/pyotr/server/validation.py: -------------------------------------------------------------------------------- 1 | """Starlette requests.""" 2 | from urllib.parse import urljoin 3 | 4 | from openapi_core.validation.request.datatypes import OpenAPIRequest, RequestParameters 5 | from openapi_core.validation.response.datatypes import OpenAPIResponse 6 | from starlette.requests import Request 7 | from starlette.responses import Response 8 | from starlette.routing import Match 9 | 10 | 11 | async def request_factory(request: Request) -> OpenAPIRequest: 12 | """Create Starlette reques.""" 13 | path_pattern = request["path"] 14 | for route in request.app.router.routes: 15 | match, _ = route.matches(request) 16 | if match == Match.FULL: 17 | path_pattern = route.path 18 | break 19 | 20 | host_url = f"{request.url.scheme}://{request.url.hostname}" 21 | if request.url.port: 22 | host_url = f"{host_url}:{request.url.port}" 23 | 24 | parameters = RequestParameters( 25 | path=request.path_params, 26 | query=request.query_params, 27 | header=dict(request.headers), 28 | cookie=request.cookies, 29 | ) 30 | 31 | return OpenAPIRequest( 32 | full_url_pattern=urljoin(host_url, path_pattern), 33 | method=request.method.lower(), 34 | parameters=parameters, 35 | body=await request.body(), 36 | mimetype=request.headers.get("content-type"), 37 | ) 38 | 39 | 40 | def response_factory(response: Response) -> OpenAPIResponse: 41 | """Create Starlette response.""" 42 | mimetype, *_ = response.headers.get("content-type", "").split(";") 43 | return OpenAPIResponse( 44 | data=response.body, 45 | status_code=response.status_code, 46 | mimetype=mimetype, 47 | ) 48 | -------------------------------------------------------------------------------- /src/pyotr/utils.py: -------------------------------------------------------------------------------- 1 | """Utility classes and functions.""" 2 | from __future__ import annotations 3 | 4 | import json 5 | from enum import Enum 6 | from itertools import chain 7 | from pathlib import Path 8 | from typing import Callable, Dict, Union 9 | 10 | import yaml 11 | from openapi_core.spec.paths import SpecPath 12 | from stringcase import camelcase 13 | 14 | 15 | class OperationSpec: 16 | """Utility class for defining API operations.""" 17 | 18 | def __init__(self, path: str, method: str, spec: dict): 19 | self.path = path 20 | self.method = method 21 | self.spec = spec 22 | 23 | def __getattr__(self, name): 24 | """ 25 | Looks for values of the specification fields. 26 | 27 | If the exact match of a name fails, also checks for the camel case version. 28 | """ 29 | if name in self.spec: 30 | return self.spec[name] 31 | if (camelcase_name := camelcase(name)) in self.spec: 32 | return self.spec[camelcase_name] 33 | return super().__getattribute__(name) 34 | 35 | @classmethod 36 | def get_all(cls, spec: dict) -> Dict[str, OperationSpec]: 37 | """Builds a dict of all operations in the spec.""" 38 | return { 39 | op_spec["operationId"]: cls(path, method, op_spec) 40 | for path, path_spec in spec["paths"].items() 41 | for method, op_spec in path_spec.items() 42 | } 43 | 44 | 45 | class SpecFileTypes(tuple, Enum): 46 | """Supported spec file extensions.""" 47 | 48 | JSON = ("json",) 49 | YAML = ("yaml", "yml") 50 | 51 | 52 | def get_spec_from_file(path: Union[Path, str]) -> SpecPath: 53 | """Loads a local file and creates an OpenAPI `Spec` object.""" 54 | path = Path(path) 55 | suffix = path.suffix[1:].lower() 56 | 57 | if suffix in SpecFileTypes.JSON: 58 | spec_load: Callable = json.load 59 | elif suffix in SpecFileTypes.YAML: 60 | spec_load = yaml.safe_load 61 | else: 62 | raise RuntimeError( 63 | f"Unknown specification file type." 64 | f" Accepted types: {', '.join(chain(*SpecFileTypes))}" 65 | ) 66 | 67 | with open(path) as spec_file: 68 | return spec_load(spec_file) 69 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berislavlopac/pyotr/d6cdebf540f1c734d979cc0fbd039b81ffbca099/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def spec_dict(config): 9 | file_path = config.test_dir / "openapi.json" 10 | with open(file_path) as spec_file: 11 | return json.load(spec_file) 12 | 13 | 14 | class Config: 15 | def __init__(self): 16 | self.test_dir = Path(__file__).parent 17 | self.endpoint_base = "tests.endpoints" 18 | 19 | 20 | @pytest.fixture 21 | def config(): 22 | return Config() 23 | -------------------------------------------------------------------------------- /src/tests/endpoints.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPStatus 3 | 4 | from starlette.responses import Response 5 | 6 | 7 | def dummy_test_endpoint(request): 8 | return {"foo": "bar"} 9 | 10 | 11 | def dummy_test_endpoint_with_argument(request): 12 | return {"foo": request.path_params["test_arg"]} 13 | 14 | 15 | async def dummy_test_endpoint_coro(request): 16 | return {"baz": 123} 17 | 18 | 19 | async def dummy_post_endpoint(request): 20 | body = await request.body() 21 | assert json.loads(body.decode()) == {"foo": "bar"} 22 | return Response(status_code=HTTPStatus.NO_CONTENT.value) 23 | 24 | 25 | async def endpoint_returning_nothing(request): 26 | ... 27 | -------------------------------------------------------------------------------- /src/tests/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.1", 3 | "info": { 4 | "title": "Test Spec", 5 | "version": "0.0.0" 6 | }, 7 | "servers": [ 8 | { 9 | "url": "https://localhost:8000" 10 | }, 11 | { 12 | "url": "http://localhost:8000" 13 | } 14 | ], 15 | "paths": { 16 | "/test": { 17 | "get": { 18 | "operationId": "dummyTestEndpoint", 19 | "summary": "A dummy test endpoint.", 20 | "description": "A test endpoint that does nothing, so is pretty dummy, but works fine for testing.", 21 | "responses": { 22 | "200": { 23 | "description": "successful operation", 24 | "content": { 25 | "application/json": { 26 | "schema": { 27 | "$ref": "#/components/schemas/Thing" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | }, 34 | "post": { 35 | "operationId": "dummyPostEndpoint", 36 | "summary": "A dummy test endpoint with POST method.", 37 | "description": "A test endpoint that does nothing, so is pretty dummy, but works fine for testing POST method.", 38 | "requestBody": { 39 | "content": { 40 | "application/json": { 41 | "schema": { 42 | "type": "object", 43 | "properties": { 44 | "foo": { 45 | "type": "string" 46 | } 47 | } 48 | } 49 | } 50 | } 51 | }, 52 | "responses": { 53 | "204": { 54 | "description": "no content" 55 | } 56 | } 57 | } 58 | }, 59 | "/test/{test_arg}": { 60 | "get": { 61 | "operationId": "dummyTestEndpointWithArgument", 62 | "parameters": [ 63 | { 64 | "name": "test_arg", 65 | "in": "path", 66 | "required": true, 67 | "schema": { 68 | "type": "string" 69 | } 70 | } 71 | ], 72 | "responses": { 73 | "200": { 74 | "description": "successful operation", 75 | "content": { 76 | "application/json": { 77 | "schema": { 78 | "$ref": "#/components/schemas/Thing" 79 | } 80 | } 81 | } 82 | } 83 | } 84 | } 85 | }, 86 | "/test-async": { 87 | "get": { 88 | "operationId": "dummyTestEndpointCoro", 89 | "responses": { 90 | "200": { 91 | "description": "successful operation", 92 | "content": { 93 | "application/json": { 94 | "schema": { 95 | "$ref": "#/components/schemas/Thing" 96 | } 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | }, 104 | "components": { 105 | "schemas": { 106 | "Thing": { 107 | "type": "object", 108 | "properties": { 109 | "foo": { 110 | "type": "string" 111 | }, 112 | "baz": { 113 | "type": "integer" 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/tests/openapi.unknown: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berislavlopac/pyotr/d6cdebf540f1c734d979cc0fbd039b81ffbca099/src/tests/openapi.unknown -------------------------------------------------------------------------------- /src/tests/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: Test Spec 4 | version: 0.0.0 5 | servers: 6 | - url: 'https://localhost:8000' 7 | - url: 'http://localhost:8000' 8 | paths: 9 | /test: 10 | get: 11 | operationId: dummyTestEndpoint 12 | summary: A dummy test endpoint. 13 | description: A test endpoint that doe nothing, so is pretty dummy, but works fine for testing. 14 | responses: 15 | '200': 16 | description: successful operation 17 | content: 18 | application/json: 19 | schema: 20 | $ref: '#/components/schemas/Thing' 21 | post: 22 | operationId: dummyPostEndpoint 23 | summary: A dummy test endpoint with POST method. 24 | description: A test endpoint that does nothing, so is pretty dummy, but works fine for testing POST method. 25 | requestBody: 26 | content: 27 | application/json: 28 | schema: 29 | type: object 30 | properties: 31 | foo: 32 | type: string 33 | responses: 34 | '204': 35 | description: no content 36 | /test/{test_arg}: 37 | get: 38 | operationId: dummyTestEndpointWithArgument 39 | parameters: 40 | - name: test_arg 41 | in: path 42 | required: true 43 | schema: 44 | type: string 45 | responses: 46 | '200': 47 | description: successful operation 48 | content: 49 | application/json: 50 | schema: 51 | $ref: '#/components/schemas/Thing' 52 | /test-async: 53 | get: 54 | operationId: dummyTestEndpointCoro 55 | responses: 56 | '200': 57 | description: successful operation 58 | content: 59 | application/json: 60 | schema: 61 | $ref: '#/components/schemas/Thing' 62 | /test-nothing: 63 | get: 64 | operationId: endpointReturningNothing 65 | responses: 66 | '204': 67 | description: no content 68 | components: 69 | schemas: 70 | Thing: 71 | type: object 72 | properties: 73 | foo: 74 | type: string 75 | baz: 76 | type: integer 77 | -------------------------------------------------------------------------------- /src/tests/test_client.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | from openapi_core.validation.response.datatypes import OpenAPIResponse 5 | from starlette.testclient import TestClient 6 | 7 | from pyotr.client import Client 8 | from pyotr.server import Application 9 | 10 | 11 | def test_client_calls_endpoint(spec_dict, config): 12 | app = Application(spec_dict, module=config.endpoint_base) 13 | client = Client(spec_dict, client=TestClient(app)) 14 | response = client.dummy_test_endpoint() 15 | assert isinstance(response, OpenAPIResponse) 16 | assert response.data == b'{"foo":"bar"}' 17 | 18 | 19 | def test_client_calls_endpoint_with_body(spec_dict, config): 20 | app = Application(spec_dict, module=config.endpoint_base) 21 | client = Client(spec_dict, client=TestClient(app)) 22 | response = client.dummy_post_endpoint(body_={"foo": "bar"}) 23 | assert isinstance(response, OpenAPIResponse) 24 | assert response.status_code == HTTPStatus.NO_CONTENT 25 | 26 | 27 | def test_client_calls_endpoint_using_server_with_path(spec_dict, config): 28 | spec_dict["servers"].insert(0, {"url": "http://localhost:8001/with/path"}) 29 | app = Application(spec_dict, module=config.endpoint_base) 30 | client = Client(spec_dict, client=TestClient(app)) 31 | response = client.dummy_test_endpoint() 32 | assert isinstance(response, OpenAPIResponse) 33 | assert response.data == b'{"foo":"bar"}' 34 | 35 | 36 | def test_client_calls_endpoint_with_custom_headers(spec_dict, config, monkeypatch): 37 | app = Application(spec_dict, module=config.endpoint_base) 38 | client = Client(spec_dict, client=TestClient(app)) 39 | 40 | def patch_request(request): 41 | def wrapper(*args, **kwargs): 42 | client.request_info = {"args": args, "kwargs": kwargs} 43 | return request(*args, **kwargs) 44 | 45 | return wrapper 46 | 47 | monkeypatch.setattr(client.client, "request", patch_request(client.client.request)) 48 | client.dummy_test_endpoint(headers_={"foo": "bar"}) 49 | headers = client.request_info["kwargs"]["headers"] 50 | assert dict(headers) == {"foo": "bar"} 51 | 52 | 53 | def test_client_incorrect_args_raises_error(spec_dict, config): 54 | app = Application(spec_dict, module=config.endpoint_base) 55 | client = Client(spec_dict, client=TestClient(app)) 56 | with pytest.raises(RuntimeError) as error: 57 | client.dummy_test_endpoint("foo") 58 | assert ( 59 | error.exconly() 60 | == "RuntimeError: Incorrect arguments: dummyTestEndpoint accepts no positional arguments" 61 | ) 62 | 63 | 64 | def test_client_too_few_args_raises_error(spec_dict, config): 65 | app = Application(spec_dict, module=config.endpoint_base) 66 | client = Client(spec_dict, client=TestClient(app)) 67 | with pytest.raises(RuntimeError) as error: 68 | client.dummy_test_endpoint_with_argument() 69 | assert error.exconly() == ( 70 | "RuntimeError: Incorrect arguments: dummyTestEndpointWithArgument accepts 1 positional argument: test_arg" 71 | ) 72 | 73 | 74 | def test_unknown_server_url_gets_added_to_spec(spec_dict): 75 | test_server = spec_dict["servers"][1]["url"] 76 | client = Client(spec_dict, server_url=test_server) 77 | assert client.server_url == test_server 78 | 79 | 80 | def test_known_server_url_gets_selected(spec_dict): 81 | client = Client(spec_dict, server_url="foo.bar") 82 | assert client.server_url == "foo.bar" 83 | assert client.spec["servers"][-1]["url"] == "foo.bar" 84 | 85 | 86 | def test_use_first_server_url_as_default(spec_dict): 87 | client = Client(spec_dict) 88 | assert client.server_url == spec_dict["servers"][0]["url"] 89 | 90 | 91 | def test_incorrect_endpoint_raises_error(spec_dict): 92 | client = Client(spec_dict) 93 | with pytest.raises(AttributeError): 94 | client.foo_bar() 95 | 96 | 97 | @pytest.mark.parametrize("filename", ("openapi.json", "openapi.yaml")) 98 | def test_from_file(config, filename): 99 | file_path = config.test_dir / filename 100 | client = Client.from_file(file_path) 101 | 102 | assert client.spec["info"]["title"] == "Test Spec" 103 | 104 | 105 | def test_from_file_raises_exception_if_unknown_type(config): 106 | file_path = config.test_dir / "openapi.unknown" 107 | with pytest.raises(RuntimeError): 108 | Client.from_file(file_path) 109 | 110 | 111 | def test_endpoint_docstring_constructed_from_spec(spec_dict): 112 | client = Client(spec_dict) 113 | assert client.dummy_test_endpoint.__doc__ == ( 114 | "A dummy test endpoint.\n\nA test endpoint that does nothing, so is pretty dummy, but works fine for testing." 115 | ) 116 | 117 | 118 | def test_endpoint_docstring_constructed_with_default_values(spec_dict): 119 | client = Client(spec_dict) 120 | assert client.dummy_test_endpoint_with_argument.__doc__ == "dummyTestEndpointWithArgument" 121 | 122 | 123 | def test_common_headers_included_in_request(spec_dict, config, monkeypatch): 124 | app = Application(spec_dict, module=config.endpoint_base) 125 | client = Client(spec_dict, client=TestClient(app), headers={"foo": "bar"}) 126 | 127 | def patch_request(request): 128 | def wrapper(*args, **kwargs): 129 | client.request_info = {"args": args, "kwargs": kwargs} 130 | return request(*args, **kwargs) 131 | 132 | return wrapper 133 | 134 | monkeypatch.setattr(client.client, "request", patch_request(client.client.request)) 135 | client.dummy_test_endpoint(headers_={"baz": "bam"}) 136 | headers = client.request_info["kwargs"]["headers"] 137 | assert dict(headers) == {"foo": "bar", "baz": "bam"} 138 | -------------------------------------------------------------------------------- /src/tests/test_server.py: -------------------------------------------------------------------------------- 1 | from inspect import iscoroutinefunction 2 | 3 | import pytest 4 | from starlette.requests import Request 5 | from starlette.responses import JSONResponse 6 | 7 | from pyotr.server import Application 8 | 9 | 10 | @pytest.mark.parametrize("filename", ("openapi.json", "openapi.yaml")) 11 | def test_server_from_file(config, filename): 12 | file_path = config.test_dir / filename 13 | app = Application.from_file(file_path, module=config.endpoint_base) 14 | assert app.spec["info"]["title"] == "Test Spec" 15 | 16 | 17 | def test_server_from_file_raises_exception_if_unknown_type(config): 18 | file_path = config.test_dir / "openapi.unknown" 19 | with pytest.raises(RuntimeError): 20 | Application.from_file(file_path) 21 | 22 | 23 | def test_server_dotted_endpoint_name(spec_dict): 24 | spec_dict["paths"]["/test"]["get"]["operationId"] = "endpoints.dummyTestEndpoint" 25 | spec_dict["paths"]["/test"]["post"]["operationId"] = "endpoints.dummyPostEndpoint" 26 | spec_dict["paths"]["/test/{test_arg}"]["get"][ 27 | "operationId" 28 | ] = "endpoints.dummyTestEndpointWithArgument" 29 | spec_dict["paths"]["/test-async"]["get"]["operationId"] = "endpoints.dummyTestEndpointCoro" 30 | app = Application(spec_dict, module="tests") 31 | route = app.routes[0] 32 | assert callable(route.endpoint) 33 | assert route.endpoint.__name__ == "dummy_test_endpoint" 34 | assert route.path == "/test" 35 | 36 | 37 | def test_server_endpoints_as_module(spec_dict): 38 | from tests import endpoints 39 | 40 | app = Application(spec_dict, module=endpoints) 41 | route = app.routes[0] 42 | assert callable(route.endpoint) 43 | assert route.endpoint.__name__ == "dummy_test_endpoint" 44 | assert route.path == "/test" 45 | 46 | 47 | def test_server_endpoints_as_module_dotted_endpoint_name(spec_dict): 48 | import tests 49 | 50 | spec_dict["paths"]["/test"]["get"]["operationId"] = "endpoints.dummyTestEndpoint" 51 | spec_dict["paths"]["/test"]["post"]["operationId"] = "endpoints.dummyPostEndpoint" 52 | spec_dict["paths"]["/test/{test_arg}"]["get"][ 53 | "operationId" 54 | ] = "endpoints.dummyTestEndpointWithArgument" 55 | spec_dict["paths"]["/test-async"]["get"]["operationId"] = "endpoints.dummyTestEndpointCoro" 56 | app = Application(spec_dict, module=tests) 57 | route = app.routes[0] 58 | assert callable(route.endpoint) 59 | assert route.endpoint.__name__ == "dummy_test_endpoint" 60 | assert route.path == "/test" 61 | 62 | 63 | def test_server_with_path(spec_dict): 64 | from tests import endpoints 65 | 66 | spec_dict["servers"].insert(0, {"url": "http://localhost:8001/with/path"}) 67 | app = Application(spec_dict, module=endpoints) 68 | expected_routes = { 69 | "/test", 70 | "/test/{test_arg}", 71 | "/with/path/test", 72 | "/test-async", 73 | "/with/path/test/{test_arg}", 74 | "/with/path/test-async", 75 | } 76 | assert {route.path for route in app.routes} == expected_routes 77 | 78 | 79 | def test_server_no_endpoint_module(spec_dict): 80 | with pytest.raises(RuntimeError): 81 | Application(spec_dict, module="foo.bar") 82 | 83 | 84 | def test_server_no_endpoint_function(spec_dict, config): 85 | spec_dict["paths"]["/test"]["get"]["operationId"] = "fooBar" 86 | with pytest.raises(RuntimeError): 87 | Application(spec_dict, module=config.endpoint_base) 88 | 89 | 90 | def test_server_wraps_endpoint_function(spec_dict, config): 91 | from .endpoints import dummy_test_endpoint 92 | 93 | app = Application(spec_dict, module=config.endpoint_base) 94 | route = app.routes[0] 95 | assert route.endpoint is not dummy_test_endpoint 96 | assert route.endpoint.__wrapped__ is dummy_test_endpoint 97 | assert not iscoroutinefunction(dummy_test_endpoint) 98 | assert iscoroutinefunction(route.endpoint) 99 | 100 | 101 | @pytest.mark.asyncio 102 | async def test_server_wraps_endpoint_function_result_with_jsonresponse(spec_dict, config): 103 | async def dummy_receive(): 104 | return {"type": "http.request"} 105 | 106 | app = Application(spec_dict, module=config.endpoint_base) 107 | for route in app.routes: 108 | if route.path == "/test": 109 | break 110 | request = Request( 111 | { 112 | "type": "http", 113 | "path": app.spec["servers"][0]["url"] + route.path, 114 | "query_string": "", 115 | "headers": {}, 116 | "app": app, 117 | "method": "get", 118 | }, 119 | dummy_receive, 120 | ) 121 | response = await route.endpoint(request) 122 | assert isinstance(response, JSONResponse) 123 | 124 | 125 | @pytest.mark.asyncio 126 | async def test_server_wraps_async_endpoint_function_result_with_jsonresponse(spec_dict, config): 127 | async def dummy_receive(): 128 | return {"type": "http.request"} 129 | 130 | app = Application(spec_dict, module=config.endpoint_base) 131 | for route in app.routes: 132 | if route.path == "/test-async": 133 | break 134 | request = Request( 135 | { 136 | "type": "http", 137 | "path": app.spec["servers"][0]["url"] + route.path, 138 | "query_string": "", 139 | "headers": {}, 140 | "app": app, 141 | "method": "get", 142 | }, 143 | dummy_receive, 144 | ) 145 | response = await route.endpoint(request) 146 | assert isinstance(response, JSONResponse) 147 | 148 | 149 | def test_init_base_argument_is_optional(spec_dict): 150 | app = Application(spec_dict) 151 | assert app.routes == [] 152 | 153 | 154 | def test_set_endpoint_method(spec_dict): 155 | from .endpoints import dummy_test_endpoint 156 | 157 | app = Application(spec_dict) 158 | assert app.routes == [] 159 | 160 | app.set_endpoint(dummy_test_endpoint) 161 | 162 | route = app.routes[0] 163 | assert route.endpoint is not dummy_test_endpoint 164 | assert route.endpoint.__wrapped__ is dummy_test_endpoint 165 | assert not iscoroutinefunction(dummy_test_endpoint) 166 | assert iscoroutinefunction(route.endpoint) 167 | 168 | 169 | def test_endpoint_decorator(spec_dict): 170 | app = Application(spec_dict) 171 | assert app.routes == [] 172 | 173 | @app.endpoint 174 | def dummy_test_endpoint(request): 175 | return {} 176 | 177 | route = app.routes[0] 178 | assert route.endpoint is not dummy_test_endpoint 179 | assert route.endpoint.__wrapped__ is dummy_test_endpoint 180 | assert not iscoroutinefunction(dummy_test_endpoint) 181 | assert iscoroutinefunction(route.endpoint) 182 | 183 | 184 | def test_endpoint_decorator_with_operation_id(spec_dict): 185 | operation_id = "dummyTestEndpoint" 186 | app = Application(spec_dict) 187 | assert app.routes == [] 188 | 189 | @app.endpoint(operation_id) 190 | def foo_bar(request): 191 | return {} 192 | 193 | route = app.routes[0] 194 | operation = app._operations[operation_id] 195 | assert route.path.endswith(operation.path) 196 | assert operation.method.upper() in route.methods 197 | assert route.endpoint is not foo_bar 198 | assert route.endpoint.__wrapped__ is foo_bar 199 | assert not iscoroutinefunction(foo_bar) 200 | assert iscoroutinefunction(route.endpoint) 201 | 202 | 203 | def test_endpoint_decorator_with_incorrect_operation_id(spec_dict): 204 | operation_id = "iDontExist" 205 | app = Application(spec_dict) 206 | assert app.routes == [] 207 | 208 | with pytest.raises(ValueError) as ex: 209 | 210 | @app.endpoint(operation_id) 211 | def foo_bar(request): 212 | return {} 213 | 214 | assert str(ex) == f"ValueError: Unknown operationId: {operation_id}." 215 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = checks, py38, py39, py310 4 | 5 | [testenv] 6 | skip_install = true 7 | deps = 8 | poetry 9 | pytest-cov 10 | mypy 11 | commands = 12 | pytest --cov 13 | mypy --install-types --non-interactive src/ 14 | 15 | [testenv:checks] 16 | deps = 17 | flake8 18 | black 19 | pydocstyle 20 | toml 21 | commands = 22 | flake8 23 | black --check src/ 24 | pydocstyle src/ 25 | --------------------------------------------------------------------------------