├── .github └── workflows │ ├── main.yml │ └── pypi-package.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── docs ├── Makefile ├── _static │ ├── custom.css │ └── fonts │ │ ├── ubuntu-mono-v15-latin-700.woff │ │ ├── ubuntu-mono-v15-latin-700.woff2 │ │ ├── ubuntu-mono-v15-latin-700italic.woff │ │ ├── ubuntu-mono-v15-latin-700italic.woff2 │ │ ├── ubuntu-mono-v15-latin-italic.woff │ │ ├── ubuntu-mono-v15-latin-italic.woff2 │ │ ├── ubuntu-mono-v15-latin-regular.woff │ │ └── ubuntu-mono-v15-latin-regular.woff2 ├── addons.md ├── changelog.md ├── composition.md ├── conf.py ├── handlers.md ├── index.md ├── indices.md ├── make.bat ├── modules.rst ├── openapi.md ├── response_shorthands.md ├── uapi.login.rst ├── uapi.openapi_ui.rst ├── uapi.rst └── uapi.sessions.rst ├── pdm.lock ├── pyproject.toml ├── src └── uapi │ ├── __init__.py │ ├── _openapi.py │ ├── aiohttp.py │ ├── attrschema.py │ ├── base.py │ ├── cookies.py │ ├── django.py │ ├── flask.py │ ├── login │ └── __init__.py │ ├── openapi.py │ ├── openapi_ui │ ├── __init__.py │ ├── elements.html │ ├── redoc.html │ └── swaggerui.html │ ├── path.py │ ├── py.typed │ ├── quart.py │ ├── requests.py │ ├── responses.py │ ├── sessions │ ├── __init__.py │ └── redis.py │ ├── shorthands.py │ ├── starlette.py │ ├── status.py │ └── types.py ├── tests ├── __init__.py ├── aiohttp.py ├── apps.py ├── conftest.py ├── django.py ├── django_uapi │ ├── __init__.py │ ├── django_uapi │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ └── manage.py ├── django_uapi_app │ ├── __init__.py │ ├── apps.py │ └── views.py ├── flask.py ├── login │ ├── __init__.py │ └── test_login.py ├── models.py ├── models_2.py ├── openapi │ ├── __init__.py │ ├── conftest.py │ ├── test_openapi.py │ ├── test_openapi_attrs.py │ ├── test_openapi_composition.py │ ├── test_openapi_forms.py │ ├── test_openapi_headers.py │ ├── test_openapi_metadata.py │ ├── test_openapi_uis.py │ └── test_shorthands.py ├── quart.py ├── response_classes.py ├── sessions │ ├── __init__.py │ ├── conftest.py │ ├── test_redis_sessions.py │ └── test_secure_cookie_sessions.py ├── starlette.py ├── test_attrs.py ├── test_composition.py ├── test_cookies.py ├── test_delete.py ├── test_exceptions.py ├── test_forms.py ├── test_framework_escape_hatch.py ├── test_get.py ├── test_head.py ├── test_headers.py ├── test_options.py ├── test_patch.py ├── test_path.py ├── test_post.py ├── test_put.py ├── test_query.py ├── test_shorthands.py ├── test_shorthands.yml └── test_subapps.py └── tox.ini /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: ["main"] 7 | pull_request: 8 | branches: ["main"] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | tests: 13 | name: "Python ${{ matrix.python-version }}" 14 | runs-on: "ubuntu-latest" 15 | 16 | strategy: 17 | matrix: 18 | python-version: ["3.10", "3.11", "3.12", "3.13"] 19 | redis-version: [6] 20 | 21 | steps: 22 | - uses: "actions/checkout@v4" 23 | 24 | - uses: "pdm-project/setup-pdm@v4" 25 | with: 26 | python-version: "${{ matrix.python-version }}" 27 | allow-python-prereleases: true 28 | cache: true 29 | version: "2.18.1" 30 | 31 | - name: "Start Redis" 32 | uses: "supercharge/redis-github-action@1.2.0" 33 | with: 34 | redis-version: "${{ matrix.redis-version }}" 35 | 36 | - name: "Run Tox" 37 | run: | 38 | python -Im pip install --upgrade tox tox-gh-actions 39 | python -Im tox 40 | 41 | - name: Upload coverage data 42 | uses: actions/upload-artifact@v4 43 | with: 44 | name: coverage-data-${{ matrix.python-version }} 45 | path: .coverage.* 46 | if-no-files-found: ignore 47 | include-hidden-files: true 48 | 49 | coverage: 50 | name: "Combine & check coverage." 51 | needs: "tests" 52 | runs-on: "ubuntu-latest" 53 | 54 | steps: 55 | - uses: "actions/checkout@v4" 56 | 57 | - uses: "actions/setup-python@v5" 58 | with: 59 | cache: "pip" 60 | python-version: "3.12" 61 | 62 | - run: "python -Im pip install --upgrade coverage[toml]" 63 | 64 | - name: Download coverage data 65 | uses: actions/download-artifact@v4 66 | with: 67 | pattern: coverage-data-* 68 | merge-multiple: true 69 | 70 | - name: "Combine coverage" 71 | run: | 72 | python -Im coverage combine 73 | python -Im coverage html --skip-covered --skip-empty 74 | python -Im coverage json 75 | 76 | # Report and write to summary. 77 | python -Im coverage report | sed 's/^/ /' >> $GITHUB_STEP_SUMMARY 78 | 79 | export TOTAL=$(python -c "import json;print(json.load(open('coverage.json'))['totals']['percent_covered_display'])") 80 | echo "total=$TOTAL" >> $GITHUB_ENV 81 | 82 | # Report again and fail if under the threshold. 83 | python -Im coverage report --fail-under=97 84 | 85 | - name: "Upload HTML report." 86 | uses: "actions/upload-artifact@v4" 87 | with: 88 | name: "html-report" 89 | path: "htmlcov" 90 | if: always() 91 | 92 | - name: "Make badge" 93 | if: github.ref == 'refs/heads/main' 94 | uses: "schneegans/dynamic-badges-action@v1.4.0" 95 | with: 96 | # GIST_TOKEN is a GitHub personal access token with scope "gist". 97 | auth: ${{ secrets.GIST_TOKEN }} 98 | gistID: fe982b645791164107bd8f6699ed0a38 99 | filename: covbadge.json 100 | label: Coverage 101 | message: ${{ env.total }}% 102 | minColorRange: 50 103 | maxColorRange: 90 104 | valColorRange: ${{ env.total }} 105 | 106 | package: 107 | name: "Build & verify package" 108 | runs-on: "ubuntu-latest" 109 | 110 | steps: 111 | - uses: "actions/checkout@v3" 112 | 113 | - uses: "actions/setup-python@v4" 114 | with: 115 | python-version: "3.11" 116 | 117 | - name: "Install tools" 118 | run: "python -m pip install twine check-wheel-contents build" 119 | 120 | - name: "Build package" 121 | run: "python -m build" 122 | 123 | - name: "List result" 124 | run: "ls -l dist" 125 | 126 | - name: "Check wheel contents" 127 | run: "check-wheel-contents dist/*.whl" 128 | 129 | - name: "Check long_description" 130 | run: "python -m twine check dist/*" 131 | -------------------------------------------------------------------------------- /.github/workflows/pypi-package.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build & maybe upload PyPI package 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | tags: ["*"] 8 | release: 9 | types: 10 | - published 11 | workflow_dispatch: 12 | 13 | permissions: 14 | contents: read 15 | id-token: write 16 | 17 | jobs: 18 | build-package: 19 | name: Build & verify package 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - uses: hynek/build-and-inspect-python-package@v1 28 | 29 | # Upload to Test PyPI on every commit on main. 30 | release-test-pypi: 31 | name: Publish in-dev package to test.pypi.org 32 | environment: release-test-pypi 33 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 34 | runs-on: ubuntu-latest 35 | needs: build-package 36 | 37 | steps: 38 | - name: Download packages built by build-and-inspect-python-package 39 | uses: actions/download-artifact@v3 40 | with: 41 | name: Packages 42 | path: dist 43 | 44 | - name: Upload package to Test PyPI 45 | uses: pypa/gh-action-pypi-publish@release/v1 46 | with: 47 | repository-url: https://test.pypi.org/legacy/ 48 | 49 | # Upload to real PyPI on GitHub Releases. 50 | release-pypi: 51 | name: Publish released package to pypi.org 52 | environment: release-pypi 53 | if: github.event.action == 'published' 54 | runs-on: ubuntu-latest 55 | needs: build-package 56 | 57 | steps: 58 | - name: Download packages built by build-and-inspect-python-package 59 | uses: actions/download-artifact@v3 60 | with: 61 | name: Packages 62 | path: dist 63 | 64 | - name: Upload package to PyPI 65 | uses: pypa/gh-action-pypi-publish@release/v1 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build 2 | .pdm-python 3 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.10" 7 | jobs: 8 | post_install: 9 | - "curl -sSL https://raw.githubusercontent.com/pdm-project/pdm/main/install-pdm.py | python3 -" 10 | - "VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH ~/.local/bin/pdm sync -dG docs" 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | This project adheres to [_Calendar Versioning_](https://calver.org/). 6 | 7 | The **first number** of the version is the year. 8 | The **second number** is incremented with each release, starting at 1 for each year. 9 | The **third number** is for emergencies when we need to start branches for older releases. 10 | 11 | 12 | 13 | ## [v24.1.0](https://github.com/tinche/uapi/compare/v23.3.0...HEAD) - UNRELEASED 14 | 15 | ### Added 16 | 17 | - `typing.Any` is now supported in the OpenAPI schema, rendering to an empty schema. 18 | ([#58](https://github.com/Tinche/uapi/pull/58)) 19 | - Dictionaries are now supported in the OpenAPI schema, rendering to object schemas with `additionalProperties`. 20 | ([#58](https://github.com/Tinche/uapi/pull/58)) 21 | - {meth}`uapi.flask.FlaskApp.run`, {meth}`uapi.quart.QuartApp.run` and {meth}`uapi.starlette.StarletteApp.run` now expose `host` parameters. 22 | ([#59](https://github.com/Tinche/uapi/pull/59)) 23 | - _uapi_ is now tested against Python 3.13. 24 | ([#60](https://github.com/Tinche/uapi/pull/60)) 25 | 26 | ## [v23.3.0](https://github.com/tinche/uapi/compare/v23.2.0...v23.3.0) - 2023-12-20 27 | 28 | ### Changed 29 | 30 | - Return types of handlers are now type-checked. 31 | ([#57](https://github.com/Tinche/uapi/pull/57)) 32 | - Introduce [Response Shorthands](https://uapi.threeofwands.com/en/latest/response_shorthands.html), port the `str`, `bytes`, `None` and _attrs_ response types to them. 33 | ([#57](https://github.com/Tinche/uapi/pull/57)) 34 | - Unions containing shorthands and _uapi_ response classes (and any combination of these) are now better supported. 35 | ([#57](https://github.com/Tinche/uapi/pull/57)) 36 | - _uapi_ is now tested against Mypy. 37 | ([#57](https://github.com/Tinche/uapi/pull/57)) 38 | 39 | ## [v23.2.0](https://github.com/tinche/uapi/compare/v23.1.0...v23.2.0) - 2023-12-10 40 | 41 | ### Changed 42 | 43 | - [`datetime.datetime`](https://docs.python.org/3/library/datetime.html#datetime-objects) and [`datetime.date`](https://docs.python.org/3/library/datetime.html#date-objects) are now supported in the OpenAPI schema, both in models and handler parameters. 44 | ([#53](https://github.com/Tinche/uapi/pull/53)) 45 | - Simple forms are now supported using `uapi.ReqForm[T]`. [Learn more](handlers.md#forms). 46 | ([#54](https://github.com/Tinche/uapi/pull/54)) 47 | - _uapi_ now sorts imports using Ruff. 48 | 49 | ## [v23.1.0](https://github.com/tinche/uapi/compare/v22.1.0...v23.1.0) - 2023-11-12 50 | 51 | ### Changed 52 | 53 | - Add the initial header implementation. 54 | - Function composition (dependency injection) is now documented. 55 | - Endpoints can be excluded from OpenAPI generation by passing them to `App.make_openapi_spec(exclude=...)` or `App.serve_openapi(exclude=...)`. 56 | - Initial implementation of OpenAPI security schemas, supporting the `apikey` type in Redis session backend. 57 | - Update the Elements OpenAPI UI to better handle cookies. 58 | - Flesh out the documentation for response types. 59 | - Add OpenAPI support for string literal fields. 60 | - Add OpenAPI support for generic _attrs_ classes. 61 | - Add OpenAPI support for unions of a single _attrs_ class and `None` (optionals). 62 | - Properly set the OpenAPI `required` attribute for _attrs_ fields without defaults. 63 | - Add OpenAPI support for primitive types in unions. 64 | - _uapi_ now uses [PDM](https://pdm.fming.dev/latest/). 65 | - Dictionary request bodies and _attrs_ classes with dictionary fields are now supported. 66 | - OpenAPI `operationId` properties for operations are now generated from handler names. 67 | - OpenAPI summaries and descriptions are now supported, and can be overridden. 68 | - `aiohttp.web.StreamResponse` is now handled as the root class of aiohttp responses. 69 | - {meth}`uapi.aiohttp.AiohttpApp.run` now uses the [aiohttp App runners](https://docs.aiohttp.org/en/stable/web_advanced.html#application-runners) internally. 70 | - _uapi_ is now tested against Flask 3. 71 | - _uapi_ is now tested against Python 3.12. 72 | 73 | ### Fixed 74 | 75 | - Stringified annotations for return types are now handled properly. 76 | - Framework-specific request objects are ignored for OpenAPI. 77 | - Fix OpenAPI generation so items produced by the dependency injection system are properly generated. 78 | - Fix OpenAPI generation for models with identical names. 79 | - Fix OpenAPI generation for response models with lists of attrs classes. 80 | 81 | ## [v22.1.0](https://github.com/tinche/uapi/compare/63cd8336f229f3a007f8fce7e9791b22abaf75d9...v22.1.0) - 2022-12-07 82 | 83 | ### Changed 84 | 85 | - Changelog starts. 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 12 | 13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 14 | 15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 16 | 17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 18 | 19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 20 | 21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 22 | 23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 24 | 25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 26 | 27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 28 | 29 | 2. Grant of Copyright License. 30 | 31 | Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 32 | 33 | 3. Grant of Patent License. 34 | 35 | Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 36 | 37 | 4. Redistribution. 38 | 39 | You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 40 | 41 | You must give any other recipients of the Work or Derivative Works a copy of this License; and 42 | You must cause any modified files to carry prominent notices stating that You changed the files; and 43 | You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 44 | If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 45 | 46 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 47 | 48 | 5. Submission of Contributions. 49 | 50 | Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 51 | 52 | 6. Trademarks. 53 | 54 | This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 55 | 56 | 7. Disclaimer of Warranty. 57 | 58 | Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 59 | 60 | 8. Limitation of Liability. 61 | 62 | In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 63 | 64 | 9. Accepting Warranty or Additional Liability. 65 | 66 | While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 67 | 68 | END OF TERMS AND CONDITIONS 69 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test lint 2 | 3 | test: 4 | pdm run pytest tests -x --ff --mypy-only-local-stub 5 | 6 | lint: 7 | pdm run mypy src/ tests/ && pdm run ruff src/ tests/ && pdm run black --check -q src/ tests/ 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uapi 2 | 3 | [![Documentation](https://img.shields.io/badge/Docs-Read%20The%20Docs-black)](https://uapi.threeofwands.com) 4 | [![License: Apache2](https://img.shields.io/badge/license-Apache2-C06524)](https://github.com/Tinche/uapi/blob/main/LICENSE) 5 | [![PyPI](https://img.shields.io/pypi/v/uapi.svg)](https://pypi.python.org/pypi/uapi) 6 | [![Supported Python Versions](https://img.shields.io/pypi/pyversions/uapi.svg)](https://github.com/Tinche/uapi) 7 | [![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/Tinche/fe982b645791164107bd8f6699ed0a38/raw/covbadge.json)](https://github.com/Tinche/uapi/actions/workflows/main.yml) 8 | 9 | _uapi_ is an elegant, high-level, extremely low-overhead Python microframework for writing HTTP APIs, either synchronously or asynchronously. 10 | 11 | _uapi_ uses a lower-level HTTP framework to run. Currently supported frameworks are aiohttp, Django, Flask, Quart, and Starlette. 12 | An _uapi_ app can be easily integrated into an existing project based on one of these frameworks, and a pure _uapi_ project can be easily switched between them when needed. 13 | 14 | Using _uapi_ enables you to: 15 | 16 | - write **either async or sync** styles of handlers, depending on the underlying framework used. 17 | - use and customize a [**function composition** (dependency injection) system](https://uapi.threeofwands.com/en/stable/composition.html), based on [incant](https://incant.threeofwands.com). 18 | - automatically **serialize and deserialize** data through [attrs](https://www.attrs.org) and [cattrs](https://catt.rs). 19 | - generate and use [**OpenAPI**](https://uapi.threeofwands.com/en/stable/openapi.html) descriptions of your endpoints. 20 | - optionally **type-check** your handlers with [Mypy](https://mypy.readthedocs.io/en/stable/). 21 | - write and use reusable and [**powerful middleware**](https://uapi.threeofwands.com/en/stable/addons.html), which integrates into the OpenAPI schema. 22 | - **integrate** with existing apps based on [Django](https://docs.djangoproject.com/en/stable/), [Starlette](https://www.starlette.io/), [Flask](https://flask.palletsprojects.com), [Quart](https://pgjones.gitlab.io/quart/) or [aiohttp](https://docs.aiohttp.org). 23 | 24 | Here's a simple taste (install Flask and gunicorn first): 25 | 26 | ```python3 27 | from uapi.flask import App 28 | 29 | app = App() 30 | 31 | @app.get("/") 32 | def index() -> str: 33 | return "Index" 34 | 35 | app.serve_openapi() 36 | app.serve_elements() 37 | 38 | app.run(__name__) # Now open http://localhost:8000/elements 39 | ``` 40 | 41 | ## Project Information 42 | 43 | - [**PyPI**](https://pypi.org/project/uapi/) 44 | - [**Source Code**](https://github.com/Tinche/uapi) 45 | - [**Documentation**](https://uapi.threeofwands.com) 46 | - [**Changelog**](https://uapi.threeofwands.com/en/latest/changelog.html) 47 | 48 | ## License 49 | 50 | _uapi_ is written by [Tin Tvrtković](https://threeofwands.com/) and distributed under the terms of the [Apache-2.0](https://spdx.org/licenses/Apache-2.0.html) license. 51 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= pdm run sphinx-build -M 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | $(SPHINXBUILD) $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | 22 | apidoc: 23 | pdm run sphinx-apidoc -o . ../src/uapi -f 24 | 25 | ## htmllive to rebuild and reload HTML files in your browser 26 | .PHONY: htmllive 27 | htmllive: SPHINXBUILD = pdm run sphinx-autobuild -b 28 | htmllive: SPHINXERRORHANDLING = --re-ignore="/\.idea/|/venv/|/topic/" 29 | htmllive: html -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | @import url('https://rsms.me/inter/inter.css'); 2 | 3 | :root { 4 | font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */ 5 | } 6 | 7 | @supports (font-variation-settings: normal) { 8 | :root { font-family: InterVariable, sans-serif; } 9 | } 10 | 11 | /* ubuntu-mono-regular - latin */ 12 | @font-face { 13 | font-family: "Ubuntu Mono"; 14 | font-style: normal; 15 | font-weight: 400; 16 | src: local(""), 17 | url("./fonts/ubuntu-mono-v15-latin-regular.woff2") format("woff2"), 18 | /* Chrome 26+, Opera 23+, Firefox 39+ */ 19 | url("./fonts/ubuntu-mono-v15-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 20 | } 21 | 22 | /* ubuntu-mono-italic - latin */ 23 | @font-face { 24 | font-family: "Ubuntu Mono"; 25 | font-style: italic; 26 | font-weight: 400; 27 | src: local(""), 28 | url("./fonts/ubuntu-mono-v15-latin-italic.woff2") format("woff2"), 29 | /* Chrome 26+, Opera 23+, Firefox 39+ */ 30 | url("./fonts/ubuntu-mono-v15-latin-italic.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 31 | } 32 | 33 | /* ubuntu-mono-700 - latin */ 34 | @font-face { 35 | font-family: "Ubuntu Mono"; 36 | font-style: normal; 37 | font-weight: 700; 38 | src: local(""), url("./fonts/ubuntu-mono-v15-latin-700.woff2") format("woff2"), 39 | /* Chrome 26+, Opera 23+, Firefox 39+ */ 40 | url("./fonts/ubuntu-mono-v15-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 41 | } 42 | 43 | /* ubuntu-mono-700italic - latin */ 44 | @font-face { 45 | font-family: "Ubuntu Mono"; 46 | font-style: italic; 47 | font-weight: 700; 48 | src: local(""), 49 | url("./fonts/ubuntu-mono-v15-latin-700italic.woff2") format("woff2"), 50 | /* Chrome 26+, Opera 23+, Firefox 39+ */ 51 | url("./fonts/ubuntu-mono-v15-latin-700italic.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 52 | } 53 | 54 | h2, 55 | h3 { 56 | margin-bottom: 0.5em; 57 | margin-top: 2rem; 58 | } 59 | 60 | :target > h1:first-of-type, 61 | :target > h2:first-of-type, 62 | :target > h3:first-of-type, 63 | span:target ~ h1:first-of-type, 64 | span:target ~ h2:first-of-type, 65 | span:target ~ h3:first-of-type, 66 | span:target ~ h4:first-of-type, 67 | span:target ~ h5:first-of-type, 68 | span:target ~ h6:first-of-type { 69 | text-decoration: underline dashed; 70 | text-decoration-thickness: 1px; 71 | } 72 | 73 | div.article-container > article { 74 | font-size: 17px; 75 | line-height: 31px; 76 | } 77 | 78 | div.admonition { 79 | font-size: 15px; 80 | line-height: 27px; 81 | margin-top: 2em; 82 | margin-bottom: 2em; 83 | } 84 | 85 | p.admonition-title { 86 | font-size: 15px !important; 87 | line-height: 20px !important; 88 | } 89 | 90 | article > li > a { 91 | font-size: 19px; 92 | line-height: 31px; 93 | } 94 | 95 | div.tab-set { 96 | margin-top: 1em; 97 | margin-bottom: 2em; 98 | } 99 | 100 | div.tab-set pre { 101 | padding: 1.25em; 102 | } 103 | 104 | body .highlight.only_dark { 105 | background: #18181a; 106 | } 107 | -------------------------------------------------------------------------------- /docs/_static/fonts/ubuntu-mono-v15-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/docs/_static/fonts/ubuntu-mono-v15-latin-700.woff -------------------------------------------------------------------------------- /docs/_static/fonts/ubuntu-mono-v15-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/docs/_static/fonts/ubuntu-mono-v15-latin-700.woff2 -------------------------------------------------------------------------------- /docs/_static/fonts/ubuntu-mono-v15-latin-700italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/docs/_static/fonts/ubuntu-mono-v15-latin-700italic.woff -------------------------------------------------------------------------------- /docs/_static/fonts/ubuntu-mono-v15-latin-700italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/docs/_static/fonts/ubuntu-mono-v15-latin-700italic.woff2 -------------------------------------------------------------------------------- /docs/_static/fonts/ubuntu-mono-v15-latin-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/docs/_static/fonts/ubuntu-mono-v15-latin-italic.woff -------------------------------------------------------------------------------- /docs/_static/fonts/ubuntu-mono-v15-latin-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/docs/_static/fonts/ubuntu-mono-v15-latin-italic.woff2 -------------------------------------------------------------------------------- /docs/_static/fonts/ubuntu-mono-v15-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/docs/_static/fonts/ubuntu-mono-v15-latin-regular.woff -------------------------------------------------------------------------------- /docs/_static/fonts/ubuntu-mono-v15-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/docs/_static/fonts/ubuntu-mono-v15-latin-regular.woff2 -------------------------------------------------------------------------------- /docs/addons.md: -------------------------------------------------------------------------------- 1 | # Addons 2 | 3 | _uapi_ ships with several useful addons. 4 | 5 | ## Redis Async Sessions 6 | 7 | ```{tip} 8 | This addon handles server-side sessions, which are anonymous by default. 9 | If you're looking for login functionality, see [uapi.login](#uapilogin) which builds on top of sessions. 10 | ``` 11 | 12 | The {meth}`uapi.sessions.redis.configure_async_sessions` addon enables the use of cookie sessions using Redis as the session store. 13 | This addon requires the use of an [aioredis 1.3](https://pypi.org/project/aioredis/1.3.1/) connection pool. 14 | 15 | First, configure the addon by giving it your `app` instance and optionally overridding parameters: 16 | 17 | ```python 18 | from aioredis import create_pool 19 | from uapi.sessions.redis import configure_async_sessions 20 | 21 | session_store = configure_async_sessions(app, await create_pool(...)) 22 | ``` 23 | 24 | Once configured, handlers may declare a parameter of type {class}`uapi.sessions.redis.AsyncSession`. 25 | The session object is a `dict[str, str]` subclass, and it needs to have the {meth}`uapi.sessions.redis.AsyncSession.update_session()` coroutine awaited to persist the session. 26 | 27 | ```python 28 | async def my_session_handler(session: AsyncSession) -> None: 29 | session['my_key'] = 'value' 30 | await session.update_session() 31 | ``` 32 | 33 | Multiple sessions using multiple cookies can be configured in parallel. 34 | If this is the case, the `session_arg_param_name` argument can be used to customize the name of the session parameter being injected. 35 | 36 | ```python 37 | another_session_store = configure_async_sessions( 38 | app, 39 | redis, 40 | cookie_name="another_session_cookie", 41 | session_arg_param_name="another_session", 42 | ) 43 | 44 | async def a_different_handler(another_session: AsyncSession) -> None: 45 | session['my_key'] = 'value' 46 | await another_session.update_session() 47 | ``` 48 | 49 | ## uapi.login 50 | 51 | The {meth}`uapi.login ` addon enables login/logout for _uapi_ apps. 52 | 53 | The _login_ addon requires a configured session store. 54 | Then, assuming the user IDs are ints, apply the addon and store the {class}`login_manager ` somewhere: 55 | 56 | ```python 57 | from uapi.login import configure_async_login 58 | 59 | login_manager = configure_async_login(app, int, session_store) 60 | ``` 61 | 62 | You'll need a login endpoint: 63 | 64 | ```python 65 | from uapi.login import AsyncLoginSession 66 | 67 | async def login(login_session: AsyncLoginSession) -> Ok[None]: 68 | if login_session.user_id is not None: 69 | # Looks like this session is already associated with a user. 70 | return Ok(None) 71 | 72 | # Check credentials, like a password or token 73 | return Ok(None, await login_session.login_and_return(user_id)) 74 | ``` 75 | 76 | Now your app's handlers can declare the `current_user_id` parameter for dependency injection: 77 | 78 | ```python 79 | async def requires_logged_in_user(current_user_id: int) -> None: 80 | pass 81 | ``` 82 | 83 | An unauthenticated request will be denied with a `Forbidden` response. 84 | 85 | A user can be logged out using {meth}`AsyncLoginManager.logout() `. 86 | 87 | ```python 88 | async def logout_user() -> None: 89 | await login_manager.logout(user_id) 90 | ``` 91 | 92 | ```{admonition} Security 93 | :class: danger 94 | 95 | The Redis Async session store uses cookies with the _SameSite_ attribute set to _lax_ by default, 96 | providing a degree of protection against cross-site request forgery when using [forms](handlers.md#forms). 97 | 98 | [Extra care](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) should be provided to the login endpoint. 99 | ``` -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CHANGELOG.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/composition.md: -------------------------------------------------------------------------------- 1 | # Handler Composition Context 2 | 3 | Handlers and middleware may be composed with the results of other functions (and coroutines, when using an async framework); this is commonly known as dependency injection. 4 | The composition context is a set of rules governing how and when this happens. 5 | _uapi_ uses the [_Incant_](https://incant.threeofwands.com) library for function composition. 6 | 7 | _uapi_ includes a number of composition rules by default, but users and third-party middleware are encouraged to define their own rules. 8 | 9 | ## Path and Query Parameters 10 | 11 | Path and query parameters can be provided to handlers and middleware, see [](handlers.md#query-parameters) and [](handlers.md#path-parameters) for details. 12 | 13 | ## Headers and Cookies 14 | 15 | Headers and cookies can be provided to handlers and middleware, see [](handlers.md#headers) and see [](handlers.md#cookies) for details. 16 | 17 | ## JSON Payloads as _attrs_ Classes 18 | 19 | JSON payloads, structured into _attrs_ classes by _cattrs_, can by provided to handlers and middleware. See [](handlers.md#attrs-classes) for details. 20 | 21 | ## Route Metadata 22 | 23 | ```{tip} 24 | _Routes_ are different than _handlers_; a single handler may be registered on multiple routes. 25 | ``` 26 | 27 | Route metadata can be provided to handlers and middleware, although it can be more useful to middleware. 28 | 29 | - The route name will be provided if a parameter is annotated as {class}`uapi.RouteName `, which is a string-based NewType. 30 | - The request HTTP method will be provided if a parameter is annotated as {class}`uapi.Method `, which is a string Literal. 31 | 32 | Here's an example using both: 33 | 34 | ```python 35 | from uapi import Method, RouteName 36 | 37 | @app.get("/") 38 | def route_name_and_method(route_name: RouteName, method: Method) -> str: 39 | return f"I am route {route_name}, requested with {method}" 40 | ``` 41 | 42 | ## Customizing the Context 43 | 44 | The composition context can be customized by defining and then using Incant hooks on the {class}`App.incant ` Incanter instance. 45 | 46 | For example, say you'd like to receive a token of some sort via a header, validate it and transform it into a user ID. 47 | The handler should look like this: 48 | 49 | ```python 50 | @app.get("/valid-header") 51 | def non_public_handler(user_id: str) -> str: 52 | return "Hello {user_id}!" 53 | ``` 54 | 55 | Without any additional configuration, _uapi_ thinks the `user_id` parameter is supposed to be a mandatory [query parameter](handlers.md#query-parameters). 56 | First, we need to create a dependency hook for our use case and register it with the App Incanter. 57 | 58 | ```python 59 | from uapi import Header 60 | 61 | @app.incant.register_by_name("user_id") 62 | def validate_token_and_fetch_user(session_token: Header[str]) -> str: 63 | # session token value will be injected from the `session-token` header 64 | 65 | user_id = validate(session_token) # Left as an exercize to the reader 66 | 67 | return user_id 68 | ``` 69 | 70 | Now our `non_public_handler` handler will have the validated user ID provided to it. 71 | 72 | ```{note} 73 | Since Incant is a true function composition library, the `session-token` dependency will also show up in the generated OpenAPI schema. 74 | This is true of all dependency hooks and middleware. 75 | 76 | The final handler signature available to _uapi_ at time of serving contains all the dependencies as function arguments. 77 | ``` 78 | 79 | ## Extending the Context 80 | 81 | The composition context can be extended with arbitrary dependencies. 82 | 83 | For example, imagine your application needs to perform HTTP requests. 84 | Ideally, the handlers should use a shared connection pool instance for efficiency. 85 | Here's a complete implementation of a very simple HTTP proxy. 86 | The example can be pasted and ran as-is as long as Starlette and Uvicorn are available. 87 | 88 | ```python 89 | from asyncio import run 90 | 91 | from httpx import AsyncClient 92 | 93 | from uapi.starlette import App 94 | 95 | app = App() 96 | 97 | _client = AsyncClient() # We only want one. 98 | app.incant.register_by_type(lambda: _client, type=AsyncClient) 99 | 100 | 101 | @app.get("/proxy") 102 | async def proxy(client: AsyncClient) -> str: 103 | """We just return the payload at www.example.com.""" 104 | return (await client.get("http://example.com")).read().decode() 105 | 106 | 107 | run(app.run()) 108 | ``` 109 | 110 | ## Integrating the `svcs` Package 111 | 112 | If you'd like to get more serious about application architecture, one of the approaches is to use the [svcs](https://svcs.hynek.me/) library. 113 | Here's a way of integrating it into _uapi_. 114 | 115 | ```python 116 | from httpx import AsyncClient 117 | from svcs import Container, Registry 118 | from asyncio import run 119 | 120 | from uapi.starlette import App 121 | 122 | reg = Registry() 123 | 124 | app = App() 125 | app.incant.register_by_type( 126 | lambda: Container(reg), type=Container, is_ctx_manager="async" 127 | ) 128 | 129 | 130 | @app.get("/proxy") 131 | async def proxy(container: Container) -> str: 132 | """We just return the payload at www.example.com.""" 133 | client = await container.aget(AsyncClient) 134 | return (await client.get("http://example.com")).read().decode() 135 | 136 | async def main() -> None: 137 | async with AsyncClient() as client: # Clean up connections at the end 138 | reg.register_value(AsyncClient, client, enter=False) 139 | await app.run() 140 | 141 | run(main()) 142 | ``` 143 | 144 | We can go even further and instead of providing the `container`, we can provide anything the container contains too. 145 | 146 | ```python 147 | from collections.abc import Callable 148 | from inspect import Parameter 149 | from asyncio import run 150 | 151 | from httpx import AsyncClient 152 | from svcs import Container, Registry 153 | 154 | from uapi.starlette import App 155 | 156 | reg = Registry() 157 | 158 | 159 | app = App() 160 | app.incant.register_by_type( 161 | lambda: Container(reg), type=Container, is_ctx_manager="async" 162 | ) 163 | 164 | 165 | def svcs_hook_factory(parameter: Parameter) -> Callable: 166 | t = parameter.annotation 167 | 168 | async def from_container(c: Container): 169 | return await c.aget(t) 170 | 171 | return from_container 172 | 173 | 174 | app.incant.register_hook_factory(lambda p: p.annotation in reg, svcs_hook_factory) 175 | 176 | 177 | @app.get("/proxy") 178 | async def proxy(client: AsyncClient) -> str: 179 | """We just return the payload at www.example.com.""" 180 | return (await client.get("http://example.com")).read().decode() 181 | 182 | 183 | async def main() -> None: 184 | async with AsyncClient() as client: 185 | reg.register_value(AsyncClient, client, enter=False) 186 | await app.run() 187 | 188 | 189 | run(main()) 190 | ``` 191 | 192 | ```{note} 193 | The _svcs_ library includes integrations for several popular web frameworks, and code examples for them. 194 | The examples shown here are independent of the underlying web framework used; they will work on all of them (with a potential sync/async tweak). 195 | ``` 196 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | from importlib.metadata import version as v 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "uapi" 21 | copyright = "2022, Tin Tvrtkovic" 22 | author = "Tin Tvrtkovic" 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = v("uapi") 26 | if "dev" in release: 27 | release = version = "UNRELEASED" 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = ["sphinx.ext.autodoc", "myst_parser", "sphinx_inline_tabs"] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ["_templates"] 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This pattern also affects html_static_path and html_extra_path. 43 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 44 | 45 | 46 | # -- Options for HTML output ------------------------------------------------- 47 | 48 | # The theme to use for HTML and HTML Help pages. See the documentation for 49 | # a list of builtin themes. 50 | # 51 | html_theme = "furo" 52 | 53 | # Add any paths that contain custom static files (such as style sheets) here, 54 | # relative to this directory. They are copied after the builtin static files, 55 | # so a file named "default.css" will overwrite the builtin "default.css". 56 | html_static_path = ["_static"] 57 | 58 | html_css_files = ["custom.css"] 59 | html_theme_options = { 60 | "light_css_variables": { 61 | "font-stack": "Inter,sans-serif", 62 | "font-stack--monospace": "'Ubuntu Mono', monospace", 63 | "code-font-size": "90%", 64 | "color-highlight-on-target": "transparent", 65 | }, 66 | "dark_css_variables": {"color-highlight-on-target": "transparent"}, 67 | } 68 | 69 | myst_heading_anchors = 3 70 | myst_enable_extensions = ["attrs_block"] 71 | autodoc_typehints = "description" 72 | autoclass_content = "both" 73 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to uapi! 2 | 3 | ```{toctree} 4 | :maxdepth: 1 5 | :caption: "Contents:" 6 | :hidden: 7 | 8 | self 9 | handlers.md 10 | composition.md 11 | openapi.md 12 | addons.md 13 | response_shorthands.md 14 | changelog.md 15 | indices.md 16 | modules.rst 17 | ``` 18 | 19 | _uapi_ is an elegant, fast, and high-level framework for writing network services in Python 3.10 and later. 20 | 21 | Using _uapi_ enables you to: 22 | 23 | - write **either async or sync** styles of handlers, depending on the underlying framework used. 24 | - use and customize a [**function composition** (dependency injection) system](composition.md), based on [incant](https://incant.threeofwands.com). 25 | - automatically **serialize and deserialize** data through [attrs](https://www.attrs.org/en/stable/) and [cattrs](https://catt.rs). 26 | - generate and use [**OpenAPI**](openapi.md) descriptions of your endpoints. 27 | - optionally **type-check** your handlers with [Mypy](https://mypy.readthedocs.io/en/stable/). 28 | - write and use [**powerful middleware**](addons.md), which integrates into the OpenAPI schema. 29 | - **integrate** with existing apps based on [Django](https://docs.djangoproject.com/en/stable/), [Starlette](https://www.starlette.io/), [Flask](https://flask.palletsprojects.com/en/latest/), [Quart](https://pgjones.gitlab.io/quart/) or [Aiohttp](https://docs.aiohttp.org/en/stable/). 30 | 31 | # Installation 32 | 33 | _uapi_ requires an underlying web framework to run. If you are unsure which to pick, we recommend Starlette for a good balance of features and speed. 34 | 35 | ```{tab} Starlette 36 | 37 | $ pip install uapi starlette uvicorn 38 | ``` 39 | 40 | ```{tab} Flask 41 | 42 | $ pip install uapi flask gunicorn 43 | ``` 44 | 45 | ```{tab} Quart 46 | 47 | $ pip install uapi quart uvicorn 48 | ``` 49 | 50 | ```{tab} Django 51 | 52 | $ pip install uapi django gunicorn 53 | ``` 54 | 55 | ```{tab} Aiohttp 56 | 57 | $ pip install uapi aiohttp 58 | ``` 59 | 60 | # Your First Handler 61 | 62 | Let's write a very simple _Hello World_ HTTP handler and expose it on the root path. 63 | 64 | Before we start writing our handlers, we need something to register them with. In _uapi_, that something is an instance of an `App`. 65 | 66 | ````{tab} Starlette 67 | 68 | ```python 69 | from uapi.starlette import App 70 | 71 | app = App() 72 | 73 | @app.get("/") 74 | async def hello() -> str: 75 | return "hello world" 76 | ``` 77 | 78 | ```` 79 | 80 | ````{tab} Flask 81 | 82 | ```python 83 | from uapi.flask import App 84 | 85 | app = App() 86 | 87 | @app.get("/") 88 | def hello() -> str: 89 | return "hello world" 90 | ``` 91 | ```` 92 | 93 | ````{tab} Quart 94 | 95 | ```python 96 | from uapi.quart import App 97 | 98 | app = App() 99 | 100 | @app.get("/") 101 | async def hello() -> str: 102 | return "hello world" 103 | ``` 104 | ```` 105 | 106 | ````{tab} Django 107 | 108 | ```python 109 | from uapi.django import App 110 | 111 | app = App() 112 | 113 | @app.get("/") 114 | def hello() -> str: 115 | return "hello world" 116 | ``` 117 | ```` 118 | 119 | ````{tab} Aiohttp 120 | 121 | ```python 122 | from uapi.aiohttp import App 123 | 124 | app = App() 125 | 126 | @app.get("/") 127 | async def hello() -> str: 128 | return "hello world" 129 | ``` 130 | ```` 131 | 132 | ```{note} 133 | 134 | _uapi_ uses type hints in certain places to minimize boilerplate code. 135 | This doesn't mean you're required to type-check your code using a tool like Mypy, however. 136 | We're not the Python police; you do you. 137 | 138 | Mypy's pretty great, though. 139 | ``` 140 | 141 | Let's start serving the file. 142 | 143 | ````{tab} Starlette 144 | 145 | Change the code to the following, and run it: 146 | ```python 147 | from asyncio import run 148 | from uapi.starlette import App 149 | 150 | app = App() 151 | 152 | @app.get("/") 153 | async def hello() -> str: 154 | return "hello world" 155 | 156 | run(app.run()) 157 | ``` 158 | 159 | ```` 160 | 161 | ````{tab} Flask 162 | 163 | Change the code to the following, and run it: 164 | ```python 165 | from uapi.flask import App 166 | 167 | app = App() 168 | 169 | @app.get("/") 170 | def hello() -> str: 171 | return "hello world" 172 | 173 | app.run(__name__) 174 | ``` 175 | ```` 176 | 177 | ````{tab} Quart 178 | 179 | Change the code to the following, and run it: 180 | ```python 181 | from asyncio import run 182 | from uapi.quart import App 183 | 184 | app = App() 185 | 186 | @app.get("/") 187 | async def hello() -> str: 188 | return "hello world" 189 | 190 | run(app.run(__name__)) 191 | ``` 192 | ```` 193 | 194 | ````{tab} Django 195 | 196 | 197 | ```python 198 | from django.conf import settings 199 | from django.core.handlers.wsgi import WSGIHandler 200 | from django.core.management import execute_from_command_line 201 | 202 | from uapi.django import App 203 | 204 | app = App() 205 | 206 | 207 | @app.get("/") 208 | def hello() -> str: 209 | return "hello world" 210 | 211 | 212 | settings.configure(ALLOWED_HOSTS="*", ROOT_URLCONF=__name__) 213 | 214 | urlpatterns = app.to_urlpatterns() 215 | 216 | if __name__ == "__main__": 217 | execute_from_command_line() 218 | else: # new 219 | application = WSGIHandler() 220 | ``` 221 | 222 | Then run the file using `python runserver`. 223 | 224 | ```{note} 225 | This example uses code from the [µDjango](https://github.com/wsvincent/django-microframework) project. 226 | ``` 227 | ```` 228 | 229 | ````{tab} Aiohttp 230 | 231 | Change the code to the following, and run it: 232 | ```python 233 | from asyncio import run 234 | from uapi.aiohttp import App 235 | 236 | app = App() 237 | 238 | @app.get("/") 239 | async def hello() -> str: 240 | return "hello world" 241 | 242 | run(app.run()) 243 | ``` 244 | ```` 245 | 246 | Your app is now running in development mode on localhost, port 8000. 247 | 248 | ``` 249 | $ curl 127.0.0.1:8000 250 | hello world⏎ 251 | ``` 252 | -------------------------------------------------------------------------------- /docs/indices.md: -------------------------------------------------------------------------------- 1 | # Indices and Tables 2 | 3 | - {any}`genindex` 4 | - {any}`modindex` 5 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | uapi 2 | ==== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | uapi 8 | -------------------------------------------------------------------------------- /docs/openapi.md: -------------------------------------------------------------------------------- 1 | # OpenAPI 2 | 3 | _uapi_ can generate and serve an OpenAPI schema for your API. 4 | 5 | ```python 6 | from uapi import App 7 | 8 | app = App() 9 | 10 | # Register your routes here 11 | 12 | # Serve the schema at /openapi.json by default 13 | app.serve_openapi() 14 | 15 | # Generate the schema, if you want to access it directly or customize it 16 | spec = app.make_openapi_spec() 17 | ``` 18 | 19 | Additionally, _uapi_ also supports serving several OpenAPI documentation viewers: 20 | 21 | ```python 22 | app.serve_swaggerui() 23 | app.serve_redoc() 24 | app.serve_elements() 25 | ``` 26 | 27 | The documentation viewer will be available at its default URL. 28 | 29 | ```{seealso} 30 | {meth}`App.serve_swaggerui() ` 31 | 32 | {meth}`App.serve_redoc() ` 33 | 34 | {meth}`App.serve_elements() ` 35 | ``` 36 | 37 | What is referred to as _routes_ in _uapi_, OpenAPI refers to as _operations_. 38 | This document uses the _uapi_ nomenclature by default. 39 | 40 | _uapi_ comes with OpenAPI schema support for the following types: 41 | 42 | - strings 43 | - integers 44 | - booleans 45 | - floats (`type: number, format: double`) 46 | - bytes (`type: string, format: binary`) 47 | - dates (`type: string, format: date`) 48 | - datetimes (`type: string, format: date-time`) 49 | - lists (`type: array`) 50 | - dictionaries (`type: object`, with `additionalProperties`) 51 | - attrs classes (`type: object`) 52 | - `typing.Any` (empty schema) 53 | 54 | ## Operation Summaries and Descriptions 55 | 56 | OpenAPI allows operations to have summaries and descriptions; summaries are usually used as operation labels in OpenAPI tooling. 57 | 58 | By default, uapi generates summaries from [route names](handlers.md#route-names). 59 | This can be customized by using your own summary transformer, which is a function taking the actual handler function or coroutine and the route name, and returning the summary string. 60 | 61 | ```python 62 | app = App() 63 | 64 | def summary_transformer(handler: Callable, name: str) -> str: 65 | """Use the name of the handler function as the summary.""" 66 | return handler.__name__ 67 | 68 | app.serve_openapi(summary_transformer=summary_transformer) 69 | ``` 70 | 71 | Operation descriptions are generated from handler docstrings by default. 72 | This can again be customized by supplying your own description transformer, with the same signature as the summary transformer. 73 | 74 | ```python 75 | app = App() 76 | 77 | def desc_transformer(handler: Callable, name: str) -> str: 78 | """Use the first line of the docstring as the description.""" 79 | doc = getattr(handler, "__doc__", None) 80 | if doc is not None: 81 | return doc.split("\n")[0] 82 | return None 83 | 84 | app.serve_openapi(description_transformer=desc_transformer) 85 | ``` 86 | 87 | 88 | OpenAPI allows Markdown to be used for descriptions. 89 | 90 | ## Endpoint Tags 91 | 92 | OpenAPI supports grouping endpoints by tags. 93 | You can specify tags for each route when registering it: 94 | 95 | ```python 96 | @app.get("/{article_id}", tags=["articles"]) 97 | async def get_article(article_id: str) -> str: 98 | return "Getting the article" 99 | ``` 100 | 101 | Depending on the OpenAPI visualization framework used, operations with tags are usually displayed grouped under the tag. 102 | -------------------------------------------------------------------------------- /docs/response_shorthands.md: -------------------------------------------------------------------------------- 1 | ```{currentmodule} uapi.shorthands 2 | 3 | ``` 4 | # Response Shorthands 5 | 6 | Custom response shorthands are created by defining a custom instance of the {class}`ResponseShorthand` protocol. 7 | This involves implementing two to four functions, depending on the amount of functionality required. 8 | 9 | ## A `datetime.datetime` Shorthand 10 | 11 | Here are the steps needed to implement a new shorthand, enabling handlers to return [`datetime.datetime`](https://docs.python.org/3/library/datetime.html#datetime-objects) instances directly. 12 | 13 | First, we need to create the shorthand class by subclassing the {class}`ResponseShorthand` generic protocol. 14 | 15 | ```python 16 | from datetime import datetime 17 | 18 | from uapi.shorthands import ResponseShorthand 19 | 20 | class DatetimeShorthand(ResponseShorthand[datetime]): 21 | pass 22 | ``` 23 | 24 | Note that the shorthand is generic over the type we want to enable. 25 | This protocol contains four static methods (functions); two mandatory ones and two optional ones. 26 | 27 | The first function we need to override is {meth}`ResponseShorthand.response_adapter_factory`. 28 | This function needs to produce an adapter which converts an instance of our type (`datetime`) into a _uapi_ [status code class](handlers.md#uapi-status-code-classes), so _uapi_ can adapt the value for the underlying framework. 29 | 30 | {emphasize-lines="6-8"} 31 | ```python 32 | from uapi.shorthands import ResponseAdapter 33 | from uapi.status import BaseResponse, Ok 34 | 35 | class DatetimeShorthand(ResponseShorthand[datetime]): 36 | 37 | @staticmethod 38 | def response_adapter_factory(type: Any) -> ResponseAdapter: 39 | return lambda value: Ok(value.isoformat(), headers={"content-type": "date"}) 40 | ``` 41 | 42 | The second function is {meth}`ResponseShorthand.is_union_member`. 43 | This function is used to recognize if a return value is an instance of the shorthand type when the return type is a union. 44 | For example, if the return type is `datetime | str`, uapi needs to be able to detect and handle both cases. 45 | 46 | {emphasize-lines="3-5"} 47 | ```python 48 | class DatetimeShorthand(ResponseShorthand[datetime]): 49 | 50 | @staticmethod 51 | def is_union_member(value: Any) -> bool: 52 | return isinstance(value, datetime) 53 | ``` 54 | 55 | With these two functions we have a minimal shorthand implementation. 56 | We can add it to an app to be able to use it: 57 | 58 | {emphasize-lines="5"} 59 | ``` 60 | from uapi.starlette import App # Or any other app 61 | 62 | app = App() 63 | 64 | app = app.add_response_shorthand(DatetimeShorthand) 65 | ``` 66 | 67 | And we're done. 68 | 69 | ### OpenAPI Integration 70 | 71 | If we stop here our shorthand won't show up in the [generated OpenAPI schema](openapi.md). 72 | To enable OpenAPI integration we need to implement one more function, {meth}`ResponseShorthand.make_openapi_response`. 73 | 74 | This function returns the [OpenAPI response definition](https://swagger.io/specification/#responses-object) for the shorthand. 75 | 76 | {emphasize-lines="5-10"} 77 | ```python 78 | from uapi.openapi import MediaType, Response, Schema 79 | 80 | class DatetimeShorthand(ResponseShorthand[datetime]): 81 | 82 | @staticmethod 83 | def make_openapi_response() -> Response: 84 | return Response( 85 | "OK", 86 | {"date": MediaType(Schema(Schema.Type.STRING, format="datetime"))}, 87 | ) 88 | ``` 89 | 90 | ### Custom Type Matching 91 | 92 | Registered shorthands are matched to handler return types using simple identity and [`issubclass`](https://docs.python.org/3/library/functions.html#issubclass) checks. 93 | Sometimes, more sophisticated matching is required. 94 | 95 | For example, the default {class}`NoneShorthand ` shorthand wouldn't work for some handlers without custom matching since it needs to match both `None` and `NoneType`. This matching can be customized by overriding the {meth}`ResponseShorthand.can_handle` function. 96 | 97 | Here's what a dummy implementation would look like for our `DatetimeShorthand`. 98 | 99 | {emphasize-lines="3-5"} 100 | ```python 101 | class DatetimeShorthand(ResponseShorthand[datetime]): 102 | 103 | @staticmethod 104 | def can_handle(type: Any) -> bool: 105 | return issubclass(type, datetime) 106 | ``` -------------------------------------------------------------------------------- /docs/uapi.login.rst: -------------------------------------------------------------------------------- 1 | uapi.login package 2 | ================== 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: uapi.login 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/uapi.openapi_ui.rst: -------------------------------------------------------------------------------- 1 | uapi.openapi\_ui package 2 | ======================== 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: uapi.openapi_ui 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/uapi.rst: -------------------------------------------------------------------------------- 1 | uapi package 2 | ============ 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | uapi.login 11 | uapi.openapi_ui 12 | uapi.sessions 13 | 14 | Submodules 15 | ---------- 16 | 17 | uapi.aiohttp module 18 | ------------------- 19 | 20 | .. automodule:: uapi.aiohttp 21 | :members: 22 | :undoc-members: 23 | :show-inheritance: 24 | 25 | uapi.base module 26 | ---------------- 27 | 28 | .. automodule:: uapi.base 29 | :members: 30 | :undoc-members: 31 | :show-inheritance: 32 | 33 | uapi.cookies module 34 | ------------------- 35 | 36 | .. automodule:: uapi.cookies 37 | :members: 38 | :undoc-members: 39 | :show-inheritance: 40 | 41 | uapi.django module 42 | ------------------ 43 | 44 | .. automodule:: uapi.django 45 | :members: 46 | :undoc-members: 47 | :show-inheritance: 48 | 49 | uapi.flask module 50 | ----------------- 51 | 52 | .. automodule:: uapi.flask 53 | :members: 54 | :undoc-members: 55 | :show-inheritance: 56 | 57 | uapi.openapi module 58 | ------------------- 59 | 60 | .. automodule:: uapi.openapi 61 | :members: 62 | :undoc-members: 63 | :show-inheritance: 64 | 65 | uapi.path module 66 | ---------------- 67 | 68 | .. automodule:: uapi.path 69 | :members: 70 | :undoc-members: 71 | :show-inheritance: 72 | 73 | uapi.quart module 74 | ----------------- 75 | 76 | .. automodule:: uapi.quart 77 | :members: 78 | :undoc-members: 79 | :show-inheritance: 80 | 81 | uapi.requests module 82 | -------------------- 83 | 84 | .. automodule:: uapi.requests 85 | :members: 86 | :undoc-members: 87 | :show-inheritance: 88 | 89 | uapi.responses module 90 | --------------------- 91 | 92 | .. automodule:: uapi.responses 93 | :members: 94 | :undoc-members: 95 | :show-inheritance: 96 | 97 | uapi.shorthands module 98 | ---------------------- 99 | 100 | .. automodule:: uapi.shorthands 101 | :members: 102 | :undoc-members: 103 | :show-inheritance: 104 | 105 | uapi.starlette module 106 | --------------------- 107 | 108 | .. automodule:: uapi.starlette 109 | :members: 110 | :undoc-members: 111 | :show-inheritance: 112 | 113 | uapi.status module 114 | ------------------ 115 | 116 | .. automodule:: uapi.status 117 | :members: 118 | :undoc-members: 119 | :show-inheritance: 120 | 121 | uapi.types module 122 | ----------------- 123 | 124 | .. automodule:: uapi.types 125 | :members: 126 | :undoc-members: 127 | :show-inheritance: 128 | 129 | Module contents 130 | --------------- 131 | 132 | .. automodule:: uapi 133 | :members: 134 | :undoc-members: 135 | :show-inheritance: 136 | -------------------------------------------------------------------------------- /docs/uapi.sessions.rst: -------------------------------------------------------------------------------- 1 | uapi.sessions package 2 | ===================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | uapi.sessions.redis module 8 | -------------------------- 9 | 10 | .. automodule:: uapi.sessions.redis 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | Module contents 16 | --------------- 17 | 18 | .. automodule:: uapi.sessions 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "uapi" 7 | description = "A Python HTTP superframework" 8 | authors = [{name = "Tin Tvrtkovic", email = "tinchester@gmail.com"}] 9 | classifiers = [ 10 | "License :: OSI Approved :: Apache Software License", 11 | "Intended Audience :: Developers", 12 | "Programming Language :: Python :: 3.10", 13 | "Programming Language :: Python :: 3.11", 14 | "Programming Language :: Python :: 3.12", 15 | "Programming Language :: Python :: 3.13", 16 | "Programming Language :: Python :: Implementation :: CPython", 17 | "Typing :: Typed", 18 | ] 19 | dependencies = [ 20 | "cattrs >= 23.2.2", 21 | "incant >= 23.2.0", 22 | "itsdangerous", 23 | "attrs >= 23.1.0", 24 | "orjson>=3.10.7", 25 | ] 26 | requires-python = ">=3.10" 27 | readme = "README.md" 28 | license = {file = "LICENSE"} 29 | dynamic = ["version"] 30 | 31 | [tool.pdm.dev-dependencies] 32 | lint = [ 33 | "black", 34 | "ruff", 35 | "mypy>=1.4.1", 36 | ] 37 | test = [ 38 | "coverage>=7.6.1", 39 | "pytest-asyncio", 40 | "httpx", 41 | "hypercorn", 42 | "aioredis==1.3.1", 43 | "uvicorn", 44 | "uapi[lint, frameworks]", 45 | "python-multipart>=0.0.6", 46 | "pytest-mypy-plugins>=3.0.0", 47 | "pytest-xdist>=3.5.0", 48 | ] 49 | frameworks = [ 50 | "aiohttp>=3.10.5", 51 | "flask", 52 | "quart", 53 | "starlette", 54 | "django", 55 | ] 56 | docs = [ 57 | "sphinx", 58 | "furo", 59 | "myst_parser", 60 | "sphinx_inline_tabs", 61 | "sphinx-autobuild>=2021.3.14", 62 | "uapi[frameworks]", 63 | ] 64 | 65 | [tool.isort] 66 | profile = "black" 67 | 68 | [tool.pytest.ini_options] 69 | asyncio_mode = "auto" 70 | 71 | [tool.black] 72 | skip_magic_trailing_comma = true 73 | 74 | [tool.mypy] 75 | warn_unused_ignores = true 76 | 77 | [[tool.mypy.overrides]] 78 | module = "django.*" 79 | ignore_missing_imports = true 80 | 81 | [[tool.mypy.overrides]] 82 | module = "aioredis.*" 83 | ignore_missing_imports = true 84 | 85 | [tool.coverage.run] 86 | parallel = true 87 | source_pkgs = ["uapi"] 88 | 89 | [tool.ruff] 90 | src = ["src", "tests"] 91 | select = [ 92 | "E", # pycodestyle 93 | "W", # pycodestyle 94 | "F", # Pyflakes 95 | "UP", # pyupgrade 96 | "N", # pep8-naming 97 | "YTT", # flake8-2020 98 | "S", # flake8-bandit 99 | "B", # flake8-bugbear 100 | "C4", # flake8-comprehensions 101 | "T10", # flake8-debugger 102 | "ISC", # flake8-implicit-str-concat 103 | "RET", # flake8-return 104 | "SIM", # flake8-simplify 105 | "DTZ", # flake8-datetimez 106 | "T20", # flake8-print 107 | "PGH", # pygrep-hooks 108 | "PLC", # Pylint 109 | "PIE", # flake8-pie 110 | "RUF", # ruff 111 | "I", # isort 112 | ] 113 | ignore = [ 114 | "E501", # line length is handled by black 115 | "E731", # assigning lambdas 116 | "S101", # assert 117 | "PGH003", # leave my type: ignores alone 118 | "B006", # trust me 119 | "B008", # can't get it to work with extend-immutable-calls 120 | "N818", # Exceptions 121 | "RUF006", # Buggy for now 122 | "RUF013", # False implicit optionals 123 | ] 124 | 125 | [tool.hatch.version] 126 | source = "vcs" 127 | raw-options = { local_scheme = "no-local-version" } 128 | -------------------------------------------------------------------------------- /src/uapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .cookies import Cookie 2 | from .requests import FormBody, Header, HeaderSpec, ReqBody, ReqBytes 3 | from .responses import ResponseException 4 | from .status import Found, Headers, SeeOther 5 | from .types import Method, RouteName 6 | 7 | __all__ = [ 8 | "Cookie", 9 | "FormBody", 10 | "Header", 11 | "HeaderSpec", 12 | "Method", 13 | "redirect_to_get", 14 | "redirect", 15 | "ReqBody", 16 | "ReqBytes", 17 | "ResponseException", 18 | "RouteName", 19 | ] 20 | 21 | 22 | def redirect(location: str, headers: Headers = {}) -> Found[None]: 23 | return Found(None, headers | {"Location": location}) 24 | 25 | 26 | def redirect_to_get(location: str, headers: Headers = {}) -> SeeOther[None]: 27 | return SeeOther(None, headers | {"Location": location}) 28 | -------------------------------------------------------------------------------- /src/uapi/attrschema.py: -------------------------------------------------------------------------------- 1 | """JSON schema for attrs.""" 2 | from types import NoneType 3 | from typing import Any 4 | 5 | from attrs import NOTHING, fields, has 6 | from cattrs._compat import is_generic, is_literal, is_union_type 7 | 8 | from .openapi import ( 9 | AnySchema, 10 | ArraySchema, 11 | OneOfSchema, 12 | Reference, 13 | Schema, 14 | SchemaBuilder, 15 | ) 16 | 17 | 18 | def _make_generic_mapping(type: type) -> dict: 19 | """A mapping of TypeVars to their actual bound types.""" 20 | res = {} 21 | 22 | for arg, param in zip(type.__args__, type.__origin__.__parameters__, strict=True): # type: ignore 23 | res[param] = arg 24 | 25 | return res 26 | 27 | 28 | def build_attrs_schema(type: Any, builder: SchemaBuilder) -> Schema: 29 | properties = {} 30 | mapping = _make_generic_mapping(type) if is_generic(type) else {} 31 | required = [] 32 | for a in fields(type): 33 | if a.type is None: 34 | continue 35 | 36 | a_type = a.type 37 | 38 | if a_type in mapping: 39 | a_type = mapping[a_type] 40 | 41 | if a_type in builder.PYTHON_PRIMITIVES_TO_OPENAPI: 42 | schema: AnySchema | Reference = builder.PYTHON_PRIMITIVES_TO_OPENAPI[a_type] 43 | elif has(a_type): 44 | schema = builder.get_schema_for_type(a_type) 45 | elif getattr(a_type, "__origin__", None) is list: 46 | arg = a_type.__args__[0] 47 | if arg in mapping: 48 | arg = mapping[arg] 49 | schema = builder.get_schema_for_type(list[arg]) # type: ignore[valid-type] 50 | elif getattr(a_type, "__origin__", None) is dict: 51 | val_arg = a_type.__args__[1] 52 | 53 | add_prop = builder.get_schema_for_type(val_arg) 54 | if isinstance(add_prop, ArraySchema): 55 | raise Exception("Arrays in additional properties not supported.") 56 | 57 | schema = Schema(Schema.Type.OBJECT, additionalProperties=add_prop) 58 | elif is_literal(a_type): 59 | schema = Schema(Schema.Type.STRING, enum=list(a_type.__args__)) 60 | elif is_union_type(a_type): 61 | refs: list[Reference | AnySchema] = [] 62 | for arg in a_type.__args__: 63 | if has(arg): 64 | refs.append(builder.get_schema_for_type(arg)) 65 | elif arg is NoneType: 66 | refs.append(Schema(Schema.Type.NULL)) 67 | elif arg in builder.PYTHON_PRIMITIVES_TO_OPENAPI: 68 | refs.append(builder.PYTHON_PRIMITIVES_TO_OPENAPI[arg]) 69 | schema = OneOfSchema(refs) 70 | else: 71 | continue 72 | properties[a.name] = schema 73 | if a.default is NOTHING: 74 | required.append(a.name) 75 | 76 | return Schema(type=Schema.Type.OBJECT, properties=properties, required=required) 77 | -------------------------------------------------------------------------------- /src/uapi/cookies.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, TypeVar 2 | 3 | from attrs import frozen 4 | 5 | from .status import Headers 6 | 7 | T1 = TypeVar("T1") 8 | T2 = TypeVar("T2") 9 | 10 | SameSite = Literal["strict", "lax", "none"] 11 | 12 | 13 | @frozen 14 | class CookieSettings: 15 | max_age: int | None = None # Seconds 16 | http_only: bool = True 17 | secure: bool = True 18 | path: str | None = None 19 | domain: str | None = None 20 | same_site: SameSite = "lax" 21 | 22 | 23 | def _make_cookie_header(name: str, value: str, settings: CookieSettings) -> Headers: 24 | val = f"{name}={value}" 25 | if settings.max_age is not None: 26 | val = f"{val}; Max-Age={settings.max_age}" 27 | if settings.http_only: 28 | val = f"{val}; HttpOnly" 29 | if settings.secure: 30 | val = f"{val}; Secure" 31 | if settings.path is not None: 32 | val = f"{val}; Path={settings.path}" 33 | if settings.domain is not None: 34 | val = f"{val}; Domain={settings.domain}" 35 | if settings.same_site != "lax": 36 | val = f"{val}; SameSite={settings.same_site}" 37 | return {f"__cookie_{name}": val} 38 | 39 | 40 | def _make_delete_cookie_header(name: str) -> dict: 41 | val = f"{name}=0; expires=Thu, 01 Jan 1970 00:00:00 GMT;" 42 | return {f"__cookie_{name}": val} 43 | 44 | 45 | #: A cookie dependency. 46 | class Cookie(str): 47 | ... 48 | 49 | 50 | def set_cookie( 51 | name: str, value: str | None, settings: CookieSettings = CookieSettings() 52 | ) -> Headers: 53 | """ 54 | Produce headers that should be returned as part of a response to set the cookie. 55 | 56 | :param value: When `None`, the cookie will be deleted. 57 | """ 58 | return ( 59 | _make_cookie_header(name, value, settings) 60 | if value is not None 61 | else _make_delete_cookie_header(name) 62 | ) 63 | -------------------------------------------------------------------------------- /src/uapi/flask.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from functools import partial 3 | from inspect import Signature, signature 4 | from typing import Any, ClassVar, Generic, TypeAlias, TypeVar 5 | 6 | from attrs import Factory, define 7 | from cattrs import Converter 8 | from incant import Hook, Incanter 9 | 10 | from flask import Flask, request 11 | from flask import Response as FrameworkResponse 12 | 13 | from . import ResponseException 14 | from .base import App as BaseApp 15 | from .path import ( 16 | angle_to_curly, 17 | parse_angle_path_params, 18 | parse_curly_path_params, 19 | strip_path_param_prefix, 20 | ) 21 | from .requests import ( 22 | HeaderSpec, 23 | ReqBytes, 24 | attrs_body_factory, 25 | get_cookie_name, 26 | get_form_type, 27 | get_header_type, 28 | get_req_body_attrs, 29 | is_form, 30 | is_header, 31 | is_req_body_attrs, 32 | ) 33 | from .responses import dict_to_headers, make_exception_adapter, make_response_adapter 34 | from .status import BadRequest, BaseResponse, get_status_code 35 | from .types import Method, RouteName 36 | 37 | __all__ = ["App", "FlaskApp"] 38 | 39 | C = TypeVar("C") 40 | C_contra = TypeVar("C_contra", contravariant=True) 41 | 42 | 43 | @define 44 | class FlaskApp(Generic[C_contra], BaseApp[C_contra | FrameworkResponse]): 45 | framework_incant: Incanter = Factory( 46 | lambda self: _make_flask_incanter(self.converter), takes_self=True 47 | ) 48 | _framework_resp_cls: ClassVar[type] = FrameworkResponse 49 | 50 | def to_framework_app(self, import_name: str) -> Flask: 51 | f = Flask(import_name) 52 | exc_adapter = make_exception_adapter(self.converter) 53 | 54 | for (method, path), (handler, name, _) in self._route_map.items(): 55 | ra = make_response_adapter( 56 | signature(handler, eval_str=True).return_annotation, 57 | FrameworkResponse, 58 | self.converter, 59 | self._shorthands, 60 | ) 61 | path_params = parse_angle_path_params(path) 62 | hooks = [Hook.for_name(p, None) for p in path_params] 63 | 64 | base_handler = self.incant.compose(handler, is_async=False) 65 | # Detect required content-types here, based on the registered 66 | # request loaders. 67 | base_sig = signature(base_handler) 68 | req_ct: str | None = None 69 | for arg in base_sig.parameters.values(): 70 | if is_req_body_attrs(arg): 71 | _, loader = get_req_body_attrs(arg) 72 | req_ct = loader.content_type 73 | 74 | prepared = self.framework_incant.compose( 75 | base_handler, hooks, is_async=False 76 | ) 77 | adapted = self.framework_incant.adapt( 78 | prepared, 79 | lambda p: p.annotation is RouteName, 80 | lambda p: p.annotation is Method, 81 | **{pp: (lambda p, _pp=pp: p.name == _pp) for pp in path_params}, 82 | ) 83 | if ra is None: 84 | 85 | def o0( 86 | _handler=adapted, 87 | _req_ct=req_ct, 88 | _fra=_framework_return_adapter, 89 | _ea=exc_adapter, 90 | _rn=name, 91 | _rm=method, 92 | ): 93 | def adapter(**kwargs): 94 | if ( 95 | _req_ct is not None 96 | and request.headers.get("content-type") != _req_ct 97 | ): 98 | return FrameworkResponse( 99 | f"invalid content type (expected {_req_ct})", 415 100 | ) 101 | try: 102 | return _handler(_rn, _rm, **kwargs) 103 | except ResponseException as exc: 104 | return _fra(_ea(exc)) 105 | 106 | return adapter 107 | 108 | adapted = o0() 109 | 110 | else: 111 | 112 | def o1( 113 | _handler=adapted, 114 | _ra=ra, 115 | _fra=_framework_return_adapter, 116 | _req_ct=req_ct, 117 | _ea=exc_adapter, 118 | _rn=name, 119 | _rm=method, 120 | ): 121 | def adapter(**kwargs): 122 | if ( 123 | _req_ct is not None 124 | and request.headers.get("content-type") != _req_ct 125 | ): 126 | return FrameworkResponse( 127 | f"invalid content type (expected {_req_ct})", 415 128 | ) 129 | try: 130 | return _fra(_ra(_handler(_rn, _rm, **kwargs))) 131 | except ResponseException as exc: 132 | return _fra(_ea(exc)) 133 | 134 | return adapter 135 | 136 | adapted = o1() 137 | 138 | f.route( 139 | path, 140 | methods=[method], 141 | endpoint=name if name is not None else handler.__name__, 142 | )(adapted) 143 | 144 | return f 145 | 146 | def run(self, import_name: str, host: str | None = None, port: int = 8000): 147 | """Start serving the app using the Flask development server.""" 148 | self.to_framework_app(import_name).run(host=host, port=port) 149 | 150 | @staticmethod 151 | def _path_param_parser(p: str) -> tuple[str, list[str]]: 152 | return (strip_path_param_prefix(angle_to_curly(p)), parse_curly_path_params(p)) 153 | 154 | 155 | App: TypeAlias = FlaskApp[FrameworkResponse] 156 | 157 | 158 | def _make_flask_incanter(converter: Converter) -> Incanter: 159 | """Create the framework incanter for Flask.""" 160 | res = Incanter() 161 | 162 | res.register_hook_factory( 163 | lambda _: True, 164 | lambda p: lambda: converter.structure( 165 | request.args[p.name] 166 | if p.default is Signature.empty 167 | else request.args.get(p.name, p.default), 168 | p.annotation, 169 | ), 170 | ) 171 | res.register_hook_factory( 172 | lambda p: p.annotation in (Signature.empty, str), 173 | lambda p: lambda: request.args[p.name] 174 | if p.default is Signature.empty 175 | else request.args.get(p.name, p.default), 176 | ) 177 | res.register_hook_factory( 178 | is_header, 179 | lambda p: _make_header_dependency( 180 | *get_header_type(p), p.name, converter, p.default 181 | ), 182 | ) 183 | res.register_hook_factory( 184 | lambda p: get_cookie_name(p.annotation, p.name) is not None, 185 | lambda p: _make_cookie_dependency(get_cookie_name(p.annotation, p.name), default=p.default), # type: ignore 186 | ) 187 | 188 | def request_bytes() -> bytes: 189 | return request.data 190 | 191 | res.register_hook(lambda p: p.annotation is ReqBytes, request_bytes) 192 | 193 | res.register_hook_factory( 194 | is_req_body_attrs, partial(attrs_body_factory, converter=converter) 195 | ) 196 | 197 | res.register_hook_factory( 198 | is_form, lambda p: _make_form_dependency(get_form_type(p), converter) 199 | ) 200 | 201 | # RouteNames and methods get an empty hook, so the parameter propagates to the base incanter. 202 | res.hook_factory_registry.insert( 203 | 0, Hook(lambda p: p.annotation in (RouteName, Method), None) 204 | ) 205 | 206 | return res 207 | 208 | 209 | def _make_header_dependency( 210 | type: type, 211 | headerspec: HeaderSpec, 212 | name: str, 213 | converter: Converter, 214 | default: Any = Signature.empty, 215 | ): 216 | if isinstance(headerspec.name, str): 217 | name = headerspec.name 218 | else: 219 | name = headerspec.name(name) 220 | if type is str: 221 | if default is Signature.empty: 222 | 223 | def read_header() -> str: 224 | return request.headers[name] 225 | 226 | return read_header 227 | 228 | def read_opt_header() -> Any: 229 | return request.headers.get(name, default) 230 | 231 | return read_opt_header 232 | 233 | handler = converter._structure_func.dispatch(type) 234 | 235 | if default is Signature.empty: 236 | 237 | def read_conv_header() -> str: 238 | return handler(request.headers[name], type) 239 | 240 | return read_conv_header 241 | 242 | def read_opt_conv_header() -> Any: 243 | return handler(request.headers.get(name, default), type) 244 | 245 | return read_opt_conv_header 246 | 247 | 248 | def _make_cookie_dependency(cookie_name: str, default=Signature.empty): 249 | if default is Signature.empty: 250 | 251 | def read_cookie() -> str: 252 | return request.cookies[cookie_name] 253 | 254 | return read_cookie 255 | 256 | def read_cookie_opt() -> Any: 257 | return request.cookies.get(cookie_name, default) 258 | 259 | return read_cookie_opt 260 | 261 | 262 | def _make_form_dependency(type: type[C], converter: Converter) -> Callable[[], C]: 263 | handler = converter._structure_func.dispatch(type) 264 | 265 | def read_form() -> C: 266 | try: 267 | return handler(request.form, type) 268 | except Exception as exc: 269 | raise ResponseException(BadRequest("invalid payload")) from exc 270 | 271 | return read_form 272 | 273 | 274 | def _framework_return_adapter(resp: BaseResponse) -> FrameworkResponse: 275 | return FrameworkResponse( 276 | resp.ret or b"", get_status_code(resp.__class__), dict_to_headers(resp.headers) # type: ignore 277 | ) 278 | -------------------------------------------------------------------------------- /src/uapi/login/__init__.py: -------------------------------------------------------------------------------- 1 | from inspect import Signature 2 | from typing import Generic, TypeVar 3 | 4 | from attrs import frozen 5 | 6 | from .. import ResponseException 7 | from ..base import AsyncApp 8 | from ..sessions.redis import AsyncRedisSessionStore, AsyncSession 9 | from ..status import BaseResponse, Forbidden, Headers 10 | 11 | T = TypeVar("T") 12 | T1 = TypeVar("T1") 13 | T2 = TypeVar("T2") 14 | 15 | 16 | @frozen 17 | class AsyncLoginManager(Generic[T]): 18 | #: The session store used for the sessions. 19 | async_session_store: AsyncRedisSessionStore 20 | 21 | async def logout(self, user_id: T) -> None: 22 | """Invalidate all sessions of `user_id`.""" 23 | await self.async_session_store.remove_namespace(str(user_id)) 24 | 25 | 26 | @frozen 27 | class AsyncLoginSession(Generic[T]): 28 | user_id: T | None 29 | _session: AsyncSession 30 | 31 | async def login_and_return(self, user_id: T) -> Headers: 32 | """Set the current session as logged with the given user ID. 33 | 34 | The produced headers need to be returned to the user to set the appropriate 35 | cookies. 36 | """ 37 | self._session["user_id"] = str(user_id) 38 | return await self._session.update_session(namespace=str(user_id)) 39 | 40 | async def logout_and_return(self) -> Headers: 41 | return await self._session.clear_session() 42 | 43 | 44 | def configure_async_login( 45 | app: AsyncApp, 46 | user_id_cls: type[T], 47 | redis_session_store: AsyncRedisSessionStore, 48 | forbidden_response: BaseResponse = Forbidden(None), 49 | ) -> AsyncLoginManager[T]: 50 | """Configure the app for handling login sessions. 51 | 52 | :param user_id_cls: The class of the user ID. Handlers will need to annotate the 53 | `current_user_id` parameter with this class or `user_id_cls | None`. 54 | """ 55 | 56 | def user_id_factory(session: AsyncSession) -> T: 57 | if "user_id" in session: 58 | return user_id_cls(session["user_id"]) # type: ignore 59 | raise ResponseException(forbidden_response) 60 | 61 | def optional_user_id_factory(session: AsyncSession) -> T | None: 62 | if "user_id" in session: 63 | return user_id_cls(session["user_id"]) # type: ignore 64 | return None 65 | 66 | def async_login_session_factory( 67 | current_user_id: user_id_cls | None, session: AsyncSession # type: ignore 68 | ) -> AsyncLoginSession[T]: 69 | return AsyncLoginSession(current_user_id, session) 70 | 71 | app.incant.register_hook( 72 | lambda p: p.name == "current_user_id" 73 | and p.annotation == user_id_cls 74 | and p.default is Signature.empty, 75 | user_id_factory, 76 | ) 77 | app.incant.register_hook( 78 | lambda p: p.name == "current_user_id" and p.annotation == user_id_cls | None, 79 | optional_user_id_factory, 80 | ) 81 | app.incant.register_hook( 82 | lambda p: p.name == "login_session" 83 | and p.annotation == AsyncLoginSession[user_id_cls], # type: ignore 84 | async_login_session_factory, 85 | ) 86 | return AsyncLoginManager(redis_session_store) 87 | -------------------------------------------------------------------------------- /src/uapi/openapi_ui/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.resources import files 2 | 3 | swaggerui = files(__package__).joinpath("swaggerui.html").read_text() 4 | redoc = files(__package__).joinpath("redoc.html").read_text() 5 | elements = files(__package__).joinpath("elements.html").read_text() 6 | -------------------------------------------------------------------------------- /src/uapi/openapi_ui/elements.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Elements in HTML 10 | 11 | 12 | 16 | 17 | 18 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/uapi/openapi_ui/redoc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Redoc 6 | 7 | 8 | 9 | 10 | 11 | 14 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/uapi/openapi_ui/swaggerui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/uapi/path.py: -------------------------------------------------------------------------------- 1 | """For path parameters.""" 2 | from re import compile, sub 3 | 4 | _angle_path_pattern = compile(r"<([a-zA-Z_:]+)>") 5 | _curly_path_pattern = compile(r"{([a-zA-Z_]+)}") 6 | _curly_path_with_conv_pattern = compile(r"{([a-zA-Z_]+:[a-zA-Z_]+)}") 7 | 8 | 9 | def parse_angle_path_params(path_str: str) -> list[str]: 10 | return [p.split(":")[-1] for p in _angle_path_pattern.findall(path_str)] 11 | 12 | 13 | def parse_curly_path_params(path_str: str) -> list[str]: 14 | return [p.split(":")[0] for p in _curly_path_pattern.findall(path_str)] 15 | 16 | 17 | def strip_path_param_prefix(path: str) -> str: 18 | return sub( 19 | _curly_path_with_conv_pattern, lambda m: f"{{{m.group(1).split(':')[1]}}}", path 20 | ) 21 | 22 | 23 | def angle_to_curly(path: str) -> str: 24 | return path.replace("<", "{").replace(">", "}") 25 | -------------------------------------------------------------------------------- /src/uapi/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/src/uapi/py.typed -------------------------------------------------------------------------------- /src/uapi/requests.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from inspect import Parameter 3 | from typing import Annotated, Any, NewType, TypeAlias, TypeVar 4 | 5 | from attrs import frozen, has 6 | from cattrs import Converter 7 | from cattrs._compat import get_args, is_annotated 8 | from orjson import loads 9 | 10 | from . import Cookie 11 | from .status import BadRequest, BaseResponse, ResponseException 12 | 13 | T = TypeVar("T") 14 | RequestLoaderPredicate: TypeAlias = Callable[[Parameter], bool] 15 | 16 | 17 | @frozen 18 | class JsonBodyLoader: 19 | """Metadata for customized loading and structuring of JSON bodies.""" 20 | 21 | content_type: str | None = "application/json" 22 | error_handler: Callable[ 23 | [Exception, bytes], BaseResponse 24 | ] = lambda _, __: BadRequest("invalid payload") 25 | 26 | 27 | @frozen 28 | class HeaderSpec: 29 | """Metadata for loading headers.""" 30 | 31 | name: str | Callable[[str], str] = lambda n: n.replace("_", "-") 32 | 33 | 34 | @frozen 35 | class FormSpec: 36 | """Metadata for loading forms.""" 37 | 38 | 39 | ReqBody = Annotated[T, JsonBodyLoader()] 40 | ReqBytes = NewType("ReqBytes", bytes) 41 | 42 | #: A form in the request body. 43 | FormBody: TypeAlias = Annotated[T, FormSpec()] 44 | 45 | #: A header dependency. 46 | Header: TypeAlias = Annotated[T, HeaderSpec()] 47 | 48 | 49 | def get_cookie_name(t, arg_name: str) -> str | None: 50 | if t is Cookie or t is Cookie | None: 51 | return arg_name 52 | 53 | if is_annotated(t): 54 | for arg in get_args(t)[1:]: 55 | if arg.__class__ is Cookie: 56 | return arg or arg_name 57 | return None 58 | 59 | 60 | def maybe_header_type(p: Parameter) -> tuple[type, HeaderSpec] | None: 61 | """Get the Annotated HeaderSpec, if present.""" 62 | t = p.annotation 63 | if is_annotated(t): 64 | args = get_args(t) 65 | if args: 66 | for arg in args[1:]: 67 | if isinstance(arg, HeaderSpec): 68 | return args[0], arg 69 | return None 70 | 71 | 72 | def get_header_type(p: Parameter) -> tuple[type, HeaderSpec]: 73 | """Similar to `maybe_req_body_attrs`, except raises.""" 74 | res = maybe_header_type(p) 75 | if res is None: 76 | # Shouldn't happen. 77 | raise Exception("No header info found") 78 | return res 79 | 80 | 81 | def is_header(p: Parameter) -> bool: 82 | return maybe_header_type(p) is not None 83 | 84 | 85 | def maybe_form_type(p: Parameter) -> type | None: 86 | """Get the underlying form type, is present.""" 87 | t = p.annotation 88 | if is_annotated(t): 89 | args = get_args(t) 90 | if args: 91 | for arg in args[1:]: 92 | if isinstance(arg, FormSpec): 93 | return args[0] 94 | return None 95 | 96 | 97 | def get_form_type(p: Parameter) -> type: 98 | if (r := maybe_form_type(p)) is None: 99 | raise Exception("No form info found") 100 | return r 101 | 102 | 103 | def is_form(p: Parameter) -> bool: 104 | """Is this parameter a form?""" 105 | return maybe_form_type(p) is not None 106 | 107 | 108 | def attrs_body_factory( 109 | parameter: Parameter, converter: Converter 110 | ) -> Callable[[ReqBytes], Any]: 111 | attrs_cls, loader = get_req_body_attrs(parameter) 112 | 113 | def structure_body(body: ReqBytes) -> Any: 114 | try: 115 | return converter.structure(loads(body), attrs_cls) 116 | except Exception as exc: 117 | raise ResponseException(loader.error_handler(exc, body)) from exc 118 | 119 | return structure_body 120 | 121 | 122 | def maybe_req_body_type(p: Parameter) -> tuple[type, JsonBodyLoader] | None: 123 | """Is this parameter a valid request body?""" 124 | t = p.annotation 125 | if is_annotated(t): 126 | args = get_args(t) 127 | if args and (has(args[0]) or getattr(args[0], "__origin__", None) is dict): 128 | for arg in args[1:]: 129 | if isinstance(arg, JsonBodyLoader): 130 | return args[0], arg 131 | return None 132 | 133 | 134 | def get_req_body_attrs(p: Parameter) -> tuple[type, JsonBodyLoader]: 135 | """Similar to `maybe_req_body_attrs`, except raises.""" 136 | res = maybe_req_body_type(p) 137 | if res is None: 138 | raise Exception("No attrs request body found") 139 | return res 140 | 141 | 142 | def is_req_body_attrs(p: Parameter) -> bool: 143 | return maybe_req_body_type(p) is not None 144 | -------------------------------------------------------------------------------- /src/uapi/responses.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Iterable, Mapping 2 | from inspect import Signature 3 | from types import MappingProxyType 4 | from typing import Any, TypeVar, get_args 5 | 6 | from attrs import has 7 | from cattrs import Converter 8 | from cattrs._compat import is_union_type 9 | from incant import is_subclass 10 | from orjson import dumps 11 | 12 | from .shorthands import ResponseShorthand, can_shorthand_handle 13 | from .status import BaseResponse, Headers, ResponseException 14 | 15 | empty_dict: Mapping[str, str] = MappingProxyType({}) 16 | 17 | 18 | def make_response_adapter( 19 | return_type: Any, 20 | framework_response_cls: type, 21 | converter: Converter, 22 | shorthands: Iterable[type[ResponseShorthand]], 23 | ) -> Callable[[Any], BaseResponse] | None: 24 | """Potentially create a function to adapt the return type to 25 | something uapi understands. 26 | """ 27 | if return_type is Signature.empty or is_subclass( 28 | return_type, framework_response_cls 29 | ): 30 | # You're on your own, buddy. 31 | return None 32 | 33 | for shorthand in shorthands: 34 | can_handle = can_shorthand_handle(return_type, shorthand) 35 | if can_handle: 36 | return shorthand.response_adapter_factory(return_type) 37 | 38 | if is_subclass(return_type, BaseResponse): 39 | return identity 40 | 41 | if is_subclass(getattr(return_type, "__origin__", None), BaseResponse) and has( 42 | inner := return_type.__args__[0] 43 | ): 44 | return lambda r: return_type( 45 | dumps(converter.unstructure(r.ret, unstructure_as=inner)), 46 | r.headers | {"content-type": "application/json"}, 47 | ) 48 | 49 | if is_union_type(return_type): 50 | return _make_union_response_adapter( 51 | get_args(return_type), converter, shorthands 52 | ) 53 | return identity 54 | 55 | 56 | def _make_union_response_adapter( 57 | types: tuple[Any], 58 | converter: Converter, 59 | shorthands: Iterable[type[ResponseShorthand]], 60 | ) -> Callable[[Any], BaseResponse] | None: 61 | # First, we check if any shorthands match. 62 | shorthand_checks: list[tuple] = [] 63 | for member in types: 64 | for shorthand in shorthands: 65 | if can_shorthand_handle(member, shorthand): 66 | shorthand_checks.append( 67 | ( 68 | shorthand.is_union_member, 69 | shorthand.response_adapter_factory(member), 70 | ) 71 | ) 72 | break 73 | 74 | if not shorthand_checks: 75 | # No shorthands, it's all BaseResponses. 76 | return lambda val: val.__class__( 77 | ret=dumps(converter.unstructure(val.ret)) if val.ret is not None else None, 78 | headers=val.headers | {"content-type": "application/json"}, 79 | ) 80 | 81 | def response_adapter(val: Any, _shs=shorthand_checks) -> BaseResponse: 82 | for is_union_member, ra in _shs: 83 | if is_union_member(val): 84 | return ra(val) 85 | return val.__class__( 86 | dumps(converter.unstructure(val.ret)) if val.ret is not None else None, 87 | val.headers | {"content-type": "application/json"}, 88 | ) 89 | 90 | return response_adapter 91 | 92 | 93 | def make_exception_adapter( 94 | converter: Converter, 95 | ) -> Callable[[ResponseException], BaseResponse]: 96 | """Produce an adapter of exceptions to BaseResponses. 97 | 98 | Since exception types aren't statically known, this can be 99 | simpler than the return adapter. 100 | """ 101 | 102 | def adapt_exception(exc: ResponseException) -> BaseResponse: 103 | if isinstance(exc.response.ret, str | bytes | None): 104 | return exc.response 105 | return exc.response.__class__( 106 | dumps(converter.unstructure(exc.response.ret)), 107 | {"content-type": "application/json"} | exc.response.headers, 108 | ) 109 | 110 | return adapt_exception 111 | 112 | 113 | T = TypeVar("T") 114 | 115 | 116 | def identity(x: T) -> T: 117 | """The identity function, used and recognized for certain optimizations.""" 118 | return x 119 | 120 | 121 | def dict_to_headers(d: Headers) -> list[tuple[str, str]]: 122 | return [(k, v) if k[:9] != "__cookie_" else ("set-cookie", v) for k, v in d.items()] 123 | -------------------------------------------------------------------------------- /src/uapi/sessions/__init__.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Annotated, TypeVar 3 | 4 | from itsdangerous import BadSignature, URLSafeTimedSerializer 5 | 6 | from .. import Cookie 7 | from ..base import App, AsyncApp 8 | from ..cookies import CookieSettings, set_cookie 9 | from ..status import Headers 10 | 11 | T1 = TypeVar("T1") 12 | T2 = TypeVar("T2") 13 | 14 | 15 | class Session(dict[str, str]): 16 | _serialize: Callable 17 | 18 | def update_session(self) -> Headers: 19 | name, val, *settings = self._serialize(self) 20 | return set_cookie(name, val, settings=CookieSettings(*settings)) 21 | 22 | 23 | def configure_secure_sessions( 24 | app: App | AsyncApp, 25 | secret_key: str, 26 | cookie_name: str = "session", 27 | salt: str = "cookie-session", 28 | settings: CookieSettings = CookieSettings(max_age=2678400), 29 | ): 30 | s = URLSafeTimedSerializer(secret_key=secret_key, salt=salt) 31 | 32 | def _serialize(self): 33 | return ( 34 | ( 35 | cookie_name, 36 | s.dumps(self), 37 | settings.max_age, 38 | settings.http_only, 39 | settings.secure, 40 | settings.path, 41 | settings.domain, 42 | settings.same_site, 43 | ) 44 | if self 45 | else (cookie_name, None) 46 | ) 47 | 48 | def get_session( 49 | session: Annotated[str | None, Cookie(cookie_name)] = None 50 | ) -> Session: 51 | if session is None: 52 | res = Session() 53 | res._serialize = _serialize 54 | return res 55 | try: 56 | data = s.loads(session) 57 | except BadSignature: 58 | raise 59 | 60 | res = Session(data) 61 | res._serialize = _serialize 62 | return res 63 | 64 | app.incant.register_hook( 65 | lambda p: p.name == "session" and p.annotation is Session, get_session 66 | ) 67 | -------------------------------------------------------------------------------- /src/uapi/sessions/redis.py: -------------------------------------------------------------------------------- 1 | """Redis backends for sessions.""" 2 | from datetime import timedelta 3 | from json import dumps, loads 4 | from secrets import token_hex 5 | from time import time 6 | from typing import TYPE_CHECKING, Annotated, TypeVar 7 | 8 | from attrs import frozen 9 | 10 | from .. import Cookie, Headers 11 | from ..base import AsyncApp, OpenAPISecuritySpec 12 | from ..cookies import CookieSettings, set_cookie 13 | from ..openapi import ApiKeySecurityScheme 14 | 15 | if TYPE_CHECKING: 16 | from aioredis import Redis 17 | 18 | T1 = TypeVar("T1") 19 | T2 = TypeVar("T2") 20 | 21 | 22 | class AsyncSession(dict[str, str]): 23 | _cookie_name: str 24 | _cookie_settings: CookieSettings 25 | _aioredis: "Redis" 26 | _namespace: str 27 | _id: str 28 | _ttl: int 29 | _key_prefix: str 30 | 31 | async def update_session(self, *, namespace: str | None = None) -> Headers: 32 | namespace = namespace or self._namespace 33 | if namespace is None: 34 | raise Exception("The namespace must be set for new sessions.") 35 | now = time() 36 | ns_key = f"{self._key_prefix}{namespace}:s" 37 | key = f"{ns_key}:{self._id}" 38 | existing_id_ttl = await self._aioredis.ttl(key) 39 | existing_namespace_ttl = await self._aioredis.ttl(ns_key) 40 | 41 | if existing_id_ttl < 0: # Means key not found. 42 | existing_id_ttl = self._ttl 43 | 44 | pipeline = self._aioredis.pipeline() 45 | 46 | pipeline.set(key, dumps(self, separators=(",", ":")), expire=existing_id_ttl) 47 | pipeline.zadd(ns_key, now + existing_id_ttl, self._id) 48 | if existing_id_ttl > existing_namespace_ttl: 49 | pipeline.expire(ns_key, existing_id_ttl) 50 | 51 | await pipeline.execute() 52 | 53 | return set_cookie( 54 | self._cookie_name, f"{namespace}:{self._id}", settings=self._cookie_settings 55 | ) 56 | 57 | async def clear_session(self) -> Headers: 58 | self.clear() 59 | if self._namespace is not None: 60 | pipeline = self._aioredis.pipeline() 61 | pipeline.delete(f"{self._namespace}:s:{self._id}") 62 | pipeline.zrem(f"{self._namespace}:s", self._id) 63 | await pipeline.execute() 64 | return set_cookie(self._cookie_name, None) 65 | 66 | 67 | @frozen 68 | class AsyncRedisSessionStore: 69 | _redis: "Redis" 70 | _key_prefix: str 71 | _cookie_name: str 72 | _cookie_settings: CookieSettings 73 | 74 | async def remove_namespace(self, namespace: str) -> None: 75 | """Remove all sessions in a particular namespace.""" 76 | ns_key = f"{self._key_prefix}{namespace}:s" 77 | session_ids = await self._redis.zrangebyscore(ns_key, time(), float("inf")) 78 | pipeline = self._redis.pipeline() 79 | for session_id in session_ids: 80 | pipeline.delete(f"{ns_key}:{session_id}") 81 | pipeline.delete(ns_key) 82 | await pipeline.execute() 83 | 84 | 85 | def configure_async_sessions( 86 | app: AsyncApp, 87 | aioredis: "Redis", 88 | max_age: timedelta = timedelta(days=14), 89 | cookie_name: str = "session_id", 90 | cookie_settings: CookieSettings = CookieSettings(), 91 | redis_key_prefix: str = "", 92 | session_arg_param_name: str = "session", 93 | ) -> AsyncRedisSessionStore: 94 | """ 95 | Configure an instance of async sessions for an app. 96 | 97 | A session ID will be generated using Python's `secrets.token_hex` and stored in a 98 | cookie. 99 | 100 | Once configured, handlers may declare a parameter of name _session_arg_param_name 101 | (defaults to `session`) and type `AsyncSession`. AsyncSessions are mappings of 102 | strings to strings, and can be used to store data using the 103 | `AsyncSession.update_session()` and `AsyncSession.clear_session()` coroutines. 104 | 105 | If the cookie is missing or the session data has expired, a new empty session will 106 | be transparently created. 107 | 108 | Sessions have optional namespaces. Namespaces are useful for logically grouping 109 | sessions, for example by user ID, so that multiple sessions can be cleared at once. 110 | 111 | Fresh sessions start with no namespace set. To set a namespace, pass it to 112 | `AsyncSession.update_session()`. 113 | 114 | An `AsyncRedisSessionStore` is produced at configuration time and can be used to 115 | clean up namespaces even outside the context of a request. 116 | 117 | :param max_age: The maximum age of a session. When this expires, the session is 118 | cleaned up from Redis. 119 | :param cookie_name: The name of the cookie to use for the session id. 120 | :param cookie_settings: The settings for the cookie. 121 | :param redis_key_prefix: The prefix to use for redis keys. 122 | :param session_arg_param_name: The name of the handler parameter that will be 123 | available for dependency injection. 124 | """ 125 | ttl = int(max_age.total_seconds()) 126 | 127 | async def session_factory( 128 | cookie: Annotated[str | None, Cookie(cookie_name)] = None 129 | ) -> AsyncSession: 130 | if cookie is not None: 131 | namespace, id = cookie.split(":") 132 | pipeline = aioredis.pipeline() 133 | pipeline.get(f"{redis_key_prefix}{namespace}:s:{id}") 134 | pipeline.zremrangebyscore(f"{redis_key_prefix}{namespace}:s", 0, time()) 135 | payload, _ = await pipeline.execute() 136 | if payload is not None: 137 | res = AsyncSession(loads(payload)) 138 | res._namespace = namespace 139 | else: 140 | res = None 141 | else: 142 | res = None 143 | 144 | if res is None: 145 | id = token_hex() 146 | res = AsyncSession() 147 | res._namespace = "" 148 | 149 | res._cookie_name = cookie_name 150 | res._cookie_settings = cookie_settings 151 | res._aioredis = aioredis 152 | res._ttl = ttl 153 | res._id = id 154 | res._key_prefix = redis_key_prefix 155 | return res 156 | 157 | app.incant.register_hook( 158 | lambda p: p.name == session_arg_param_name and p.annotation is AsyncSession, 159 | session_factory, 160 | ) 161 | 162 | app._openapi_security.append( 163 | OpenAPISecuritySpec(ApiKeySecurityScheme(cookie_name, "cookie")) 164 | ) 165 | 166 | return AsyncRedisSessionStore( 167 | aioredis, redis_key_prefix, cookie_name, cookie_settings 168 | ) 169 | -------------------------------------------------------------------------------- /src/uapi/shorthands.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from types import NoneType 3 | from typing import Any, Literal, Protocol, TypeAlias, TypeVar, get_origin 4 | 5 | from attrs import AttrsInstance, has 6 | from cattrs import Converter 7 | from incant import is_subclass 8 | from orjson import dumps 9 | 10 | from .openapi import MediaType, Response, SchemaBuilder 11 | from .status import BaseResponse, NoContent, Ok 12 | 13 | __all__ = [ 14 | "ResponseShorthand", 15 | "ResponseAdapter", 16 | "NoneShorthand", 17 | "StrShorthand", 18 | "BytesShorthand", 19 | ] 20 | 21 | T_co = TypeVar("T_co", covariant=True) 22 | ResponseAdapter: TypeAlias = Callable[[Any], BaseResponse] 23 | 24 | 25 | class ResponseShorthand(Protocol[T_co]): 26 | """The base protocol for response shorthands.""" 27 | 28 | @staticmethod 29 | def response_adapter_factory(type: Any) -> ResponseAdapter: # pragma: no cover 30 | """Produce a converter that turns a value of this type into a base response. 31 | 32 | :param type: The actual type being handled by the shorthand. 33 | """ 34 | ... 35 | 36 | @staticmethod 37 | def is_union_member(value: Any) -> bool: # pragma: no cover 38 | """Return whether the actual value of a union is this type. 39 | 40 | Used when handlers return unions of types. 41 | """ 42 | ... 43 | 44 | @staticmethod 45 | def make_openapi_response(type: Any, builder: SchemaBuilder) -> Response | None: 46 | """Produce an OpenAPI response for this shorthand type. 47 | 48 | If this isn't overriden, no OpenAPI schema will be generated. 49 | """ 50 | return None 51 | 52 | @staticmethod 53 | def can_handle(type: Any) -> bool | Literal["check_type"]: 54 | """Whether the shorthand can handle this type. 55 | 56 | Skip overriding to use an `isinstance` check and an equality check 57 | against the generic type parameter of the shorthand. 58 | """ 59 | return "check_type" 60 | 61 | 62 | class NoneShorthand(ResponseShorthand[None]): 63 | """Support for handlers returning `None`. 64 | 65 | The response code is set to 204, and the content type is left unset. 66 | """ 67 | 68 | @staticmethod 69 | def response_adapter_factory(_: Any) -> ResponseAdapter: 70 | def response_adapter(_, _nc=NoContent()): 71 | return _nc 72 | 73 | return response_adapter 74 | 75 | @staticmethod 76 | def is_union_member(value: Any) -> bool: 77 | return value is None 78 | 79 | @staticmethod 80 | def make_openapi_response(_: Any, __: SchemaBuilder) -> Response: 81 | return Response("No content") 82 | 83 | @staticmethod 84 | def can_handle(type: Any) -> bool | Literal["check_type"]: 85 | return type in (None, NoneType) 86 | 87 | 88 | class StrShorthand(ResponseShorthand[str]): 89 | """Support for handlers returning `str`. 90 | 91 | The response code is set to 200 and the content type is set to `text/plain`. 92 | """ 93 | 94 | @staticmethod 95 | def response_adapter_factory(type: Any) -> ResponseAdapter: 96 | return lambda value: Ok(value, headers={"content-type": "text/plain"}) 97 | 98 | @staticmethod 99 | def is_union_member(value: Any) -> bool: 100 | return isinstance(value, str) 101 | 102 | @staticmethod 103 | def make_openapi_response(_: Any, builder: SchemaBuilder) -> Response: 104 | return Response( 105 | "OK", {"text/plain": MediaType(builder.PYTHON_PRIMITIVES_TO_OPENAPI[str])} 106 | ) 107 | 108 | 109 | class BytesShorthand(ResponseShorthand[bytes]): 110 | """Support for handlers returning `bytes`. 111 | 112 | The response code is set to 200 and the content type is set to 113 | `application/octet-stream`. 114 | """ 115 | 116 | @staticmethod 117 | def response_adapter_factory(type: Any) -> ResponseAdapter: 118 | return lambda value: Ok( 119 | value, headers={"content-type": "application/octet-stream"} 120 | ) 121 | 122 | @staticmethod 123 | def is_union_member(value: Any) -> bool: 124 | return isinstance(value, bytes) 125 | 126 | @staticmethod 127 | def make_openapi_response(_: Any, builder: SchemaBuilder) -> Response: 128 | return Response( 129 | "OK", 130 | { 131 | "application/octet-stream": MediaType( 132 | builder.PYTHON_PRIMITIVES_TO_OPENAPI[bytes] 133 | ) 134 | }, 135 | ) 136 | 137 | 138 | def make_attrs_shorthand( 139 | converter: Converter, 140 | ) -> type[ResponseShorthand[AttrsInstance]]: 141 | class AttrsShorthand(ResponseShorthand[AttrsInstance]): 142 | """Support for handlers returning _attrs_ classes.""" 143 | 144 | @staticmethod 145 | def response_adapter_factory(type: Any) -> ResponseAdapter: 146 | hook = converter._unstructure_func.dispatch(type) 147 | headers = {"content-type": "application/json"} 148 | 149 | def response_adapter( 150 | value: AttrsInstance, _h=hook, _hs=headers 151 | ) -> Ok[bytes]: 152 | return Ok(dumps(_h(value)), _hs) 153 | 154 | return response_adapter 155 | 156 | @staticmethod 157 | def is_union_member(value: Any) -> bool: 158 | return has(value.__class__) and not isinstance(value, BaseResponse) 159 | 160 | @staticmethod 161 | def make_openapi_response(type: Any, builder: SchemaBuilder) -> Response | None: 162 | return Response( 163 | "OK", {"application/json": MediaType(builder.get_schema_for_type(type))} 164 | ) 165 | 166 | @staticmethod 167 | def can_handle(type: Any) -> bool | Literal["check_type"]: 168 | return has(type) and not is_subclass(get_origin(type) or type, BaseResponse) 169 | 170 | return AttrsShorthand 171 | 172 | 173 | def get_shorthand_type(shorthand: type[ResponseShorthand]) -> Any: 174 | """Get the underlying shorthand type (ResponseShorthand[T] -> T).""" 175 | return shorthand.__orig_bases__[0].__args__[0] # type: ignore 176 | 177 | 178 | def can_shorthand_handle(type: Any, shorthand: type[ResponseShorthand]) -> bool: 179 | res = shorthand.can_handle(type) 180 | return res is True or ( 181 | res == "check_type" 182 | and ((st := get_shorthand_type(shorthand)) is type or is_subclass(type, st)) 183 | ) 184 | -------------------------------------------------------------------------------- /src/uapi/status.py: -------------------------------------------------------------------------------- 1 | """Status code classes for return values.""" 2 | from functools import cache 3 | from typing import Generic, Literal, TypeAlias, TypeVar 4 | 5 | from attrs import Factory, define, frozen 6 | 7 | __all__ = [ 8 | "Ok", 9 | "Created", 10 | "NoContent", 11 | "Found", 12 | "SeeOther", 13 | "BadRequest", 14 | "Forbidden", 15 | "NotFound", 16 | "InternalServerError", 17 | "BaseResponse", 18 | "R", 19 | ] 20 | 21 | R = TypeVar("R") 22 | S = TypeVar("S") 23 | 24 | 25 | Headers: TypeAlias = dict[str, str] 26 | 27 | 28 | @define(order=False) 29 | class BaseResponse(Generic[S, R]): 30 | ret: R 31 | headers: Headers = Factory(dict) 32 | 33 | @classmethod 34 | def status_code(cls) -> int: 35 | return cls.__orig_bases__[0].__args__[0].__args__[0] # type: ignore 36 | 37 | 38 | @define 39 | class ResponseException(Exception): 40 | """An exception that is converted into an HTTP response.""" 41 | 42 | response: BaseResponse 43 | 44 | 45 | @cache 46 | def get_status_code(resp: type[BaseResponse]) -> int: 47 | return resp.status_code() 48 | 49 | 50 | @define 51 | class Ok(BaseResponse[Literal[200], R]): 52 | pass 53 | 54 | 55 | @define 56 | class Created(BaseResponse[Literal[201], R]): 57 | pass 58 | 59 | 60 | @frozen 61 | class NoContent(BaseResponse[Literal[204], None]): 62 | ret: None = None 63 | 64 | @classmethod 65 | def status_code(cls) -> int: 66 | return 204 67 | 68 | 69 | @define 70 | class Found(BaseResponse[Literal[302], R]): 71 | pass 72 | 73 | 74 | @define 75 | class SeeOther(BaseResponse[Literal[303], R]): 76 | pass 77 | 78 | 79 | @define 80 | class BadRequest(BaseResponse[Literal[400], R]): 81 | pass 82 | 83 | 84 | @define 85 | class Forbidden(BaseResponse[Literal[403], R]): 86 | pass 87 | 88 | 89 | @define 90 | class NotFound(BaseResponse[Literal[404], R]): 91 | pass 92 | 93 | 94 | @define 95 | class InternalServerError(BaseResponse[Literal[500], R]): 96 | pass 97 | -------------------------------------------------------------------------------- /src/uapi/types.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Sequence 2 | from typing import Literal, NewType, TypeAlias, TypeVar 3 | 4 | R = TypeVar("R") 5 | CB = Callable[..., R] 6 | 7 | #: The route name. 8 | RouteName = NewType("RouteName", str) 9 | 10 | RouteTags: TypeAlias = Sequence[str] 11 | 12 | #: The HTTP request method. 13 | Method: TypeAlias = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] 14 | 15 | PathParamParser: TypeAlias = Callable[[str], tuple[str, list[str]]] 16 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/tests/__init__.py -------------------------------------------------------------------------------- /tests/aiohttp.py: -------------------------------------------------------------------------------- 1 | from aiohttp.web import Request, Response 2 | from uapi import Method, ResponseException, RouteName 3 | from uapi.aiohttp import App 4 | from uapi.status import NoContent 5 | 6 | from .apps import configure_base_async 7 | 8 | 9 | class RespSubclass(Response): 10 | pass 11 | 12 | 13 | def make_app() -> App: 14 | app = App() 15 | 16 | configure_base_async(app) 17 | 18 | @app.get("/framework-request") 19 | async def framework_request(req: Request) -> str: 20 | return "framework_request" + req.headers["test"] 21 | 22 | @app.post("/framework-resp-subclass") 23 | async def framework_resp_subclass() -> RespSubclass: 24 | return RespSubclass(body="framework_resp_subclass", status=201) 25 | 26 | async def path_param(path_id: int) -> Response: 27 | return Response(text=str(path_id + 1)) 28 | 29 | app.route("/path/{path_id}", path_param) 30 | 31 | @app.options("/unannotated-exception") 32 | async def unannotated_exception() -> Response: 33 | raise ResponseException(NoContent()) 34 | 35 | @app.get("/query/unannotated", tags=["query"]) 36 | async def query_unannotated(query) -> Response: 37 | return Response(text=query + "suffix") 38 | 39 | @app.get("/query/string", tags=["query"]) 40 | async def query_string(query: str) -> Response: 41 | return Response(text=query + "suffix") 42 | 43 | @app.get("/query", tags=["query"]) 44 | async def query_param(page: int) -> Response: 45 | return Response(text=str(page + 1)) 46 | 47 | @app.get("/query-default", tags=["query"]) 48 | async def query_default(page: int = 0) -> Response: 49 | return Response(text=str(page + 1)) 50 | 51 | @app.post("/post/no-body-native-response") 52 | async def post_no_body() -> Response: 53 | return Response(text="post", status=201) 54 | 55 | @app.post("/path1/{path_id}") 56 | async def post_path_string(path_id: str) -> str: 57 | return str(int(path_id) + 2) 58 | 59 | # Route name composition. 60 | @app.get("/comp/route-name-native") 61 | @app.post("/comp/route-name-native", name="route-name-native-post") 62 | def route_name_native(route_name: RouteName) -> Response: 63 | return Response(text=route_name) 64 | 65 | # Request method composition. 66 | @app.get("/comp/req-method-native") 67 | @app.post("/comp/req-method-native", name="request-method-native-post") 68 | def request_method_native(req_method: Method) -> Response: 69 | return Response(text=req_method) 70 | 71 | return app 72 | 73 | 74 | async def run_on_aiohttp(app: App, port: int): 75 | await app.run(port, handle_signals=False, shutdown_timeout=0.0, access_log=None) 76 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from asyncio import create_task, new_event_loop 2 | from asyncio.exceptions import CancelledError 3 | from collections.abc import AsyncIterator, Callable 4 | from contextlib import suppress 5 | 6 | import pytest 7 | 8 | from .aiohttp import make_app as make_aiohttp_app 9 | from .aiohttp import run_on_aiohttp 10 | from .django import run_on_django 11 | from .django_uapi_app.views import app 12 | from .flask import make_app as make_flask_app 13 | from .flask import run_on_flask 14 | from .quart import make_app as make_quart_app 15 | from .quart import run_on_quart 16 | from .starlette import make_app as make_starlette_app 17 | from .starlette import run_on_starlette 18 | 19 | 20 | @pytest.fixture(scope="session") 21 | def event_loop(): 22 | loop = new_event_loop() 23 | try: 24 | yield loop 25 | finally: 26 | loop.close() 27 | 28 | 29 | @pytest.fixture( 30 | params=["aiohttp", "flask", "quart", "starlette", "django"], scope="session" 31 | ) 32 | async def server(request, unused_tcp_port_factory: Callable[..., int]): 33 | unused_tcp_port = unused_tcp_port_factory() 34 | if request.param == "aiohttp": 35 | t = create_task(run_on_aiohttp(make_aiohttp_app(), unused_tcp_port)) 36 | yield unused_tcp_port 37 | t.cancel() 38 | with suppress(CancelledError): 39 | await t 40 | elif request.param == "flask": 41 | t = create_task(run_on_flask(make_flask_app(), unused_tcp_port)) 42 | yield unused_tcp_port 43 | t.cancel() 44 | with suppress(CancelledError): 45 | await t 46 | elif request.param == "quart": 47 | t = create_task(run_on_quart(make_quart_app(), unused_tcp_port)) 48 | yield unused_tcp_port 49 | t.cancel() 50 | with suppress(CancelledError): 51 | await t 52 | elif request.param == "starlette": 53 | t = create_task(run_on_starlette(make_starlette_app(), unused_tcp_port)) 54 | yield unused_tcp_port 55 | t.cancel() 56 | with suppress(CancelledError): 57 | await t 58 | elif request.param == "django": 59 | t = create_task(run_on_django(app, unused_tcp_port)) 60 | yield unused_tcp_port 61 | t.cancel() 62 | with suppress(CancelledError): 63 | await t 64 | else: 65 | raise Exception("Unknown server framework") 66 | 67 | 68 | @pytest.fixture( 69 | params=["aiohttp", "flask", "quart", "starlette", "django"], scope="session" 70 | ) 71 | async def server_with_openapi( 72 | request, unused_tcp_port_factory: Callable[[], int] 73 | ) -> AsyncIterator[int]: 74 | unused_tcp_port = unused_tcp_port_factory() 75 | if request.param == "aiohttp": 76 | aiohttp_app = make_aiohttp_app() 77 | aiohttp_app.serve_openapi() 78 | t = create_task(run_on_aiohttp(aiohttp_app, unused_tcp_port)) 79 | yield unused_tcp_port 80 | t.cancel() 81 | with suppress(CancelledError): 82 | await t 83 | elif request.param == "flask": 84 | flask_app = make_flask_app() 85 | flask_app.serve_openapi() 86 | t = create_task(run_on_flask(flask_app, unused_tcp_port)) 87 | yield unused_tcp_port 88 | t.cancel() 89 | with suppress(CancelledError): 90 | await t 91 | elif request.param == "quart": 92 | quart_app = make_quart_app() 93 | quart_app.serve_openapi() 94 | t = create_task(run_on_quart(quart_app, unused_tcp_port)) 95 | yield unused_tcp_port 96 | t.cancel() 97 | with suppress(CancelledError): 98 | await t 99 | elif request.param == "starlette": 100 | starlette_app = make_starlette_app() 101 | starlette_app.serve_openapi() 102 | t = create_task(run_on_starlette(starlette_app, unused_tcp_port)) 103 | yield unused_tcp_port 104 | t.cancel() 105 | with suppress(CancelledError): 106 | await t 107 | elif request.param == "django": 108 | t = create_task(run_on_django(app, unused_tcp_port)) 109 | yield unused_tcp_port 110 | t.cancel() 111 | with suppress(CancelledError): 112 | await t 113 | else: 114 | raise Exception("Unknown server framework") 115 | -------------------------------------------------------------------------------- /tests/django.py: -------------------------------------------------------------------------------- 1 | from asyncio import CancelledError, Event, create_task 2 | 3 | from hypercorn.asyncio import serve 4 | from hypercorn.config import Config 5 | 6 | from uapi.django import DjangoApp 7 | 8 | # Sigh 9 | urlpatterns: list = [] 10 | 11 | 12 | async def run_on_django(app: DjangoApp, port: int) -> None: 13 | from django.conf import settings 14 | from django.core.handlers.wsgi import WSGIHandler 15 | 16 | urlpatterns.clear() 17 | urlpatterns.extend(app.to_urlpatterns()) 18 | 19 | if not settings.configured: 20 | settings.configure(ROOT_URLCONF=__name__, DEBUG=True) 21 | 22 | application = WSGIHandler() 23 | 24 | config = Config() 25 | config.bind = [f"localhost:{port}"] 26 | 27 | event = Event() 28 | 29 | t = create_task( 30 | serve(application, config, shutdown_trigger=event.wait, mode="wsgi") # type: ignore 31 | ) 32 | 33 | try: 34 | await t 35 | except CancelledError: 36 | event.set() 37 | await t 38 | raise 39 | finally: 40 | urlpatterns.clear() 41 | -------------------------------------------------------------------------------- /tests/django_uapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/tests/django_uapi/__init__.py -------------------------------------------------------------------------------- /tests/django_uapi/django_uapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/tests/django_uapi/django_uapi/__init__.py -------------------------------------------------------------------------------- /tests/django_uapi/django_uapi/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for django_uapi project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_uapi.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /tests/django_uapi/django_uapi/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_uapi project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.1.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = ( 24 | "django-insecure-9_f&0nghezgc!%y%sn10(y4s14^-d57e9em4d901$u9#qbv!9+" # noqa: S105 25 | ) 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS: list[str] = [] 31 | 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | "django.contrib.admin", 37 | "django.contrib.auth", 38 | "django.contrib.contenttypes", 39 | "django.contrib.sessions", 40 | "django.contrib.messages", 41 | "django.contrib.staticfiles", 42 | "tests.django_uapi_app", 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | "django.middleware.security.SecurityMiddleware", 47 | "django.contrib.sessions.middleware.SessionMiddleware", 48 | "django.middleware.common.CommonMiddleware", 49 | "django.middleware.csrf.CsrfViewMiddleware", 50 | "django.contrib.auth.middleware.AuthenticationMiddleware", 51 | "django.contrib.messages.middleware.MessageMiddleware", 52 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 53 | ] 54 | 55 | ROOT_URLCONF = "tests.django_uapi.django_uapi.urls" 56 | 57 | TEMPLATES = [ 58 | { 59 | "BACKEND": "django.template.backends.django.DjangoTemplates", 60 | "DIRS": [], 61 | "APP_DIRS": True, 62 | "OPTIONS": { 63 | "context_processors": [ 64 | "django.template.context_processors.debug", 65 | "django.template.context_processors.request", 66 | "django.contrib.auth.context_processors.auth", 67 | "django.contrib.messages.context_processors.messages", 68 | ] 69 | }, 70 | } 71 | ] 72 | 73 | WSGI_APPLICATION = "tests.django_uapi.django_uapi.wsgi.application" 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 78 | 79 | DATABASES = { 80 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"} 81 | } 82 | 83 | 84 | # Password validation 85 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 86 | 87 | AUTH_PASSWORD_VALIDATORS = [ 88 | { 89 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 90 | }, 91 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 92 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 93 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 94 | ] 95 | 96 | 97 | # Internationalization 98 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 99 | 100 | LANGUAGE_CODE = "en-us" 101 | 102 | TIME_ZONE = "UTC" 103 | 104 | USE_I18N = True 105 | 106 | USE_TZ = True 107 | 108 | 109 | # Static files (CSS, JavaScript, Images) 110 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 111 | 112 | STATIC_URL = "static/" 113 | 114 | # Default primary key field type 115 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 116 | 117 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 118 | -------------------------------------------------------------------------------- /tests/django_uapi/django_uapi/urls.py: -------------------------------------------------------------------------------- 1 | """django_uapi URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import include, path 17 | from tests.django_uapi_app.views import app 18 | 19 | urlpatterns = [path("", include(app.to_urlpatterns()))] 20 | -------------------------------------------------------------------------------- /tests/django_uapi/django_uapi/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_uapi project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault( 15 | "DJANGO_SETTINGS_MODULE", "tests.django_uapi.django_uapi.settings" 16 | ) 17 | 18 | application = get_wsgi_application() 19 | -------------------------------------------------------------------------------- /tests/django_uapi/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault( 10 | "DJANGO_SETTINGS_MODULE", "tests.django_uapi.django_uapi.settings" 11 | ) 12 | try: 13 | from django.core.management import execute_from_command_line 14 | except ImportError as exc: 15 | raise ImportError( 16 | "Couldn't import Django. Are you sure it's installed and " 17 | "available on your PYTHONPATH environment variable? Did you " 18 | "forget to activate a virtual environment?" 19 | ) from exc 20 | execute_from_command_line(sys.argv) 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /tests/django_uapi_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/tests/django_uapi_app/__init__.py -------------------------------------------------------------------------------- /tests/django_uapi_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoUapiAppConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "tests.django_uapi_app" 7 | -------------------------------------------------------------------------------- /tests/django_uapi_app/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest as Request 2 | from django.http import HttpResponse as Response 3 | from uapi import Method, ResponseException, RouteName 4 | from uapi.django import App 5 | from uapi.status import NoContent 6 | 7 | from ..apps import configure_base_sync 8 | 9 | app = App() 10 | 11 | configure_base_sync(app) 12 | 13 | 14 | class DjangoRespSubclass(Response): 15 | pass 16 | 17 | 18 | @app.get("/framework-request") 19 | def framework_request(req: Request) -> str: 20 | return "framework_request" + req.headers["test"] 21 | 22 | 23 | @app.post("/framework-resp-subclass") 24 | def framework_resp_subclass() -> DjangoRespSubclass: 25 | return DjangoRespSubclass("framework_resp_subclass", status=201) 26 | 27 | 28 | def path(path_id: int) -> Response: 29 | return Response(str(path_id + 1)) 30 | 31 | 32 | app.route("/path/", path) 33 | 34 | 35 | @app.options("/unannotated-exception") 36 | def unannotated_exception() -> Response: 37 | raise ResponseException(NoContent()) 38 | 39 | 40 | @app.get("/query/unannotated", tags=["query"]) 41 | def query_unannotated(query) -> Response: 42 | return Response(query + "suffix") 43 | 44 | 45 | @app.get("/query/string", tags=["query"]) 46 | def query_string(query: str) -> Response: 47 | return Response(query + "suffix") 48 | 49 | 50 | @app.get("/query", tags=["query"]) 51 | def query(page: int) -> Response: 52 | return Response(str(page + 1)) 53 | 54 | 55 | @app.get("/query-default", tags=["query"]) 56 | def query_default(page: int = 0) -> Response: 57 | return Response(str(page + 1)) 58 | 59 | 60 | @app.post("/post/no-body-native-response") 61 | def post_no_body() -> Response: 62 | return Response("post", status=201) 63 | 64 | 65 | @app.post("/path1/") 66 | def post_path_string(path_id: str) -> str: 67 | return str(int(path_id) + 2) 68 | 69 | 70 | # This is difficult to programatically set, so just always run it. 71 | app.serve_openapi() 72 | 73 | 74 | # Route name composition. 75 | @app.get("/comp/route-name-native") 76 | @app.post("/comp/route-name-native", name="route-name-native-post") 77 | def route_name_native(route_name: RouteName) -> Response: 78 | return Response(route_name) 79 | 80 | 81 | # Request method composition. 82 | @app.get("/comp/req-method-native") 83 | @app.post("/comp/req-method-native", name="request-method-native-post") 84 | def request_method_native(req_method: Method) -> Response: 85 | return Response(req_method) 86 | -------------------------------------------------------------------------------- /tests/flask.py: -------------------------------------------------------------------------------- 1 | from asyncio import CancelledError, Event, create_task 2 | 3 | from hypercorn.asyncio import serve 4 | from hypercorn.config import Config 5 | 6 | from flask import Response, request 7 | from uapi import ResponseException 8 | from uapi.flask import App, FlaskApp 9 | from uapi.status import NoContent 10 | from uapi.types import Method, RouteName 11 | 12 | from .apps import configure_base_sync 13 | 14 | 15 | def make_app() -> App: 16 | app = App() 17 | 18 | configure_base_sync(app) 19 | 20 | @app.get("/framework-request") 21 | def framework_request() -> str: 22 | return "framework_request" + request.headers["test"] 23 | 24 | @app.post("/framework-resp-subclass") 25 | def framework_resp_subclass() -> Response: 26 | return Response("framework_resp_subclass", status=201) 27 | 28 | def path(path_id: int) -> Response: 29 | return Response(str(path_id + 1)) 30 | 31 | app.route("/path/", path) 32 | 33 | @app.options("/unannotated-exception") 34 | def unannotated_exception() -> Response: 35 | raise ResponseException(NoContent()) 36 | 37 | @app.get("/query/unannotated", tags=["query"]) 38 | def query_unannotated(query) -> Response: 39 | return Response(query + "suffix") 40 | 41 | @app.get("/query/string", tags=["query"]) 42 | def query_string(query: str) -> Response: 43 | return Response(query + "suffix") 44 | 45 | @app.get("/query", tags=["query"]) 46 | def query(page: int) -> Response: 47 | return Response(str(page + 1)) 48 | 49 | @app.get("/query-default", tags=["query"]) 50 | def query_default(page: int = 0) -> Response: 51 | return Response(str(page + 1)) 52 | 53 | @app.post("/post/no-body-native-response") 54 | def post_no_body() -> Response: 55 | return Response("post", status=201) 56 | 57 | @app.post("/path1/") 58 | def post_path_string(path_id: str) -> str: 59 | return str(int(path_id) + 2) 60 | 61 | # Route name composition. 62 | @app.get("/comp/route-name-native") 63 | @app.post("/comp/route-name-native", name="route-name-native-post") 64 | def route_name_native(route_name: RouteName) -> Response: 65 | return Response(route_name) 66 | 67 | # Request method composition. 68 | @app.get("/comp/req-method-native") 69 | @app.post("/comp/req-method-native", name="request-method-native-post") 70 | def request_method_native(req_method: Method) -> Response: 71 | return Response(req_method) 72 | 73 | return app 74 | 75 | 76 | async def run_on_flask(app: FlaskApp, port: int): 77 | config = Config() 78 | config.bind = [f"localhost:{port}"] 79 | 80 | event = Event() 81 | 82 | t = create_task( 83 | serve( 84 | app.to_framework_app(__name__), 85 | config, 86 | shutdown_trigger=event.wait, # type: ignore 87 | mode="wsgi", 88 | ) 89 | ) 90 | 91 | try: 92 | await t 93 | except CancelledError: 94 | event.set() 95 | await t 96 | raise 97 | -------------------------------------------------------------------------------- /tests/login/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/tests/login/__init__.py -------------------------------------------------------------------------------- /tests/login/test_login.py: -------------------------------------------------------------------------------- 1 | from asyncio import CancelledError, create_task, sleep 2 | from collections.abc import Callable 3 | from contextlib import suppress 4 | from datetime import timedelta 5 | 6 | import pytest 7 | from aioredis import create_redis_pool 8 | from httpx import AsyncClient 9 | 10 | from tests.starlette import run_on_starlette as run_on_framework 11 | from uapi.cookies import CookieSettings 12 | from uapi.login import AsyncLoginSession, configure_async_login 13 | from uapi.sessions.redis import configure_async_sessions 14 | from uapi.starlette import App as FrameworkApp 15 | from uapi.status import Created, NoContent 16 | 17 | 18 | async def configure_login_app(app: FrameworkApp) -> None: 19 | rss = configure_async_sessions( 20 | app, 21 | await create_redis_pool("redis://", encoding="utf8"), 22 | cookie_settings=CookieSettings(secure=False), 23 | max_age=timedelta(seconds=1), 24 | ) 25 | login_manager = configure_async_login(app, int, rss) 26 | 27 | @app.get("/") 28 | async def index(current_user_id: int | None) -> str: 29 | if current_user_id is None: 30 | return "no user" 31 | return str(current_user_id) 32 | 33 | @app.post("/login") 34 | async def login(login_session: AsyncLoginSession[int]) -> Created[None]: 35 | return Created(None, await login_session.login_and_return(10)) 36 | 37 | @app.post("/logout") 38 | async def logout(login_session: AsyncLoginSession[int]) -> NoContent: 39 | return NoContent(await login_session.logout_and_return()) 40 | 41 | @app.delete("/sessions/{user_id}") 42 | async def logout_other(current_user_id: int, user_id: int) -> str: 43 | """The current user is an admin, and is logging out another user.""" 44 | await login_manager.logout(user_id) 45 | return "OK" 46 | 47 | 48 | @pytest.fixture(scope="session") 49 | async def login_app(unused_tcp_port_factory: Callable[..., int]): 50 | unused_tcp_port = unused_tcp_port_factory() 51 | app = FrameworkApp() 52 | await configure_login_app(app) 53 | t = create_task(run_on_framework(app, unused_tcp_port)) 54 | yield unused_tcp_port 55 | 56 | t.cancel() 57 | with suppress(CancelledError): 58 | await t 59 | 60 | 61 | async def test_login_logout(login_app: int): 62 | """Test a normal login/logout workflow.""" 63 | user_id = 10 64 | async with AsyncClient() as client: 65 | resp = await client.get(f"http://localhost:{login_app}/") 66 | assert resp.text == "no user" 67 | 68 | resp = await client.post( 69 | f"http://localhost:{login_app}/login", params={"user_id": str(user_id)} 70 | ) 71 | 72 | assert resp.status_code == 201 73 | 74 | resp = await client.get(f"http://localhost:{login_app}/") 75 | assert resp.text == str(user_id) 76 | 77 | async with AsyncClient() as new_client: 78 | resp = await new_client.get(f"http://localhost:{login_app}/") 79 | assert resp.text == "no user" 80 | 81 | resp = await client.get(f"http://localhost:{login_app}/") 82 | assert resp.text == str(user_id) 83 | 84 | resp = await client.post(f"http://localhost:{login_app}/logout") 85 | assert resp.text == "" 86 | assert resp.status_code == 204 87 | assert not resp.cookies 88 | 89 | resp = await client.get(f"http://localhost:{login_app}/") 90 | assert resp.text == "no user" 91 | 92 | 93 | async def test_session_expiry(login_app: int): 94 | """Test session expiry.""" 95 | user_id = 10 96 | async with AsyncClient() as client: 97 | resp = await client.get(f"http://localhost:{login_app}/") 98 | assert resp.text == "no user" 99 | 100 | resp = await client.post( 101 | f"http://localhost:{login_app}/login", params={"user_id": user_id} 102 | ) 103 | 104 | assert resp.status_code == 201 105 | 106 | resp = await client.get(f"http://localhost:{login_app}/") 107 | assert resp.text == str(user_id) 108 | 109 | await sleep(2) 110 | 111 | resp = await client.get(f"http://localhost:{login_app}/") 112 | assert resp.text == "no user" 113 | 114 | 115 | async def test_logging_out_others(login_app: int): 116 | """Test whether other users can be logged out.""" 117 | user_id = 10 118 | admin_id = 11 119 | async with AsyncClient() as client: # This is the user. 120 | resp = await client.get(f"http://localhost:{login_app}/") 121 | assert resp.text == "no user" 122 | 123 | resp = await client.post( 124 | f"http://localhost:{login_app}/login", params={"user_id": user_id} 125 | ) 126 | 127 | assert resp.status_code == 201 128 | 129 | resp = await client.get(f"http://localhost:{login_app}/") 130 | assert resp.text == str(user_id) 131 | 132 | async with AsyncClient() as new_client: # This is the admin. 133 | resp = await new_client.delete(f"http://localhost:{login_app}/sessions/10") 134 | assert resp.status_code == 403 135 | 136 | await new_client.post( 137 | f"http://localhost:{login_app}/login", params={"user_id": admin_id} 138 | ) 139 | resp = await new_client.delete(f"http://localhost:{login_app}/sessions/10") 140 | assert resp.status_code == 200 141 | 142 | resp = await client.get(f"http://localhost:{login_app}/") 143 | assert resp.text == "no user" 144 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, timezone 2 | from typing import Generic, Literal, TypeVar 3 | 4 | from attrs import Factory, define 5 | 6 | 7 | @define 8 | class SimpleModel: 9 | """A simple dummy model.""" 10 | 11 | an_int: int = 1 12 | a_string: str = "1" 13 | a_float: float = 1.0 14 | 15 | 16 | @define 17 | class SimpleModelNoDefaults: 18 | """A simple dummy model with no defaults.""" 19 | 20 | an_int: int 21 | a_string: str 22 | a_float: float 23 | 24 | 25 | @define 26 | class NestedModel: 27 | """A nested model.""" 28 | 29 | simple_model: SimpleModel = SimpleModel() 30 | a_dict: dict[str, str] = Factory(dict) 31 | a_list: list[SimpleModel] = Factory(list) 32 | 33 | 34 | @define 35 | class ResponseList: 36 | a: str 37 | 38 | 39 | @define 40 | class ResponseModel: 41 | a_list: list[ResponseList] 42 | 43 | 44 | @define 45 | class ModelWithLiteral: 46 | a: Literal["a", "b", "c"] = "a" 47 | 48 | 49 | @define 50 | class ModelWithDatetime: 51 | """Contains a datetime.""" 52 | 53 | a: datetime 54 | b: date 55 | c: datetime = datetime.now(timezone.utc) 56 | d: date = datetime.now(timezone.utc).date() 57 | 58 | 59 | T = TypeVar("T") 60 | U = TypeVar("U") 61 | 62 | 63 | @define 64 | class GenericModel(Generic[T]): 65 | a: T 66 | b: list[T] = Factory(list) 67 | 68 | 69 | @define 70 | class ResponseGenericModel(Generic[T, U]): 71 | """Used in a response to test collection.""" 72 | 73 | a: T 74 | b: list[U] = Factory(list) 75 | 76 | 77 | @define 78 | class ResponseGenericModelInner: 79 | a: int 80 | 81 | 82 | @define 83 | class ResponseGenericModelListInner: 84 | a: int 85 | 86 | 87 | @define 88 | class SumTypesRequestModel: 89 | @define 90 | class SumTypesRequestInner: 91 | a: int 92 | 93 | inner: SumTypesRequestInner | None 94 | opt_string: str | None 95 | opt_def_string: str | None = None 96 | 97 | 98 | @define 99 | class SumTypesResponseModel: 100 | @define 101 | class SumTypesResponseInner: 102 | a: int 103 | 104 | inner: SumTypesResponseInner | None 105 | 106 | 107 | @define 108 | class ModelWithDict: 109 | dict_field: dict[str, SimpleModel] 110 | -------------------------------------------------------------------------------- /tests/models_2.py: -------------------------------------------------------------------------------- 1 | """Test models with the same name, different modules.""" 2 | from attrs import define 3 | 4 | 5 | @define 6 | class SimpleModel: 7 | """A simple dummy model, named like models.SimpleModel.""" 8 | 9 | a_different_int: int 10 | -------------------------------------------------------------------------------- /tests/openapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/tests/openapi/__init__.py -------------------------------------------------------------------------------- /tests/openapi/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from uapi.base import App 4 | 5 | from ..aiohttp import make_app as aiohttp_make_app 6 | from ..django_uapi_app.views import app as django_app 7 | from ..flask import make_app as flask_make_app 8 | from ..quart import make_app as quart_make_app 9 | from ..starlette import make_app as starlette_make_app 10 | 11 | 12 | def django_make_app() -> App: 13 | return django_app 14 | 15 | 16 | @pytest.fixture( 17 | params=[ 18 | aiohttp_make_app, 19 | flask_make_app, 20 | quart_make_app, 21 | starlette_make_app, 22 | django_make_app, 23 | ], 24 | ids=["aiohttp", "flask", "quart", "starlette", "django"], 25 | ) 26 | def app(request) -> App: 27 | return request.param() 28 | -------------------------------------------------------------------------------- /tests/openapi/test_openapi.py: -------------------------------------------------------------------------------- 1 | """Test the OpenAPI schema generation.""" 2 | from httpx import AsyncClient 3 | 4 | from uapi.base import App 5 | from uapi.openapi import IntegerSchema, OpenAPI, Parameter, Response, Schema, converter 6 | 7 | 8 | async def test_get_index(server_with_openapi: int) -> None: 9 | async with AsyncClient() as client: 10 | resp = await client.get(f"http://localhost:{server_with_openapi}/openapi.json") 11 | raw = resp.json() 12 | 13 | spec: OpenAPI = converter.structure(raw, OpenAPI) 14 | 15 | op = spec.paths["/"] 16 | assert op is not None 17 | assert op.get is not None 18 | assert op.get.summary == "Hello" 19 | assert op.get.description == "To be used as a description." 20 | assert op.get.operationId == "hello" 21 | assert op.get.parameters == [] 22 | assert len(op.get.responses) == 1 23 | assert op.get.responses["200"] 24 | assert isinstance(op.get.responses["200"].content["text/plain"].schema, Schema) 25 | assert ( 26 | op.get.responses["200"].content["text/plain"].schema.type == Schema.Type.STRING 27 | ) 28 | 29 | assert op.post is not None 30 | assert op.post.summary == "Hello-Post" 31 | assert op.post.operationId == "hello-post" 32 | assert op.get.description == "To be used as a description." 33 | 34 | 35 | def test_get_path_param(app: App) -> None: 36 | spec: OpenAPI = app.make_openapi_spec() 37 | 38 | op = spec.paths["/path/{path_id}"] 39 | assert op is not None 40 | assert op.get is not None 41 | assert op.get.parameters == [ 42 | Parameter( 43 | name="path_id", 44 | kind=Parameter.Kind.PATH, 45 | required=True, 46 | schema=IntegerSchema(), 47 | ) 48 | ] 49 | assert len(op.get.responses) == 1 50 | assert op.get.responses["200"] 51 | assert op.get.responses["200"].content == {} 52 | 53 | assert op.get.description is None 54 | 55 | 56 | def test_get_query_int(app: App) -> None: 57 | spec: OpenAPI = app.make_openapi_spec() 58 | 59 | op = spec.paths["/query"] 60 | assert op is not None 61 | assert op.get is not None 62 | assert op.get.parameters == [ 63 | Parameter( 64 | name="page", 65 | kind=Parameter.Kind.QUERY, 66 | required=True, 67 | schema=IntegerSchema(), 68 | ) 69 | ] 70 | assert len(op.get.responses) == 1 71 | assert op.get.responses["200"] 72 | 73 | 74 | def test_get_query_default(app: App) -> None: 75 | spec: OpenAPI = app.make_openapi_spec() 76 | 77 | op = spec.paths["/query-default"] 78 | assert op is not None 79 | assert op.get 80 | assert op.get.parameters == [ 81 | Parameter( 82 | name="page", 83 | kind=Parameter.Kind.QUERY, 84 | required=False, 85 | schema=IntegerSchema(), 86 | ) 87 | ] 88 | assert len(op.get.responses) == 1 89 | assert op.get.responses["200"] 90 | 91 | 92 | def test_get_query_unannotated(app: App) -> None: 93 | spec: OpenAPI = app.make_openapi_spec() 94 | 95 | op = spec.paths["/query/unannotated"] 96 | assert op is not None 97 | assert op.get 98 | assert op.get.parameters == [ 99 | Parameter( 100 | name="query", 101 | kind=Parameter.Kind.QUERY, 102 | required=True, 103 | schema=Schema(Schema.Type.STRING), 104 | ) 105 | ] 106 | assert len(op.get.responses) == 1 107 | assert op.get.responses["200"] 108 | 109 | 110 | def test_get_query_string(app: App) -> None: 111 | spec: OpenAPI = app.make_openapi_spec() 112 | 113 | op = spec.paths["/query/string"] 114 | assert op is not None 115 | assert op.get is not None 116 | assert op.get.parameters == [ 117 | Parameter( 118 | name="query", 119 | kind=Parameter.Kind.QUERY, 120 | required=True, 121 | schema=Schema(Schema.Type.STRING), 122 | ) 123 | ] 124 | assert len(op.get.responses) == 1 125 | assert op.get.responses["200"] 126 | 127 | 128 | def test_get_bytes(app: App) -> None: 129 | spec: OpenAPI = app.make_openapi_spec() 130 | 131 | op = spec.paths["/response-bytes"] 132 | assert op is not None 133 | assert op.get 134 | assert op.get.parameters == [] 135 | assert len(op.get.responses) == 1 136 | assert op.get.responses["200"] 137 | assert op.get.responses["200"].content["application/octet-stream"].schema == Schema( 138 | Schema.Type.STRING, format="binary" 139 | ) 140 | 141 | 142 | def test_post_no_body_native_response(app: App) -> None: 143 | spec: OpenAPI = app.make_openapi_spec() 144 | 145 | op = spec.paths["/post/no-body-native-response"] 146 | assert op is not None 147 | assert op.get is None 148 | assert op.post is not None 149 | assert op.post.parameters == [] 150 | assert len(op.post.responses) == 1 151 | assert op.post.responses["200"] 152 | 153 | 154 | def test_post_no_body_no_response(app: App) -> None: 155 | spec: OpenAPI = app.make_openapi_spec() 156 | 157 | op = spec.paths["/post/no-body-no-response"] 158 | assert op is not None 159 | assert op.get is None 160 | assert op.post is not None 161 | assert op.post.parameters == [] 162 | assert len(op.post.responses) == 1 163 | assert op.post.responses["204"] 164 | 165 | 166 | def test_post_custom_status(app: App) -> None: 167 | spec: OpenAPI = app.make_openapi_spec() 168 | 169 | op = spec.paths["/post/201"] 170 | assert op is not None 171 | assert op.get is None 172 | assert op.post is not None 173 | assert op.post.parameters == [] 174 | assert len(op.post.responses) == 1 175 | assert op.post.responses["201"] 176 | 177 | 178 | def test_post_multiple_statuses(app: App) -> None: 179 | spec: OpenAPI = app.make_openapi_spec() 180 | 181 | op = spec.paths["/post/multiple"] 182 | assert op is not None 183 | assert op.get is None 184 | assert op.post is not None 185 | assert op.post.parameters == [] 186 | assert len(op.post.responses) == 2 187 | assert op.post.responses["200"] 188 | schema = op.post.responses["200"].content["text/plain"].schema 189 | assert isinstance(schema, Schema) 190 | assert schema.type == Schema.Type.STRING 191 | assert op.post.responses["201"] 192 | assert not op.post.responses["201"].content 193 | 194 | 195 | def test_put_cookie(app: App) -> None: 196 | spec: OpenAPI = app.make_openapi_spec() 197 | 198 | op = spec.paths["/put/cookie"] 199 | assert op is not None 200 | assert op.get is None 201 | assert op.post is None 202 | assert op.put is not None 203 | assert op.put.parameters == [ 204 | Parameter( 205 | "a_cookie", 206 | Parameter.Kind.COOKIE, 207 | required=True, 208 | schema=Schema(Schema.Type.STRING), 209 | ) 210 | ] 211 | assert op.put.responses["200"] 212 | schema = op.put.responses["200"].content["text/plain"].schema 213 | assert isinstance(schema, Schema) 214 | assert schema.type == Schema.Type.STRING 215 | 216 | 217 | def test_delete(app: App) -> None: 218 | spec: OpenAPI = app.make_openapi_spec() 219 | 220 | op = spec.paths["/delete/header"] 221 | assert op is not None 222 | assert op.get is None 223 | assert op.post is None 224 | assert op.put is None 225 | assert op.delete is not None 226 | assert op.delete.responses == {"204": Response("No content", {})} 227 | 228 | 229 | def test_ignore_framework_request(app: App) -> None: 230 | """Framework request params are ignored.""" 231 | spec: OpenAPI = app.make_openapi_spec() 232 | 233 | op = spec.paths["/framework-request"] 234 | assert op is not None 235 | assert op.get is not None 236 | assert op.post is None 237 | assert op.put is None 238 | assert op.patch is None 239 | assert op.delete is None 240 | 241 | assert op.get.parameters == [] 242 | 243 | 244 | def test_get_injection(app: App) -> None: 245 | spec: OpenAPI = app.make_openapi_spec() 246 | 247 | op = spec.paths["/injection"] 248 | assert op is not None 249 | assert op.get is not None 250 | assert op.get.parameters == [ 251 | Parameter( 252 | name="header-for-injection", 253 | kind=Parameter.Kind.HEADER, 254 | required=True, 255 | schema=Schema(Schema.Type.STRING), 256 | ) 257 | ] 258 | assert len(op.get.responses) == 1 259 | assert op.get.responses["200"] 260 | 261 | 262 | def test_excluded(app: App) -> None: 263 | spec: OpenAPI = app.make_openapi_spec(exclude={"excluded"}) 264 | 265 | assert "/excluded" not in spec.paths 266 | 267 | 268 | def test_tags(app: App) -> None: 269 | """Tags are properly generated.""" 270 | spec: OpenAPI = app.make_openapi_spec() 271 | 272 | tagged_routes = [ 273 | ("/response-bytes", "get"), 274 | ("/query/unannotated", "get"), 275 | ("/query/string", "get"), 276 | ("/query", "get"), 277 | ("/query-default", "get"), 278 | ] 279 | 280 | for path, path_item in spec.paths.items(): 281 | for method in ("get", "post", "put", "delete", "patch"): 282 | if getattr(path_item, method) is not None: 283 | if (path, method) in tagged_routes: 284 | assert ["query"] == getattr(path_item, method).tags 285 | else: 286 | assert not getattr(path_item, method).tags 287 | 288 | 289 | def test_user_response_class(app: App) -> None: 290 | spec: OpenAPI = app.make_openapi_spec(exclude={"excluded"}) 291 | 292 | pathitem = spec.paths["/throttled"] 293 | assert pathitem.get is not None 294 | 295 | assert "429" in pathitem.get.responses 296 | assert "200" in pathitem.get.responses 297 | -------------------------------------------------------------------------------- /tests/openapi/test_openapi_composition.py: -------------------------------------------------------------------------------- 1 | """Test OpenAPI while composing.""" 2 | from uapi.base import App 3 | from uapi.openapi import MediaType, OpenAPI, Response, Schema 4 | 5 | 6 | def test_route_name_and_methods(app: App): 7 | """Route names and methods should be filtered out of OpenAPI schemas.""" 8 | spec = app.make_openapi_spec() 9 | 10 | op = spec.paths["/comp/route-name"].get 11 | assert op is not None 12 | assert op == OpenAPI.PathItem.Operation( 13 | {"200": Response("OK", {"text/plain": MediaType(Schema(Schema.Type.STRING))})}, 14 | summary="Route Name", 15 | operationId="route_name", 16 | ) 17 | 18 | op = spec.paths["/comp/route-name"].post 19 | assert op is not None 20 | assert op == OpenAPI.PathItem.Operation( 21 | {"200": Response("OK", {"text/plain": MediaType(Schema(Schema.Type.STRING))})}, 22 | summary="Route-Name-Post", 23 | operationId="route-name-post", 24 | ) 25 | 26 | 27 | def test_native_route_name_and_methods(app: App): 28 | """ 29 | Route names and methods in native handlers should be filtered out of OpenAPI 30 | schemas. 31 | """ 32 | spec = app.make_openapi_spec() 33 | 34 | op = spec.paths["/comp/route-name-native"].get 35 | assert op is not None 36 | assert op == OpenAPI.PathItem.Operation( 37 | {"200": Response("OK")}, 38 | summary="Route Name Native", 39 | operationId="route_name_native", 40 | ) 41 | 42 | op = spec.paths["/comp/route-name-native"].post 43 | assert op is not None 44 | assert op == OpenAPI.PathItem.Operation( 45 | {"200": Response("OK")}, 46 | summary="Route-Name-Native-Post", 47 | operationId="route-name-native-post", 48 | ) 49 | -------------------------------------------------------------------------------- /tests/openapi/test_openapi_forms.py: -------------------------------------------------------------------------------- 1 | """Forms work with the OpenAPI schema.""" 2 | from uapi.base import App 3 | from uapi.openapi import IntegerSchema, MediaType, Reference, RequestBody, Schema 4 | 5 | 6 | def test_forms(app: App): 7 | """Simple forms work.""" 8 | spec = app.make_openapi_spec() 9 | 10 | pi = spec.paths["/form"] 11 | 12 | assert pi.post 13 | assert pi.post.parameters == [] 14 | 15 | assert pi.post.requestBody == RequestBody( 16 | { 17 | "application/x-www-form-urlencoded": MediaType( 18 | schema=Reference(ref="#/components/schemas/SimpleModelNoDefaults") 19 | ) 20 | } 21 | ) 22 | assert spec.components.schemas["SimpleModelNoDefaults"] == Schema( 23 | Schema.Type.OBJECT, 24 | { 25 | "an_int": IntegerSchema(), 26 | "a_string": Schema(Schema.Type.STRING), 27 | "a_float": Schema(Schema.Type.NUMBER, format="double"), 28 | }, 29 | required=["an_int", "a_string", "a_float"], 30 | ) 31 | -------------------------------------------------------------------------------- /tests/openapi/test_openapi_headers.py: -------------------------------------------------------------------------------- 1 | """Test headers.""" 2 | from uapi.openapi import OpenAPI, Parameter, Schema 3 | 4 | from ..django_uapi_app.views import App 5 | 6 | 7 | def test_header(app: App) -> None: 8 | spec: OpenAPI = app.make_openapi_spec() 9 | 10 | op = spec.paths["/header"] 11 | assert op is not None 12 | assert op.get is None 13 | assert op.post is None 14 | assert op.put is not None 15 | assert op.delete is None 16 | 17 | assert op.put.parameters == [ 18 | Parameter( 19 | "test-header", 20 | Parameter.Kind.HEADER, 21 | required=True, 22 | schema=Schema(Schema.Type.STRING), 23 | ) 24 | ] 25 | assert op.put.responses["200"] 26 | schema = op.put.responses["200"].content["text/plain"].schema 27 | assert isinstance(schema, Schema) 28 | assert schema.type == Schema.Type.STRING 29 | 30 | 31 | def test_default_header(app: App) -> None: 32 | spec: OpenAPI = app.make_openapi_spec() 33 | 34 | op = spec.paths["/header-default"] 35 | assert op is not None 36 | assert op.get is None 37 | assert op.post is None 38 | assert op.put is not None 39 | assert op.delete is None 40 | 41 | assert op.put.parameters == [ 42 | Parameter( 43 | "test-header", 44 | Parameter.Kind.HEADER, 45 | required=False, 46 | schema=Schema(Schema.Type.STRING), 47 | ) 48 | ] 49 | assert op.put.responses["200"] 50 | schema = op.put.responses["200"].content["text/plain"].schema 51 | assert isinstance(schema, Schema) 52 | assert schema.type == Schema.Type.STRING 53 | -------------------------------------------------------------------------------- /tests/openapi/test_openapi_metadata.py: -------------------------------------------------------------------------------- 1 | """Tests for OpenAPI metadata, like summaries and descriptions.""" 2 | from collections.abc import Callable 3 | 4 | from uapi.aiohttp import App 5 | from uapi.openapi import OpenAPI, converter 6 | 7 | 8 | def test_transformers() -> None: 9 | """Transformers are correctly applied.""" 10 | app = App() 11 | 12 | @app.get("/") 13 | def my_handler() -> None: 14 | """A docstring. 15 | 16 | Multiline. 17 | """ 18 | return 19 | 20 | @app.incant.register_by_name 21 | def test(q: int) -> int: 22 | return q 23 | 24 | # This handler uses dependency injection so will get wrapped 25 | # by incant. 26 | @app.post("/") 27 | def handler_with_injection(test: int) -> None: 28 | """A simple docstring.""" 29 | return 30 | 31 | spec = app.make_openapi_spec() 32 | assert spec.paths["/"].get 33 | assert spec.paths["/"].get.summary == "My Handler" 34 | assert spec.paths["/"].get.description == my_handler.__doc__ 35 | 36 | def my_summary_transformer(handler, name: str) -> str: 37 | return name.upper() 38 | 39 | def my_desc_transformer(handler: Callable, name: str) -> str: 40 | """Return the first line of the docstring.""" 41 | return (handler.__doc__ or "").split("\n")[0].strip() 42 | 43 | app.serve_openapi( 44 | summary_transformer=my_summary_transformer, 45 | description_transformer=my_desc_transformer, 46 | ) 47 | 48 | handler = app._route_map[("GET", "/openapi.json")] 49 | 50 | transformed_spec = converter.loads(handler[0]().ret, OpenAPI) 51 | 52 | assert transformed_spec.paths["/"].get 53 | assert transformed_spec.paths["/"].get.summary == "MY_HANDLER" 54 | assert transformed_spec.paths["/"].get.description == "A docstring." 55 | 56 | assert transformed_spec.paths["/"].post 57 | assert transformed_spec.paths["/"].post.summary == "HANDLER_WITH_INJECTION" 58 | assert transformed_spec.paths["/"].post.description == "A simple docstring." 59 | -------------------------------------------------------------------------------- /tests/openapi/test_openapi_uis.py: -------------------------------------------------------------------------------- 1 | from asyncio import CancelledError, create_task 2 | from collections.abc import Callable 3 | from contextlib import suppress 4 | 5 | import pytest 6 | 7 | from aiohttp import ClientSession 8 | from uapi.flask import App 9 | 10 | from ..flask import run_on_flask 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | async def openapi_renderer_app(unused_tcp_port_factory: Callable[..., int]): 15 | unused_tcp_port = unused_tcp_port_factory() 16 | app = App() 17 | 18 | app.serve_openapi(path="/openapi_test.json") 19 | app.serve_swaggerui(openapi_path="/openapi_test.json") 20 | app.serve_redoc(openapi_path="/openapi_test.json") 21 | app.serve_elements(openapi_path="/openapi_test.json") 22 | 23 | t = create_task(run_on_flask(app, unused_tcp_port)) 24 | yield unused_tcp_port 25 | 26 | t.cancel() 27 | with suppress(CancelledError): 28 | await t 29 | 30 | 31 | async def test_elements(openapi_renderer_app: int) -> None: 32 | async with ClientSession() as session: 33 | resp = await session.get(f"http://localhost:{openapi_renderer_app}/elements") 34 | 35 | assert resp.status == 200 36 | assert 'apiDescriptionUrl="/openapi_test.json"' in (await resp.text()) 37 | 38 | 39 | async def test_swagger(openapi_renderer_app: int) -> None: 40 | """SwaggerUI is served properly.""" 41 | async with ClientSession() as session: 42 | resp = await session.get(f"http://localhost:{openapi_renderer_app}/swaggerui") 43 | 44 | assert resp.status == 200 45 | assert 'url: "/openapi_test.json"' in (await resp.text()) 46 | 47 | 48 | async def test_redoc(openapi_renderer_app: int) -> None: 49 | """Redoc is served properly.""" 50 | async with ClientSession() as session: 51 | resp = await session.get(f"http://localhost:{openapi_renderer_app}/redoc") 52 | 53 | assert resp.status == 200 54 | assert "spec-url='/openapi_test.json'" in (await resp.text()) 55 | -------------------------------------------------------------------------------- /tests/openapi/test_shorthands.py: -------------------------------------------------------------------------------- 1 | """OpenAPI works with shorthands.""" 2 | from datetime import datetime, timezone 3 | from typing import Any 4 | 5 | from uapi.openapi import MediaType, OneOfSchema, Response, Schema, SchemaBuilder 6 | from uapi.quart import App 7 | from uapi.shorthands import ResponseAdapter, ResponseShorthand 8 | from uapi.status import Ok 9 | 10 | from ..test_shorthands import DatetimeShorthand 11 | 12 | 13 | def test_no_openapi() -> None: 14 | """Shorthands without OpenAPI support work.""" 15 | app = App().add_response_shorthand(DatetimeShorthand) 16 | 17 | @app.get("/") 18 | async def datetime_handler() -> datetime: 19 | return datetime(2000, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc) 20 | 21 | spec = app.make_openapi_spec() 22 | 23 | assert spec.paths["/"] 24 | assert spec.paths["/"].get is not None 25 | assert spec.paths["/"].get.responses == {} 26 | 27 | 28 | def test_has_openapi() -> None: 29 | """Shorthands with OpenAPI support work.""" 30 | 31 | class OpenAPIDateTime(DatetimeShorthand): 32 | @staticmethod 33 | def make_openapi_response(_, __: SchemaBuilder) -> Response | None: 34 | return Response( 35 | "DESC", 36 | {"test": MediaType(Schema(Schema.Type.STRING, format="datetime"))}, 37 | ) 38 | 39 | app = App().add_response_shorthand(OpenAPIDateTime) 40 | 41 | @app.get("/") 42 | async def datetime_handler() -> datetime: 43 | return datetime(2000, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc) 44 | 45 | spec = app.make_openapi_spec() 46 | 47 | assert spec.paths["/"] 48 | assert spec.paths["/"].get is not None 49 | assert spec.paths["/"].get.responses == { 50 | "200": Response( 51 | "DESC", {"test": MediaType(Schema(Schema.Type.STRING, format="datetime"))} 52 | ) 53 | } 54 | 55 | 56 | def test_unions() -> None: 57 | """A union of a shorthand and a BaseResponse works.""" 58 | app = App() 59 | 60 | @app.get("/") 61 | async def index() -> Ok[str] | bytes: 62 | return b"" 63 | 64 | spec = app.make_openapi_spec() 65 | 66 | op = spec.paths["/"].get 67 | assert op is not None 68 | assert op.responses == { 69 | "200": Response( 70 | "OK", 71 | content={ 72 | "text/plain": MediaType(Schema(Schema.Type.STRING)), 73 | "application/octet-stream": MediaType( 74 | Schema(Schema.Type.STRING, format="binary") 75 | ), 76 | }, 77 | ) 78 | } 79 | 80 | 81 | def test_unions_same_content_type() -> None: 82 | """Content types coalesce.""" 83 | 84 | class MyStr: 85 | pass 86 | 87 | class CustomShorthand(ResponseShorthand[MyStr]): 88 | @staticmethod 89 | def response_adapter_factory(_) -> ResponseAdapter: 90 | return lambda value: Ok(value) 91 | 92 | @staticmethod 93 | def is_union_member(value: Any) -> bool: 94 | return isinstance(value, str) 95 | 96 | @staticmethod 97 | def make_openapi_response(_, builder: SchemaBuilder) -> Response | None: 98 | return Response( 99 | "OK", 100 | {"text/plain": MediaType(builder.PYTHON_PRIMITIVES_TO_OPENAPI[bool])}, 101 | ) 102 | 103 | app = App().add_response_shorthand(CustomShorthand) 104 | 105 | @app.get("/") 106 | async def index() -> str | MyStr: 107 | return "" 108 | 109 | spec = app.make_openapi_spec() 110 | 111 | op = spec.paths["/"].get 112 | assert op is not None 113 | assert op.responses == { 114 | "200": Response( 115 | "OK", 116 | content={ 117 | "text/plain": MediaType( 118 | OneOfSchema( 119 | [Schema(Schema.Type.STRING), Schema(Schema.Type.BOOLEAN)] 120 | ) 121 | ) 122 | }, 123 | ) 124 | } 125 | 126 | 127 | def test_unions_same_content_type_oneof() -> None: 128 | """Content types coalesce.""" 129 | 130 | class MyStr: 131 | pass 132 | 133 | class CustomShorthand(ResponseShorthand[MyStr]): 134 | @staticmethod 135 | def response_adapter_factory(_) -> ResponseAdapter: 136 | return lambda value: Ok(value) 137 | 138 | @staticmethod 139 | def is_union_member(value: Any) -> bool: 140 | return isinstance(value, str) 141 | 142 | @staticmethod 143 | def make_openapi_response(_, __: SchemaBuilder) -> Response | None: 144 | return Response( 145 | "OK", 146 | { 147 | "text/plain": MediaType( 148 | OneOfSchema( 149 | [Schema(Schema.Type.BOOLEAN), Schema(Schema.Type.NULL)] 150 | ) 151 | ) 152 | }, 153 | ) 154 | 155 | app = App().add_response_shorthand(CustomShorthand) 156 | 157 | @app.get("/") 158 | async def index() -> str | MyStr: 159 | return "" 160 | 161 | spec = app.make_openapi_spec() 162 | 163 | op = spec.paths["/"].get 164 | assert op is not None 165 | assert op.responses == { 166 | "200": Response( 167 | "OK", 168 | content={ 169 | "text/plain": MediaType( 170 | OneOfSchema( 171 | [ 172 | Schema(Schema.Type.STRING), 173 | Schema(Schema.Type.BOOLEAN), 174 | Schema(Schema.Type.NULL), 175 | ] 176 | ) 177 | ) 178 | }, 179 | ) 180 | } 181 | 182 | 183 | def test_unions_of_shorthands() -> None: 184 | """A union of shorthands works.""" 185 | app = App() 186 | 187 | @app.get("/") 188 | async def index() -> str | None | bytes: 189 | return b"" 190 | 191 | spec = app.make_openapi_spec() 192 | 193 | op = spec.paths["/"].get 194 | assert op is not None 195 | assert op.responses == { 196 | "200": Response( 197 | "OK", 198 | content={ 199 | "text/plain": MediaType(Schema(Schema.Type.STRING)), 200 | "application/octet-stream": MediaType( 201 | Schema(Schema.Type.STRING, format="binary") 202 | ), 203 | }, 204 | ), 205 | "204": Response("No content"), 206 | } 207 | -------------------------------------------------------------------------------- /tests/quart.py: -------------------------------------------------------------------------------- 1 | from quart import Response, request 2 | from uapi import Method, ResponseException, RouteName 3 | from uapi.quart import App 4 | from uapi.status import NoContent 5 | 6 | from .apps import configure_base_async 7 | 8 | 9 | def make_app() -> App: 10 | app = App() 11 | 12 | configure_base_async(app) 13 | 14 | @app.get("/framework-request") 15 | async def framework_request() -> str: 16 | return "framework_request" + request.headers["test"] 17 | 18 | @app.post("/framework-resp-subclass") 19 | async def framework_resp_subclass() -> Response: 20 | return Response("framework_resp_subclass", status=201) 21 | 22 | async def path(path_id: int) -> Response: 23 | return Response(str(path_id + 1)) 24 | 25 | app.route("/path/", path) 26 | 27 | @app.post("/path1/") 28 | async def post_path_string(path_id: str) -> str: 29 | return str(int(path_id) + 2) 30 | 31 | @app.options("/unannotated-exception") 32 | async def unannotated_exception() -> Response: 33 | raise ResponseException(NoContent()) 34 | 35 | @app.get("/query/unannotated", tags=["query"]) 36 | async def query_unannotated(query) -> Response: 37 | return Response(query + "suffix") 38 | 39 | @app.get("/query/string", tags=["query"]) 40 | async def query_string(query: str) -> Response: 41 | return Response(query + "suffix") 42 | 43 | @app.get("/query", tags=["query"]) 44 | async def query(page: int) -> Response: 45 | return Response(str(page + 1)) 46 | 47 | @app.get("/query-default", tags=["query"]) 48 | async def query_default(page: int = 0) -> Response: 49 | return Response(str(page + 1)) 50 | 51 | @app.post("/post/no-body-native-response") 52 | async def post_no_body() -> Response: 53 | return Response("post", status=201) 54 | 55 | # Route name composition. 56 | @app.get("/comp/route-name-native") 57 | @app.post("/comp/route-name-native", name="route-name-native-post") 58 | def route_name_native(route_name: RouteName) -> Response: 59 | return Response(route_name) 60 | 61 | # Request method composition. 62 | @app.get("/comp/req-method-native") 63 | @app.post("/comp/req-method-native", name="request-method-native-post") 64 | def request_method_native(req_method: Method) -> Response: 65 | return Response(req_method) 66 | 67 | return app 68 | 69 | 70 | async def run_on_quart(app: App, port: int) -> None: 71 | await app.run(__name__, port=port, handle_signals=False, log_level="critical") 72 | -------------------------------------------------------------------------------- /tests/response_classes.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from uapi.status import BaseResponse, R 4 | 5 | 6 | class TooManyRequests(BaseResponse[Literal[429], R]): 7 | """A user-defined response class.""" 8 | -------------------------------------------------------------------------------- /tests/sessions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tinche/uapi/74a478fb91368eb7f341607bcc2114ed54706144/tests/sessions/__init__.py -------------------------------------------------------------------------------- /tests/sessions/conftest.py: -------------------------------------------------------------------------------- 1 | from asyncio import CancelledError, create_task 2 | from collections.abc import Callable 3 | from contextlib import suppress 4 | 5 | import pytest 6 | 7 | from uapi.aiohttp import App as AiohttpApp 8 | from uapi.cookies import CookieSettings 9 | from uapi.flask import App as FlaskApp 10 | from uapi.flask import FlaskApp as OriginFlaskApp 11 | from uapi.quart import App as QuartApp 12 | from uapi.sessions import Session, configure_secure_sessions 13 | from uapi.starlette import App as StarletteApp 14 | from uapi.status import Created, NoContent 15 | 16 | from ..aiohttp import run_on_aiohttp 17 | from ..flask import run_on_flask 18 | from ..quart import run_on_quart 19 | from ..starlette import run_on_starlette 20 | 21 | 22 | def configure_secure_session_app( 23 | app: AiohttpApp | QuartApp | StarletteApp | FlaskApp, 24 | ) -> None: 25 | configure_secure_sessions( 26 | app, "test", settings=CookieSettings(max_age=2, secure=False) 27 | ) 28 | 29 | if isinstance(app, OriginFlaskApp): 30 | 31 | @app.get("/") 32 | def index(session: Session) -> str: 33 | if "user_id" not in session: 34 | return "not-logged-in" 35 | return session["user_id"] 36 | 37 | @app.post("/login") 38 | def login(username: str, session: Session) -> Created[None]: 39 | session["user_id"] = username 40 | return Created(None, session.update_session()) 41 | 42 | @app.post("/logout") 43 | def logout(session: Session) -> NoContent: 44 | session.pop("user_id", None) 45 | return NoContent(session.update_session()) 46 | 47 | else: 48 | 49 | @app.get("/") 50 | async def index(session: Session) -> str: 51 | if "user_id" not in session: 52 | return "not-logged-in" 53 | return session["user_id"] 54 | 55 | @app.post("/login") 56 | async def login(username: str, session: Session) -> Created[None]: 57 | session["user_id"] = username 58 | return Created(None, session.update_session()) 59 | 60 | @app.post("/logout") 61 | async def logout(session: Session) -> NoContent: 62 | session.pop("user_id", None) 63 | return NoContent(session.update_session()) 64 | 65 | 66 | @pytest.fixture(params=["aiohttp", "flask", "quart", "starlette"], scope="session") 67 | async def secure_cookie_session_app( 68 | request, unused_tcp_port_factory: Callable[..., int] 69 | ): 70 | unused_tcp_port = unused_tcp_port_factory() 71 | if request.param == "aiohttp": 72 | app = AiohttpApp() 73 | configure_secure_session_app(app) 74 | t = create_task(run_on_aiohttp(app, unused_tcp_port)) 75 | yield unused_tcp_port 76 | t.cancel() 77 | with suppress(CancelledError): 78 | await t 79 | elif request.param == "flask": 80 | flask_app = FlaskApp() 81 | configure_secure_session_app(flask_app) 82 | t = create_task(run_on_flask(flask_app, unused_tcp_port)) 83 | yield unused_tcp_port 84 | t.cancel() 85 | with suppress(CancelledError): 86 | await t 87 | elif request.param == "quart": 88 | quart_app = QuartApp() 89 | configure_secure_session_app(quart_app) 90 | t = create_task(run_on_quart(quart_app, unused_tcp_port)) 91 | yield unused_tcp_port 92 | t.cancel() 93 | with suppress(CancelledError): 94 | await t 95 | elif request.param == "starlette": 96 | starlette_app = StarletteApp() 97 | configure_secure_session_app(starlette_app) 98 | t = create_task(run_on_starlette(starlette_app, unused_tcp_port)) 99 | yield unused_tcp_port 100 | t.cancel() 101 | with suppress(CancelledError): 102 | await t 103 | else: 104 | raise Exception("Unknown server framework") 105 | -------------------------------------------------------------------------------- /tests/sessions/test_redis_sessions.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from asyncio import CancelledError, create_task, sleep 3 | from collections.abc import Callable 4 | from datetime import timedelta 5 | 6 | import pytest 7 | from aioredis import create_redis_pool 8 | from httpx import AsyncClient 9 | 10 | from tests.aiohttp import run_on_aiohttp 11 | from uapi.aiohttp import App as AiohttpApp 12 | from uapi.cookies import CookieSettings 13 | from uapi.openapi import ApiKeySecurityScheme 14 | from uapi.sessions.redis import AsyncSession, configure_async_sessions 15 | from uapi.status import Created, NoContent 16 | 17 | 18 | async def configure_redis_session_app(app: AiohttpApp) -> None: 19 | configure_async_sessions( 20 | app, 21 | await create_redis_pool("redis://"), 22 | cookie_settings=CookieSettings(secure=False), 23 | max_age=timedelta(seconds=1), 24 | ) 25 | 26 | @app.get("/") 27 | async def index(session: AsyncSession) -> str: 28 | if "user_id" not in session: 29 | return "naughty!" 30 | return session["user_id"] 31 | 32 | @app.post("/login") 33 | async def login(username: str, session: AsyncSession) -> Created[None]: 34 | session["user_id"] = username 35 | return Created(None, await session.update_session(namespace=username)) 36 | 37 | @app.post("/logout") 38 | async def logout(session: AsyncSession) -> NoContent: 39 | return NoContent(await session.clear_session()) 40 | 41 | 42 | @pytest.fixture(scope="session") 43 | async def redis_session_app(unused_tcp_port_factory: Callable[..., int]): 44 | unused_tcp_port = unused_tcp_port_factory() 45 | app = AiohttpApp() 46 | await configure_redis_session_app(app) 47 | app.serve_openapi() 48 | t = create_task(run_on_aiohttp(app, unused_tcp_port)) 49 | yield unused_tcp_port 50 | t.cancel() 51 | with contextlib.suppress(CancelledError): 52 | await t 53 | 54 | 55 | async def test_login_logout(redis_session_app: int): 56 | """Test path parameter handling.""" 57 | username = "MyCoolUsername" 58 | async with AsyncClient() as client: 59 | resp = await client.get(f"http://localhost:{redis_session_app}/") 60 | assert resp.text == "naughty!" 61 | 62 | resp = await client.post( 63 | f"http://localhost:{redis_session_app}/login", params={"username": username} 64 | ) 65 | 66 | assert resp.status_code == 201 67 | 68 | resp = await client.get(f"http://localhost:{redis_session_app}/") 69 | assert resp.text == "MyCoolUsername" 70 | 71 | async with AsyncClient() as new_client: 72 | resp = await new_client.get(f"http://localhost:{redis_session_app}/") 73 | assert resp.text == "naughty!" 74 | 75 | resp = await client.get(f"http://localhost:{redis_session_app}/") 76 | assert resp.text == "MyCoolUsername" 77 | 78 | resp = await client.post(f"http://localhost:{redis_session_app}/logout") 79 | assert resp.text == "" 80 | assert resp.status_code == 204 81 | assert not resp.cookies 82 | 83 | resp = await client.get(f"http://localhost:{redis_session_app}/") 84 | assert resp.text == "naughty!" 85 | 86 | 87 | async def test_session_expiry(redis_session_app: int) -> None: 88 | """Test path parameter handling.""" 89 | username = "MyCoolUsername" 90 | async with AsyncClient() as client: 91 | resp = await client.get(f"http://localhost:{redis_session_app}/") 92 | assert resp.text == "naughty!" 93 | 94 | resp = await client.post( 95 | f"http://localhost:{redis_session_app}/login", params={"username": username} 96 | ) 97 | 98 | assert resp.status_code == 201 99 | 100 | resp = await client.get(f"http://localhost:{redis_session_app}/") 101 | assert resp.text == "MyCoolUsername" 102 | 103 | await sleep(2) 104 | 105 | resp = await client.get(f"http://localhost:{redis_session_app}/") 106 | assert resp.text == "naughty!" 107 | 108 | 109 | async def test_openapi_security() -> None: 110 | app = AiohttpApp() 111 | await configure_redis_session_app(app) 112 | 113 | openapi = app.make_openapi_spec() 114 | 115 | assert openapi.components.securitySchemes[ 116 | "cookie/session_id" 117 | ] == ApiKeySecurityScheme("session_id", "cookie") 118 | 119 | assert openapi.paths["/"].get 120 | assert openapi.paths["/"].get.security == [{"cookie/session_id": []}] 121 | 122 | assert openapi.paths["/logout"].post 123 | assert openapi.paths["/logout"].post.security == [{"cookie/session_id": []}] 124 | -------------------------------------------------------------------------------- /tests/sessions/test_secure_cookie_sessions.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | 3 | from httpx import AsyncClient 4 | 5 | 6 | async def test_login_logout(secure_cookie_session_app: int) -> None: 7 | """Test logging in and out.""" 8 | username = "MyCoolUsername" 9 | async with AsyncClient() as client: 10 | resp = await client.get(f"http://localhost:{secure_cookie_session_app}/") 11 | assert resp.text == "not-logged-in" 12 | 13 | resp = await client.post( 14 | f"http://localhost:{secure_cookie_session_app}/login", 15 | params={"username": username}, 16 | ) 17 | 18 | assert resp.status_code == 201 19 | 20 | resp = await client.get(f"http://localhost:{secure_cookie_session_app}/") 21 | assert resp.text == username 22 | 23 | async with AsyncClient() as new_client: 24 | resp = await new_client.get( 25 | f"http://localhost:{secure_cookie_session_app}/" 26 | ) 27 | assert resp.text == "not-logged-in" 28 | 29 | resp = await client.get(f"http://localhost:{secure_cookie_session_app}/") 30 | assert resp.text == username 31 | 32 | resp = await client.post(f"http://localhost:{secure_cookie_session_app}/logout") 33 | assert resp.text == "" 34 | assert resp.status_code == 204 35 | assert not resp.cookies 36 | 37 | resp = await client.get(f"http://localhost:{secure_cookie_session_app}/") 38 | assert resp.text == "not-logged-in" 39 | 40 | 41 | async def test_session_expiry(secure_cookie_session_app: int): 42 | """Test path parameter handling.""" 43 | username = "MyCoolUsername" 44 | async with AsyncClient() as client: 45 | resp = await client.get(f"http://localhost:{secure_cookie_session_app}/") 46 | assert resp.text == "not-logged-in" 47 | 48 | resp = await client.post( 49 | f"http://localhost:{secure_cookie_session_app}/login", 50 | params={"username": username}, 51 | ) 52 | 53 | assert resp.status_code == 201 54 | 55 | resp = await client.get(f"http://localhost:{secure_cookie_session_app}/") 56 | assert resp.text == username 57 | 58 | await sleep(3) 59 | 60 | resp = await client.get(f"http://localhost:{secure_cookie_session_app}/") 61 | assert resp.text == "not-logged-in" 62 | -------------------------------------------------------------------------------- /tests/starlette.py: -------------------------------------------------------------------------------- 1 | from starlette.requests import Request 2 | from starlette.responses import PlainTextResponse, Response 3 | from uapi import Method, ResponseException, RouteName 4 | from uapi.starlette import App 5 | from uapi.status import NoContent 6 | 7 | from .apps import configure_base_async 8 | 9 | 10 | def make_app() -> App: 11 | app = App() 12 | configure_base_async(app) 13 | 14 | @app.get("/framework-request") 15 | async def framework_request(req: Request) -> str: 16 | return "framework_request" + req.headers["test"] 17 | 18 | @app.post("/framework-resp-subclass") 19 | async def framework_resp_subclass() -> PlainTextResponse: 20 | return PlainTextResponse("framework_resp_subclass", status_code=201) 21 | 22 | async def path(path_id: int) -> Response: 23 | return Response(str(path_id + 1)) 24 | 25 | app.route("/path/{path_id}", path) 26 | 27 | @app.options("/unannotated-exception") 28 | async def unannotated_exception() -> Response: 29 | raise ResponseException(NoContent()) 30 | 31 | @app.post("/post/no-body-native-response") 32 | async def post_no_body() -> Response: 33 | return Response("post", 201) 34 | 35 | @app.get("/query/unannotated", tags=["query"]) 36 | async def query_unannotated(query) -> Response: 37 | return Response(query + "suffix") 38 | 39 | @app.get("/query/string", tags=["query"]) 40 | async def query_string(query: str) -> Response: 41 | return Response(query + "suffix") 42 | 43 | @app.get("/query", tags=["query"]) 44 | async def query(page: int) -> Response: 45 | return Response(str(page + 1)) 46 | 47 | @app.get("/query-default", tags=["query"]) 48 | async def query_default(page: int = 0) -> Response: 49 | return Response(str(page + 1)) 50 | 51 | @app.post("/path1/{path_id}") 52 | async def post_path_string(path_id: str) -> str: 53 | return str(int(path_id) + 2) 54 | 55 | # Route name composition. 56 | @app.get("/comp/route-name-native") 57 | @app.post("/comp/route-name-native", name="route-name-native-post") 58 | def route_name_native(route_name: RouteName) -> Response: 59 | return Response(route_name) 60 | 61 | # Request method composition. 62 | @app.get("/comp/req-method-native") 63 | @app.post("/comp/req-method-native", name="request-method-native-post") 64 | def request_method_native(req_method: Method) -> Response: 65 | return Response(req_method) 66 | 67 | return app 68 | 69 | 70 | async def run_on_starlette(app: App, port: int) -> None: 71 | await app.run(port=port, handle_signals=False) 72 | -------------------------------------------------------------------------------- /tests/test_attrs.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from json import dumps 3 | 4 | from cattrs import unstructure 5 | from httpx import AsyncClient 6 | 7 | from tests.models import NestedModel 8 | 9 | 10 | async def test_model(server) -> None: 11 | model = NestedModel() 12 | unstructured = unstructure(model) 13 | async with AsyncClient() as client: 14 | resp = await client.post( 15 | f"http://localhost:{server}/post/model", json=unstructured 16 | ) 17 | assert resp.status_code == 201 18 | assert resp.json() == unstructured 19 | 20 | 21 | async def test_model_wrong_content_type(server) -> None: 22 | """The server should refuse invalid content types, for security.""" 23 | model = NestedModel() 24 | unstructured = unstructure(model) 25 | async with AsyncClient() as client: 26 | resp = await client.post( 27 | f"http://localhost:{server}/post/model", 28 | content=dumps(unstructured), 29 | headers={"content-type": "text/plain"}, 30 | ) 31 | assert resp.status_code == 415 32 | 33 | 34 | async def test_model_error(server: int) -> None: 35 | """The server returns error on invalid data.""" 36 | async with AsyncClient() as client: 37 | resp = await client.post( 38 | f"http://localhost:{server}/post/model", json={"a_dict": "a"} 39 | ) 40 | assert resp.status_code == 400 41 | 42 | 43 | async def test_patch_custom_loader_no_ct(server: int) -> None: 44 | """No content-type required or validated on this endpoint.""" 45 | async with AsyncClient() as client: 46 | resp = await client.patch( 47 | f"http://localhost:{server}/custom-loader-no-ct", 48 | json={"simple_model": {"an_int": 2}}, 49 | ) 50 | assert resp.status_code == 200 51 | assert resp.text == "3" 52 | resp = await client.patch( 53 | f"http://localhost:{server}/custom-loader-no-ct", 54 | json={"simple_model": {"an_int": 2}}, 55 | headers={"content-type": "application/vnd.uapi.v1+json"}, 56 | ) 57 | assert resp.status_code == 200 58 | assert resp.text == "3" 59 | 60 | 61 | async def test_model_custom_error(server: int) -> None: 62 | """The server returns custom errors.""" 63 | async with AsyncClient() as client: 64 | resp = await client.post( 65 | f"http://localhost:{server}/custom-loader-error", json={"a_dict": "a"} 66 | ) 67 | assert resp.status_code == 403 68 | assert resp.text == "While structuring NestedModel (1 sub-exception)" 69 | 70 | 71 | async def test_attrs_union(server: int) -> None: 72 | """Unions of attrs classes work.""" 73 | async with AsyncClient() as client: 74 | resp = await client.patch(f"http://localhost:{server}/patch/attrs") 75 | assert resp.status_code == 200 76 | assert resp.json() == { 77 | "a_dict": {}, 78 | "a_list": [], 79 | "simple_model": {"a_float": 1.0, "a_string": "1", "an_int": 1}, 80 | } 81 | resp = await client.patch( 82 | f"http://localhost:{server}/patch/attrs", params={"test": "1"} 83 | ) 84 | assert resp.status_code == 201 85 | assert resp.json() == {"a_float": 1.0, "a_string": "1", "an_int": 1} 86 | 87 | 88 | async def test_attrs_union_nocontent(server: int) -> None: 89 | """Unions of attrs classes and NoContent work.""" 90 | async with AsyncClient() as client: 91 | resp = await client.get(f"http://localhost:{server}/response-union-nocontent") 92 | assert resp.status_code == 200 93 | assert resp.json() == {"a_float": 1.0, "a_string": "1", "an_int": 1} 94 | resp = await client.get( 95 | f"http://localhost:{server}/response-union-nocontent", params={"page": "1"} 96 | ) 97 | assert resp.status_code == 204 98 | assert resp.read() == b"" 99 | 100 | 101 | async def test_attrs_union_none(server: int) -> None: 102 | """Unions of attrs classes and None work.""" 103 | async with AsyncClient() as client: 104 | resp = await client.get(f"http://localhost:{server}/response-union-none") 105 | assert resp.status_code == 200 106 | assert resp.json() == {"a_float": 1.0, "a_string": "1", "an_int": 1} 107 | resp = await client.get( 108 | f"http://localhost:{server}/response-union-none", params={"page": "1"} 109 | ) 110 | assert resp.status_code == 403 111 | assert resp.read() == b"" 112 | 113 | 114 | async def test_generic_model(server) -> None: 115 | async with AsyncClient() as client: 116 | resp = await client.post( 117 | f"http://localhost:{server}/generic-model", json={"a": 1} 118 | ) 119 | assert resp.status_code == 200 120 | assert resp.json() == { 121 | "a": {"an_int": 1, "a_string": "1", "a_float": 1.0}, 122 | "b": [], 123 | } 124 | 125 | 126 | async def test_dictionary_models(server) -> None: 127 | async with AsyncClient() as client: 128 | resp = await client.post( 129 | f"http://localhost:{server}/dictionary-models", json={"a": {}, "b": {}} 130 | ) 131 | assert resp.status_code == 200 132 | assert resp.json() == { 133 | "dict_field": { 134 | "a": {"an_int": 1, "a_string": "1", "a_float": 1.0}, 135 | "b": {"an_int": 1, "a_string": "1", "a_float": 1.0}, 136 | } 137 | } 138 | 139 | 140 | async def test_datetime_models(server) -> None: 141 | """datetime and date models work.""" 142 | a_val = "2020-01-01T00:00:00+00:00" 143 | b_val = "2020-01-01" 144 | test_time = datetime.now(timezone.utc).isoformat() 145 | 146 | async with AsyncClient() as client: 147 | resp = await client.post( 148 | f"http://localhost:{server}/datetime-models", 149 | json={"a": a_val, "b": b_val, "c": a_val, "d": b_val}, 150 | params={"req_query_datetime": test_time}, 151 | ) 152 | assert resp.status_code == 200 153 | assert resp.json() == {"a": a_val, "b": b_val, "c": test_time, "d": b_val} 154 | 155 | now = datetime.now(timezone.utc).isoformat() 156 | 157 | resp = await client.post( 158 | f"http://localhost:{server}/datetime-models", 159 | json={"a": a_val, "b": b_val, "c": a_val, "d": b_val}, 160 | params={"query_datetime": now, "req_query_datetime": test_time}, 161 | ) 162 | assert resp.status_code == 200 163 | assert resp.json() == {"a": now, "b": b_val, "c": test_time, "d": b_val} 164 | -------------------------------------------------------------------------------- /tests/test_composition.py: -------------------------------------------------------------------------------- 1 | """Test the composition context.""" 2 | from httpx import AsyncClient 3 | 4 | 5 | async def test_route_name(server: int): 6 | async with AsyncClient() as client: 7 | resp = await client.get(f"http://localhost:{server}/comp/route-name") 8 | assert (await resp.aread()) == b"route_name" 9 | 10 | resp = await client.get(f"http://localhost:{server}/comp/route-name-native") 11 | assert (await resp.aread()) == b"route_name_native" 12 | 13 | resp = await client.post(f"http://localhost:{server}/comp/route-name") 14 | assert (await resp.aread()) == b"route-name-post" 15 | 16 | resp = await client.post(f"http://localhost:{server}/comp/route-name-native") 17 | assert (await resp.aread()) == b"route-name-native-post" 18 | 19 | 20 | async def test_request_method(server: int): 21 | async with AsyncClient() as client: 22 | resp = await client.get(f"http://localhost:{server}/comp/req-method") 23 | assert (await resp.aread()) == b"GET" 24 | 25 | resp = await client.post(f"http://localhost:{server}/comp/req-method") 26 | assert (await resp.aread()) == b"POST" 27 | 28 | resp = await client.get(f"http://localhost:{server}/comp/req-method-native") 29 | assert (await resp.aread()) == b"GET" 30 | 31 | resp = await client.post(f"http://localhost:{server}/comp/req-method-native") 32 | assert (await resp.aread()) == b"POST" 33 | -------------------------------------------------------------------------------- /tests/test_cookies.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | 4 | async def test_put_cookie(server: int): 5 | """Cookies work (and on a PUT request).""" 6 | async with AsyncClient() as client: 7 | resp = await client.put( 8 | f"http://localhost:{server}/put/cookie", cookies={"a_cookie": "test"} 9 | ) 10 | assert resp.status_code == 200 11 | assert resp.text == "test" 12 | 13 | 14 | async def test_put_cookie_optional(server): 15 | """Optional cookies work.""" 16 | async with AsyncClient() as client: 17 | resp = await client.put(f"http://localhost:{server}/put/cookie-optional") 18 | assert resp.status_code == 200 19 | assert resp.text == "missing" 20 | resp = await client.put( 21 | f"http://localhost:{server}/put/cookie-optional", 22 | cookies={"A-COOKIE": "cookie"}, 23 | ) 24 | assert resp.status_code == 200 25 | assert resp.text == "cookie" 26 | -------------------------------------------------------------------------------- /tests/test_delete.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | 4 | async def test_delete_response_header(server): 5 | async with AsyncClient() as client: 6 | resp = await client.delete(f"http://localhost:{server}/delete/header") 7 | assert resp.status_code == 204 8 | assert resp.headers["response"] == "test" 9 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | """Tests for ResponseException scenarios.""" 2 | from httpx import AsyncClient 3 | 4 | 5 | async def test_attrs_exception(server): 6 | """Response exceptions work properly in all code paths.""" 7 | async with AsyncClient() as client: 8 | resp = await client.get(f"http://localhost:{server}/exc/attrs") 9 | assert resp.status_code == 200 10 | assert resp.json() == {"a_float": 1.0, "a_string": "1", "an_int": 1} 11 | assert resp.headers["content-type"] == "application/json" 12 | 13 | resp = await client.get(f"http://localhost:{server}/exc/attrs-response") 14 | assert resp.status_code == 200 15 | assert resp.json() == {"a_float": 1.0, "a_string": "1", "an_int": 1} 16 | assert resp.headers["content-type"] == "application/json" 17 | 18 | resp = await client.get(f"http://localhost:{server}/exc/attrs-none") 19 | assert resp.status_code == 200 20 | assert resp.json() == {"a_float": 1.0, "a_string": "1", "an_int": 1} 21 | assert resp.headers["content-type"] == "application/json" 22 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | """Tests for forms.""" 2 | from httpx import AsyncClient 3 | 4 | 5 | async def test_simple_form(server): 6 | """Simplest of forms work.""" 7 | async with AsyncClient() as client: 8 | # content type will be automatically set to 9 | # "application/x-www-form-urlencoded" by httpx 10 | resp = await client.post( 11 | f"http://localhost:{server}/form", 12 | data={"an_int": 2, "a_string": "2", "a_float": 2.0}, 13 | ) 14 | assert resp.status_code == 200 15 | assert resp.read() == b"2" 16 | 17 | 18 | async def test_wrong_content_type(server): 19 | """Wrong content types are rejected.""" 20 | async with AsyncClient() as client: 21 | resp = await client.post( 22 | f"http://localhost:{server}/form", 23 | data={"an_int": 2, "a_string": "2", "a_float": 2.0}, 24 | headers={"content-type": "application/json"}, 25 | ) 26 | 27 | # All frameworks currently silently supply an empty form dictionary, 28 | # which makes the structuring fail. 29 | assert resp.status_code == 400 30 | 31 | 32 | async def test_validation_failure(server): 33 | """Validation failures are handled properly.""" 34 | async with AsyncClient() as client: 35 | resp = await client.post( 36 | f"http://localhost:{server}/form", data={"an_int": "test"} 37 | ) 38 | assert resp.status_code == 400 39 | -------------------------------------------------------------------------------- /tests/test_framework_escape_hatch.py: -------------------------------------------------------------------------------- 1 | """Tests for framework escape hatches.""" 2 | from httpx import AsyncClient 3 | 4 | 5 | async def test_framework_req(server: int) -> None: 6 | """Frameworks use framework requests for this endpoint.""" 7 | async with AsyncClient() as client: 8 | resp = await client.get( 9 | f"http://localhost:{server}/framework-request", headers={"test": "1"} 10 | ) 11 | assert resp.status_code == 200 12 | assert resp.text == "framework_request1" 13 | 14 | 15 | async def test_no_body_native_response_post(server: int) -> None: 16 | async with AsyncClient() as client: 17 | resp = await client.post( 18 | f"http://localhost:{server}/post/no-body-native-response" 19 | ) 20 | assert resp.status_code == 201 21 | assert resp.text == "post" 22 | 23 | 24 | async def test_native_resp_subclass(server: int) -> None: 25 | """Subclasses of the native response work.""" 26 | async with AsyncClient() as client: 27 | resp = await client.post(f"http://localhost:{server}/framework-resp-subclass") 28 | assert resp.status_code == 201 29 | assert resp.text == "framework_resp_subclass" 30 | -------------------------------------------------------------------------------- /tests/test_get.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | 4 | async def test_index(server): 5 | async with AsyncClient() as client: 6 | resp = await client.get(f"http://localhost:{server}") 7 | assert resp.status_code == 200 8 | assert resp.text == "Hello, world" 9 | assert resp.headers["content-type"] == "text/plain" 10 | 11 | 12 | async def test_query_parameter_unannotated(server): 13 | """Test query parameter handling for unannotated parameters.""" 14 | async with AsyncClient() as client: 15 | resp = await client.get( 16 | f"http://localhost:{server}/query/unannotated", params={"query": "test"} 17 | ) 18 | assert resp.status_code == 200 19 | assert resp.text == "testsuffix" 20 | 21 | 22 | async def test_query_parameter_string(server): 23 | """Test query parameter handling for string annotated parameters.""" 24 | async with AsyncClient() as client: 25 | resp = await client.get( 26 | f"http://localhost:{server}/query/string", params={"query": "test"} 27 | ) 28 | assert resp.status_code == 200 29 | assert resp.text == "testsuffix" 30 | 31 | 32 | async def test_query_parameter(server): 33 | """Test query parameter handling.""" 34 | async with AsyncClient() as client: 35 | resp = await client.get(f"http://localhost:{server}/query", params={"page": 10}) 36 | assert resp.status_code == 200 37 | assert resp.text == "11" 38 | 39 | 40 | async def test_query_parameter_default(server): 41 | """Test query parameter handling.""" 42 | async with AsyncClient() as client: 43 | resp = await client.get( 44 | f"http://localhost:{server}/query-default", params={"page": 10} 45 | ) 46 | assert resp.status_code == 200 47 | assert resp.text == "11" 48 | resp = await client.get(f"http://localhost:{server}/query-default") 49 | assert resp.status_code == 200 50 | assert resp.text == "1" 51 | 52 | 53 | async def test_response_bytes(server): 54 | """Test byte responses.""" 55 | async with AsyncClient() as client: 56 | resp = await client.get(f"http://localhost:{server}/response-bytes") 57 | assert resp.status_code == 200 58 | assert resp.headers["content-type"] == "application/octet-stream" 59 | assert resp.read() == b"2" 60 | 61 | 62 | async def test_response_model(server): 63 | """Test models in the response.""" 64 | async with AsyncClient() as client: 65 | resp = await client.get(f"http://localhost:{server}/get/model") 66 | assert resp.status_code == 200 67 | assert resp.headers["content-type"] == "application/json" 68 | assert ( 69 | resp.read() 70 | == b'{"simple_model":{"an_int":1,"a_string":"1","a_float":1.0},"a_dict":{},"a_list":[]}' 71 | ) 72 | 73 | 74 | async def test_response_model_custom_status(server): 75 | """Test models in the response.""" 76 | async with AsyncClient() as client: 77 | resp = await client.get(f"http://localhost:{server}/get/model-status") 78 | assert resp.status_code == 201 79 | assert resp.headers["content-type"] == "application/json" 80 | assert resp.headers["test"] == "test" 81 | assert ( 82 | resp.read() 83 | == b'{"simple_model":{"an_int":1,"a_string":"1","a_float":1.0},"a_dict":{},"a_list":[]}' 84 | ) 85 | 86 | 87 | async def test_user_response_class(server): 88 | """Test user response classes.""" 89 | async with AsyncClient() as client: 90 | resp = await client.get(f"http://localhost:{server}/throttled") 91 | assert resp.status_code == 429 92 | # Django omits the content-length 93 | assert resp.headers.get("content-length", "0") == "0" 94 | -------------------------------------------------------------------------------- /tests/test_head.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | from uapi.status import Forbidden, get_status_code 4 | 5 | 6 | async def test_head_exc(server): 7 | """A head request, fulfilled by a ResponseException.""" 8 | async with AsyncClient() as client: 9 | resp = await client.head(f"http://localhost:{server}/head/exc") 10 | assert resp.status_code == get_status_code(Forbidden) 11 | -------------------------------------------------------------------------------- /tests/test_headers.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | 4 | async def test_simple_header(server: int) -> None: 5 | """Headers work properly.""" 6 | async with AsyncClient() as client: 7 | resp = await client.put( 8 | f"http://localhost:{server}/header", headers={"test-header": "test"} 9 | ) 10 | assert resp.status_code == 200 11 | assert resp.text == "test" 12 | 13 | 14 | async def test_missing_header(server: int) -> None: 15 | """Missing headers provide errors.""" 16 | async with AsyncClient() as client: 17 | resp = await client.put(f"http://localhost:{server}/header") 18 | assert resp.status_code in (400, 500) 19 | 20 | 21 | async def test_header_with_str_default(server: int) -> None: 22 | """String headers with defaults work properly.""" 23 | async with AsyncClient() as client: 24 | resp = await client.put(f"http://localhost:{server}/header-string-default") 25 | assert resp.status_code == 200 26 | assert resp.text == "def" 27 | 28 | resp = await client.put( 29 | f"http://localhost:{server}/header-string-default", 30 | headers={"test-header": "1"}, 31 | ) 32 | assert resp.status_code == 200 33 | assert resp.text == "1" 34 | 35 | 36 | async def test_header_with_default(server: int) -> None: 37 | """Headers with defaults work properly.""" 38 | async with AsyncClient() as client: 39 | resp = await client.put(f"http://localhost:{server}/header-default") 40 | assert resp.status_code == 200 41 | assert resp.text == "default" 42 | 43 | resp = await client.put( 44 | f"http://localhost:{server}/header-default", headers={"test-header": "1"} 45 | ) 46 | assert resp.status_code == 200 47 | assert resp.text == "1" 48 | 49 | 50 | async def test_nonstring_header(server: int) -> None: 51 | """Non-string headers without defaults work properly.""" 52 | async with AsyncClient() as client: 53 | resp = await client.get( 54 | f"http://localhost:{server}/header-nonstring", headers={"test-header": "1"} 55 | ) 56 | assert resp.status_code == 200 57 | assert resp.text == "1" 58 | 59 | 60 | async def test_header_name_override(server: int) -> None: 61 | """Headers can override their names.""" 62 | async with AsyncClient() as client: 63 | resp = await client.get(f"http://localhost:{server}/header-renamed") 64 | assert resp.status_code in (400, 500) 65 | 66 | resp = await client.get( 67 | f"http://localhost:{server}/header-renamed", headers={"test_header": "test"} 68 | ) 69 | assert resp.status_code == 200 70 | assert resp.text == "test" 71 | -------------------------------------------------------------------------------- /tests/test_options.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | from uapi.status import NoContent, get_status_code 4 | 5 | 6 | async def test_head_exc(server: int) -> None: 7 | """A head request, fulfilled by a ResponseException.""" 8 | async with AsyncClient() as client: 9 | resp = await client.options(f"http://localhost:{server}/unannotated-exception") 10 | assert resp.status_code == get_status_code(NoContent) 11 | -------------------------------------------------------------------------------- /tests/test_patch.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | 4 | async def test_patch_cookie(server): 5 | async with AsyncClient() as client: 6 | resp = await client.patch(f"http://localhost:{server}/patch/cookie") 7 | assert resp.status_code == 200 8 | assert resp.cookies["cookie"] == "my_cookie" 9 | -------------------------------------------------------------------------------- /tests/test_path.py: -------------------------------------------------------------------------------- 1 | """Tests for path parameters.""" 2 | from httpx import AsyncClient 3 | 4 | 5 | async def test_path_parameter(server): 6 | """Test path parameter handling.""" 7 | async with AsyncClient() as client: 8 | resp = await client.get(f"http://localhost:{server}/path/15") 9 | assert resp.status_code == 200 10 | assert resp.text == "16" 11 | 12 | 13 | async def test_path_string(server): 14 | """Posting to a path URL which returns a string.""" 15 | async with AsyncClient() as client: 16 | resp = await client.post(f"http://localhost:{server}/path1/20") 17 | assert resp.status_code == 200 18 | assert resp.text == "22" 19 | -------------------------------------------------------------------------------- /tests/test_post.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | 4 | async def test_hello(server): 5 | """The hello handler should work, to show chaining of @route decorators.""" 6 | async with AsyncClient() as client: 7 | resp = await client.post(f"http://localhost:{server}/") 8 | assert resp.status_code == 200 9 | 10 | 11 | async def test_no_body_no_response_post(server: int) -> None: 12 | async with AsyncClient() as client: 13 | resp = await client.post(f"http://localhost:{server}/post/no-body-no-response") 14 | assert resp.status_code == 204 15 | assert resp.read() == b"" 16 | 17 | 18 | async def test_201(server): 19 | async with AsyncClient() as client: 20 | resp = await client.post(f"http://localhost:{server}/post/201") 21 | assert resp.status_code == 201 22 | assert resp.text == "test" 23 | 24 | 25 | async def test_multiple(server): 26 | async with AsyncClient() as client: 27 | resp = await client.post(f"http://localhost:{server}/post/multiple") 28 | assert resp.status_code == 201 29 | assert resp.read() == b"" 30 | -------------------------------------------------------------------------------- /tests/test_put.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | 4 | async def test_put_custom_loader(server: int) -> None: 5 | async with AsyncClient() as client: 6 | resp = await client.put( 7 | f"http://localhost:{server}/custom-loader", 8 | json={"simple_model": {"an_int": 2}}, 9 | ) 10 | assert resp.status_code == 415 11 | assert ( 12 | resp.text == "invalid content type (expected application/vnd.uapi.v1+json)" 13 | ) 14 | resp = await client.put( 15 | f"http://localhost:{server}/custom-loader", 16 | json={"simple_model": {"an_int": 2}}, 17 | headers={"content-type": "application/vnd.uapi.v1+json"}, 18 | ) 19 | assert resp.status_code == 200 20 | assert resp.text == "2" 21 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | 4 | async def test_query_post(server): 5 | """Test query params in posts.""" 6 | async with AsyncClient() as client: 7 | resp = await client.post( 8 | f"http://localhost:{server}/query-post", params={"page": "2"} 9 | ) 10 | assert resp.status_code == 200 11 | assert resp.read() == b"3" 12 | -------------------------------------------------------------------------------- /tests/test_shorthands.py: -------------------------------------------------------------------------------- 1 | """Tests for response shorthands.""" 2 | from asyncio import create_task 3 | from datetime import datetime, timezone 4 | from typing import Any 5 | 6 | import pytest 7 | from attrs import define 8 | from httpx import AsyncClient 9 | 10 | from uapi.aiohttp import AiohttpApp 11 | from uapi.django import DjangoApp 12 | from uapi.flask import FlaskApp 13 | from uapi.quart import App, QuartApp 14 | from uapi.shorthands import ResponseAdapter, ResponseShorthand 15 | from uapi.starlette import StarletteApp 16 | from uapi.status import Created, Ok 17 | 18 | from .aiohttp import run_on_aiohttp 19 | from .django import run_on_django 20 | from .flask import run_on_flask 21 | from .quart import run_on_quart 22 | from .starlette import run_on_starlette 23 | 24 | 25 | class DatetimeShorthand(ResponseShorthand[datetime]): 26 | @staticmethod 27 | def response_adapter_factory(_) -> ResponseAdapter: 28 | return lambda value: Created(value.isoformat()) 29 | 30 | @staticmethod 31 | def is_union_member(value: Any) -> bool: 32 | return isinstance(value, datetime) 33 | 34 | 35 | @pytest.mark.parametrize( 36 | "app_type", [QuartApp, AiohttpApp, StarletteApp, FlaskApp, DjangoApp] 37 | ) 38 | async def test_custom_shorthand( 39 | unused_tcp_port: int, 40 | app_type: type[QuartApp] 41 | | type[AiohttpApp] 42 | | type[StarletteApp] 43 | | type[FlaskApp] 44 | | type[DjangoApp], 45 | ) -> None: 46 | """Custom shorthands work.""" 47 | app = app_type[None]().add_response_shorthand(DatetimeShorthand) # type: ignore 48 | 49 | @app.get("/") 50 | def datetime_handler() -> datetime: 51 | return datetime(2000, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc) 52 | 53 | if app_type is QuartApp: 54 | t = create_task(run_on_quart(app, unused_tcp_port)) 55 | elif app_type is AiohttpApp: 56 | t = create_task(run_on_aiohttp(app, unused_tcp_port)) 57 | elif app_type is StarletteApp: 58 | t = create_task(run_on_starlette(app, unused_tcp_port)) 59 | elif app_type is FlaskApp: 60 | t = create_task(run_on_flask(app, unused_tcp_port)) 61 | elif app_type is DjangoApp: 62 | t = create_task(run_on_django(app, unused_tcp_port)) 63 | 64 | try: 65 | async with AsyncClient() as client: 66 | resp = await client.get(f"http://localhost:{unused_tcp_port}/") 67 | 68 | assert resp.status_code == 201 69 | finally: 70 | t.cancel() 71 | 72 | 73 | async def test_shorthand_unions(unused_tcp_port) -> None: 74 | """Shorthands in unions work.""" 75 | app = App() 76 | 77 | @app.get("/") 78 | async def handler(q: int = 0) -> Ok[str] | None: 79 | return None if not q else Ok("test") 80 | 81 | @app.get("/defaults") 82 | async def default_shorthands(q: int = 0) -> None | bytes | str: 83 | if not q: 84 | return None 85 | if q == 1: 86 | return b"bytes" 87 | return "text" 88 | 89 | t = create_task(run_on_quart(app, unused_tcp_port)) 90 | 91 | try: 92 | async with AsyncClient() as client: 93 | resp = await client.get(f"http://localhost:{unused_tcp_port}/") 94 | assert resp.status_code == 204 95 | 96 | resp = await client.get( 97 | f"http://localhost:{unused_tcp_port}/", params={"q": "1"} 98 | ) 99 | assert resp.status_code == 200 100 | 101 | resp = await client.get(f"http://localhost:{unused_tcp_port}/defaults") 102 | assert resp.status_code == 204 103 | 104 | resp = await client.get( 105 | f"http://localhost:{unused_tcp_port}/defaults", params={"q": 1} 106 | ) 107 | assert resp.status_code == 200 108 | assert resp.headers["content-type"] == "application/octet-stream" 109 | assert await resp.aread() == b"bytes" 110 | 111 | resp = await client.get( 112 | f"http://localhost:{unused_tcp_port}/defaults", params={"q": 2} 113 | ) 114 | assert resp.status_code == 200 115 | assert resp.headers["content-type"] == "text/plain" 116 | assert await resp.aread() == b"text" 117 | finally: 118 | t.cancel() 119 | 120 | 121 | async def test_attrs_shorthand_unions(unused_tcp_port) -> None: 122 | """Attrs shorthands in unions work.""" 123 | app = App() 124 | 125 | @define 126 | class C: 127 | a: int 128 | 129 | @app.get("/") 130 | async def handler(q: int = 0) -> Created[str] | C: 131 | return C(q) if not q else Created("test") 132 | 133 | @app.get("/2") 134 | async def handler_2(q: int = 0) -> str | C: 135 | return C(q) if not q else "test" 136 | 137 | t = create_task(run_on_quart(app, unused_tcp_port)) 138 | 139 | try: 140 | async with AsyncClient() as client: 141 | resp = await client.get(f"http://localhost:{unused_tcp_port}/") 142 | assert resp.status_code == 200 143 | assert (await resp.aread()) == b'{"a":0}' 144 | 145 | resp = await client.get( 146 | f"http://localhost:{unused_tcp_port}/", params={"q": "1"} 147 | ) 148 | assert resp.status_code == 201 149 | assert (await resp.aread()) == b'"test"' 150 | 151 | resp = await client.get(f"http://localhost:{unused_tcp_port}/2") 152 | assert resp.status_code == 200 153 | assert (await resp.aread()) == b'{"a":0}' 154 | 155 | resp = await client.get( 156 | f"http://localhost:{unused_tcp_port}/2", params={"q": "1"} 157 | ) 158 | assert resp.status_code == 200 159 | assert (await resp.aread()) == b"test" 160 | 161 | finally: 162 | t.cancel() 163 | -------------------------------------------------------------------------------- /tests/test_shorthands.yml: -------------------------------------------------------------------------------- 1 | - case: shorthand_str 2 | main: | 3 | from uapi.starlette import App 4 | 5 | app = App() 6 | 7 | @app.get("/") 8 | async def index() -> str: 9 | return "" 10 | 11 | - case: shorthand_attrs 12 | main: | 13 | from attrs import define 14 | from uapi.starlette import App 15 | 16 | @define 17 | class A: 18 | a: int 19 | 20 | app = App() 21 | 22 | @app.get("/") 23 | async def index() -> A: 24 | return A(1) 25 | 26 | - case: shorthand_unsupported 27 | main: | 28 | from uapi.starlette import App 29 | from datetime import datetime 30 | 31 | app = App() 32 | 33 | @app.get("/") 34 | async def index() -> datetime: 35 | return datetime(2000, 1, 1, 0, 0, 0) 36 | out: | 37 | main:6: error: Argument 1 has incompatible type "Callable[[], Coroutine[Any, Any, datetime]]"; expected "Callable[..., BaseResponse[Any, Any] | str | bytes | AttrsInstance | Response | Coroutine[None, None, BaseResponse[Any, Any] | str | bytes | AttrsInstance | Response | None] | None]" [arg-type] 38 | 39 | - case: shorthand_added 40 | main: | 41 | from typing import Any 42 | from datetime import datetime 43 | 44 | from uapi.starlette import App 45 | from uapi.shorthands import ResponseShorthand, ResponseAdapter 46 | from uapi.status import BaseResponse 47 | 48 | class DatetimeShorthand(ResponseShorthand[datetime]): 49 | @staticmethod 50 | def response_adapter_factory(type: Any) -> ResponseAdapter: 51 | return lambda _: BaseResponse(None) 52 | 53 | @staticmethod 54 | def is_union_member(value: Any) -> bool: 55 | return False 56 | 57 | app = App().add_response_shorthand(DatetimeShorthand) 58 | 59 | @app.get("/") 60 | async def index() -> datetime: 61 | return datetime(2000, 1, 1, 0, 0, 0) -------------------------------------------------------------------------------- /tests/test_subapps.py: -------------------------------------------------------------------------------- 1 | from httpx import AsyncClient 2 | 3 | 4 | async def test_subapp(server): 5 | async with AsyncClient() as client: 6 | resp = await client.get(f"http://localhost:{server}/subapp") 7 | assert resp.status_code == 200 8 | assert resp.text == "subapp" 9 | 10 | resp = await client.get(f"http://localhost:{server}/subapp/subapp") 11 | assert resp.status_code == 200 12 | assert resp.text == "subapp" 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Keep docs in sync with docs env and .readthedocs.yml. 2 | [gh-actions] 3 | python = 4 | 3.10: py310 5 | 3.11: py311, docs 6 | 3.12: py312, lint 7 | 3.13: py313 8 | 9 | [tox] 10 | envlist = py310, py311, py312, py313, lint, docs 11 | isolated_build = True 12 | skipsdist = true 13 | 14 | [testenv:lint] 15 | basepython = python3.12 16 | allowlist_externals = 17 | make 18 | pdm 19 | commands = 20 | pdm install -G :all,lint,test 21 | pdm run make lint 22 | 23 | [testenv] 24 | setenv = 25 | PDM_IGNORE_SAVED_PYTHON="1" 26 | COVERAGE_PROCESS_START={toxinidir}/pyproject.toml 27 | commands_pre = 28 | pdm sync -G test 29 | python -c 'import pathlib; pathlib.Path("{env_site_packages_dir}/cov.pth").write_text("import coverage; coverage.process_startup()")' 30 | commands = 31 | pdm run coverage run -m pytest tests --mypy-only-local-stub {posargs:-n auto} 32 | allowlist_externals = pdm 33 | package = wheel 34 | wheel_build_env = .pkg 35 | 36 | [testenv:py312] 37 | setenv = 38 | PDM_IGNORE_SAVED_PYTHON="1" 39 | COVERAGE_PROCESS_START={toxinidir}/pyproject.toml 40 | COVERAGE_CORE=sysmon 41 | 42 | [testenv:docs] 43 | basepython = python3.11 44 | setenv = 45 | PYTHONHASHSEED = 0 46 | commands_pre = 47 | pdm sync -G :all,docs 48 | commands = 49 | make docs 50 | allowlist_externals = 51 | make 52 | pdm --------------------------------------------------------------------------------