mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
56 Commits
versioningit
...
v3.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 24958bff51 | |||
| 364f6b67f7 | |||
| 2cab7b5af7 | |||
| 1bfd85b003 | |||
| 33ebc77f10 | |||
| 30801557a3 | |||
| 73d46e9b03 | |||
| 3d65d88ea9 | |||
| 8f979719a0 | |||
| 0cbcaf9c4f | |||
| 3fa6f11ffa | |||
| 8b88b148bf | |||
| 1aecafa82a | |||
| 8c763aa97e | |||
| 91aa242a5a | |||
| 084d057a99 | |||
| d3acf2c1c1 | |||
| 80715a12ac | |||
| 66fc7afbe4 | |||
| e7776eb9e8 | |||
| 944d47da45 | |||
| a3a12cff77 | |||
| 7b2839086d | |||
| 351ff8d95e | |||
| 2278beba18 | |||
| 3cfc7ec2b6 | |||
| 0de22eeed2 | |||
| b0cc37861b | |||
| 7d4532acc9 | |||
| 1b63d2943a | |||
| b5723303c8 | |||
| 5730be4b31 | |||
| 6f9c11645a | |||
| 827cc64988 | |||
| 7b5db5bc33 | |||
| b9a03c7088 | |||
| 4cbf55508e | |||
| 83d0fcf1ae | |||
| a698eaaab3 | |||
| 3aa21eed08 | |||
| 2741c74b90 | |||
| aba96525ad | |||
| a5b6d36991 | |||
| e4cff76fa6 | |||
| f11ad7136d | |||
| c32e8c7468 | |||
| d93e3cd12c | |||
| 040f1a57e4 | |||
| 307313744f | |||
| 98ca45003b | |||
| ab76594297 | |||
| 7fba0f6362 | |||
| 4ff73e9d0c | |||
| 68bbea0a55 | |||
| 106e5e9073 | |||
| 3426aa71da |
@@ -0,0 +1,3 @@
|
||||
github: kennethreitz
|
||||
thanks_dev: kennethreitz
|
||||
custom: https://cash.app/$KennethReitz
|
||||
@@ -0,0 +1,42 @@
|
||||
name: "Documentation"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request: ~
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel redundant in-progress jobs.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
documentation:
|
||||
name: "Documentation"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_SYSTEM_PYTHON: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
|
||||
- name: Install package and documentation dependencies
|
||||
run: uv pip install '.[docs]'
|
||||
|
||||
- name: Build static HTML documentation
|
||||
run: sphinx-build -W --keep-going docs/source docs/build
|
||||
+28
-13
@@ -12,30 +12,45 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: "Python ${{ matrix.python-version }} on ${{ matrix.os }}"
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: "Python ${{ matrix.python-version }}"
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [
|
||||
"ubuntu-latest",
|
||||
"macos-12",
|
||||
"macos-latest",
|
||||
]
|
||||
python-version: [
|
||||
"3.9",
|
||||
"3.10",
|
||||
"3.11",
|
||||
"3.12",
|
||||
"3.13",
|
||||
"pypy3.10",
|
||||
]
|
||||
env:
|
||||
UV_SYSTEM_PYTHON: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: yezz123/setup-uv@v4
|
||||
- run: uv pip install --editable '.[graphql,develop,test]' --system
|
||||
- run: poe check
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
version: "latest"
|
||||
enable-cache: true
|
||||
cache-suffix: ${{ matrix.python-version }}
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
|
||||
- name: Install package
|
||||
run: uv pip install '.[develop,test]'
|
||||
|
||||
- name: Run tests
|
||||
run: pytest
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
.pytest_cache
|
||||
.DS_Store
|
||||
coverage.xml
|
||||
.coverage*
|
||||
|
||||
__pycache__
|
||||
tests/__pycache__
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# .readthedocs.yml
|
||||
# Read the Docs configuration file
|
||||
|
||||
# Details
|
||||
# - https://docs.readthedocs.io/en/stable/config-file/v2.html
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: "ubuntu-24.04"
|
||||
tools:
|
||||
python: "3.12"
|
||||
|
||||
python:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
||||
|
||||
sphinx:
|
||||
configuration: docs/source/conf.py
|
||||
|
||||
# Use standard HTML builder.
|
||||
builder: html
|
||||
|
||||
# Fail on all warnings to avoid broken references.
|
||||
fail_on_warning: true
|
||||
|
||||
# Optionally build your docs in additional formats such as PDF
|
||||
#formats:
|
||||
# - pdf
|
||||
+87
-46
@@ -7,6 +7,46 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v3.0.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
- Platform: Added support for Python 3.10 - Python 3.13
|
||||
- CLI: `responder run` now also accepts a filesystem path on its `<target>`
|
||||
argument, enabling usage on single-file applications.
|
||||
- CLI: `responder run` now also accepts URLs.
|
||||
|
||||
### Changed
|
||||
|
||||
- Platform: Minimum Python version is now 3.9 (dropped 3.6, 3.7, 3.8)
|
||||
- Dependencies: Dramatically reduced core dependency count (10 → 5)
|
||||
- Removed `requests`, `requests-toolbelt`, `rfc3986`, `whitenoise`
|
||||
- Moved `apispec` and `marshmallow` to `openapi` optional extra
|
||||
- Replaced `rfc3986` with stdlib `urllib.parse`
|
||||
- Replaced `requests-toolbelt` multipart decoder with `python-multipart`
|
||||
- Replaced deprecated `starlette.middleware.wsgi` with `a2wsgi`
|
||||
- Switched from WhiteNoise to ServeStatic
|
||||
- Dependencies: Pinned `starlette[full]>=0.40` (was unpinned)
|
||||
- GraphQL: Upgraded to `graphene>=3` and `graphql-core>=3.1`
|
||||
(from `graphene<3` and `graphql-server-core`, which is unmaintained)
|
||||
- GraphQL: Updated GraphiQL UI from 0.12.0 (2018) to 3.0.6 with React 18
|
||||
- Extensions: All of CLI-, GraphQL-, and OpenAPI-Support modules are
|
||||
extensions now, found within the `responder.ext` module namespace.
|
||||
- Packaging: Migrated from `setup.py` to declarative `pyproject.toml`
|
||||
|
||||
### Removed
|
||||
|
||||
- Platform: Removed support for EOL Python 3.6, 3.7, 3.8
|
||||
- Status codes: Removed deprecated `resume_incomplete` and `resume`
|
||||
aliases for HTTP 308 (marked for removal in 3.0)
|
||||
- CLI: `responder run --build` ceased to exist
|
||||
|
||||
### Fixed
|
||||
|
||||
- Routing: Fixed dispatching `static_route=None` on Windows
|
||||
- uvicorn: `--debug` now maps to uvicorn's `log_level = "debug"`
|
||||
- Tests: Fixed deprecated httpx TestClient usage
|
||||
|
||||
## [v2.0.5] - 2019-12-15
|
||||
|
||||
### Added
|
||||
@@ -333,49 +373,50 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
- Conception!
|
||||
|
||||
[unreleased]: https://github.com/taoufik07/responder/compare/v2.0.5..HEAD
|
||||
[v2.0.5]: https://github.com/taoufik07/responder/compare/v2.0.4..v2.0.5
|
||||
[v2.0.4]: https://github.com/taoufik07/responder/compare/v2.0.3..v2.0.4
|
||||
[v2.0.3]: https://github.com/taoufik07/responder/compare/v2.0.2..v2.0.3
|
||||
[v2.0.2]: https://github.com/taoufik07/responder/compare/v2.0.1..v2.0.2
|
||||
[v2.0.1]: https://github.com/taoufik07/responder/compare/v2.0.0..v2.0.1
|
||||
[v2.0.0]: https://github.com/taoufik07/responder/compare/v1.3.2..v2.0.0
|
||||
[v1.3.2]: https://github.com/taoufik07/responder/compare/v1.3.1..v1.3.2
|
||||
[v1.3.1]: https://github.com/taoufik07/responder/compare/v1.3.0..v1.3.1
|
||||
[v1.3.0]: https://github.com/taoufik07/responder/compare/v1.2.0..v1.3.0
|
||||
[v1.2.0]: https://github.com/taoufik07/responder/compare/v1.1.3..v1.2.0
|
||||
[v1.1.3]: https://github.com/taoufik07/responder/compare/v1.1.2..v1.1.3
|
||||
[v1.1.2]: https://github.com/taoufik07/responder/compare/v1.1.1..v1.1.2
|
||||
[v1.1.1]: https://github.com/taoufik07/responder/compare/v1.1.0..v1.1.1
|
||||
[v1.1.0]: https://github.com/taoufik07/responder/compare/v1.0.5..v1.1.0
|
||||
[v1.0.5]: https://github.com/taoufik07/responder/compare/v1.0.4..v1.0.5
|
||||
[v1.0.4]: https://github.com/taoufik07/responder/compare/v1.0.3..v1.0.4
|
||||
[v1.0.3]: https://github.com/taoufik07/responder/compare/v1.0.2..v1.0.3
|
||||
[v1.0.2]: https://github.com/taoufik07/responder/compare/v1.0.1..v1.0.2
|
||||
[v1.0.1]: https://github.com/taoufik07/responder/compare/v1.0.0..v1.0.1
|
||||
[v1.0.0]: https://github.com/taoufik07/responder/compare/v0.3.3..v1.0.0
|
||||
[v0.3.3]: https://github.com/taoufik07/responder/compare/v0.3.2..v0.3.3
|
||||
[v0.3.2]: https://github.com/taoufik07/responder/compare/v0.3.1..v0.3.2
|
||||
[v0.3.1]: https://github.com/taoufik07/responder/compare/v0.3.0..v0.3.1
|
||||
[v0.3.0]: https://github.com/taoufik07/responder/compare/v0.2.3..v0.3.0
|
||||
[v0.2.3]: https://github.com/taoufik07/responder/compare/v0.2.2..v0.2.3
|
||||
[v0.2.2]: https://github.com/taoufik07/responder/compare/v0.2.1..v0.2.2
|
||||
[v0.2.1]: https://github.com/taoufik07/responder/compare/v0.2.0..v0.2.1
|
||||
[v0.2.0]: https://github.com/taoufik07/responder/compare/v0.1.6..v0.2.0
|
||||
[v0.1.6]: https://github.com/taoufik07/responder/compare/v0.1.5..v0.1.6
|
||||
[v0.1.5]: https://github.com/taoufik07/responder/compare/v0.1.4..v0.1.5
|
||||
[v0.1.4]: https://github.com/taoufik07/responder/compare/v0.1.3..v0.1.4
|
||||
[v0.1.3]: https://github.com/taoufik07/responder/compare/v0.1.2..v0.1.3
|
||||
[v0.1.2]: https://github.com/taoufik07/responder/compare/v0.1.1..v0.1.2
|
||||
[v0.1.1]: https://github.com/taoufik07/responder/compare/v0.1.0..v0.1.1
|
||||
[v0.1.0]: https://github.com/taoufik07/responder/compare/v0.0.10..v0.1.0
|
||||
[v0.0.10]: https://github.com/taoufik07/responder/compare/v0.0.9..v0.0.10
|
||||
[v0.0.9]: https://github.com/taoufik07/responder/compare/v0.0.8..v0.0.9
|
||||
[v0.0.8]: https://github.com/taoufik07/responder/compare/v0.0.7..v0.0.8
|
||||
[v0.0.7]: https://github.com/taoufik07/responder/compare/v0.0.6..v0.0.7
|
||||
[v0.0.6]: https://github.com/taoufik07/responder/compare/v0.0.5..v0.0.6
|
||||
[v0.0.5]: https://github.com/taoufik07/responder/compare/v0.0.4..v0.0.5
|
||||
[v0.0.4]: https://github.com/taoufik07/responder/compare/v0.0.3..v0.0.4
|
||||
[v0.0.3]: https://github.com/taoufik07/responder/compare/v0.0.2..v0.0.3
|
||||
[v0.0.2]: https://github.com/taoufik07/responder/compare/v0.0.1..v0.0.2
|
||||
[v0.0.1]: https://github.com/taoufik07/responder/compare/v0.0.0..v0.0.1
|
||||
[unreleased]: https://github.com/kennethreitz/responder/compare/v3.0.0..HEAD
|
||||
[v3.0.0]: https://github.com/kennethreitz/responder/compare/v2.0.5..v3.0.0
|
||||
[v2.0.5]: https://github.com/kennethreitz/responder/compare/v2.0.4..v2.0.5
|
||||
[v2.0.4]: https://github.com/kennethreitz/responder/compare/v2.0.3..v2.0.4
|
||||
[v2.0.3]: https://github.com/kennethreitz/responder/compare/v2.0.2..v2.0.3
|
||||
[v2.0.2]: https://github.com/kennethreitz/responder/compare/v2.0.1..v2.0.2
|
||||
[v2.0.1]: https://github.com/kennethreitz/responder/compare/v2.0.0..v2.0.1
|
||||
[v2.0.0]: https://github.com/kennethreitz/responder/compare/v1.3.2..v2.0.0
|
||||
[v1.3.2]: https://github.com/kennethreitz/responder/compare/v1.3.1..v1.3.2
|
||||
[v1.3.1]: https://github.com/kennethreitz/responder/compare/v1.3.0..v1.3.1
|
||||
[v1.3.0]: https://github.com/kennethreitz/responder/compare/v1.2.0..v1.3.0
|
||||
[v1.2.0]: https://github.com/kennethreitz/responder/compare/v1.1.3..v1.2.0
|
||||
[v1.1.3]: https://github.com/kennethreitz/responder/compare/v1.1.2..v1.1.3
|
||||
[v1.1.2]: https://github.com/kennethreitz/responder/compare/v1.1.1..v1.1.2
|
||||
[v1.1.1]: https://github.com/kennethreitz/responder/compare/v1.1.0..v1.1.1
|
||||
[v1.1.0]: https://github.com/kennethreitz/responder/compare/v1.0.5..v1.1.0
|
||||
[v1.0.5]: https://github.com/kennethreitz/responder/compare/v1.0.4..v1.0.5
|
||||
[v1.0.4]: https://github.com/kennethreitz/responder/compare/v1.0.3..v1.0.4
|
||||
[v1.0.3]: https://github.com/kennethreitz/responder/compare/v1.0.2..v1.0.3
|
||||
[v1.0.2]: https://github.com/kennethreitz/responder/compare/v1.0.1..v1.0.2
|
||||
[v1.0.1]: https://github.com/kennethreitz/responder/compare/v1.0.0..v1.0.1
|
||||
[v1.0.0]: https://github.com/kennethreitz/responder/compare/v0.3.3..v1.0.0
|
||||
[v0.3.3]: https://github.com/kennethreitz/responder/compare/v0.3.2..v0.3.3
|
||||
[v0.3.2]: https://github.com/kennethreitz/responder/compare/v0.3.1..v0.3.2
|
||||
[v0.3.1]: https://github.com/kennethreitz/responder/compare/v0.3.0..v0.3.1
|
||||
[v0.3.0]: https://github.com/kennethreitz/responder/compare/v0.2.3..v0.3.0
|
||||
[v0.2.3]: https://github.com/kennethreitz/responder/compare/v0.2.2..v0.2.3
|
||||
[v0.2.2]: https://github.com/kennethreitz/responder/compare/v0.2.1..v0.2.2
|
||||
[v0.2.1]: https://github.com/kennethreitz/responder/compare/v0.2.0..v0.2.1
|
||||
[v0.2.0]: https://github.com/kennethreitz/responder/compare/v0.1.6..v0.2.0
|
||||
[v0.1.6]: https://github.com/kennethreitz/responder/compare/v0.1.5..v0.1.6
|
||||
[v0.1.5]: https://github.com/kennethreitz/responder/compare/v0.1.4..v0.1.5
|
||||
[v0.1.4]: https://github.com/kennethreitz/responder/compare/v0.1.3..v0.1.4
|
||||
[v0.1.3]: https://github.com/kennethreitz/responder/compare/v0.1.2..v0.1.3
|
||||
[v0.1.2]: https://github.com/kennethreitz/responder/compare/v0.1.1..v0.1.2
|
||||
[v0.1.1]: https://github.com/kennethreitz/responder/compare/v0.1.0..v0.1.1
|
||||
[v0.1.0]: https://github.com/kennethreitz/responder/compare/v0.0.10..v0.1.0
|
||||
[v0.0.10]: https://github.com/kennethreitz/responder/compare/v0.0.9..v0.0.10
|
||||
[v0.0.9]: https://github.com/kennethreitz/responder/compare/v0.0.8..v0.0.9
|
||||
[v0.0.8]: https://github.com/kennethreitz/responder/compare/v0.0.7..v0.0.8
|
||||
[v0.0.7]: https://github.com/kennethreitz/responder/compare/v0.0.6..v0.0.7
|
||||
[v0.0.6]: https://github.com/kennethreitz/responder/compare/v0.0.5..v0.0.6
|
||||
[v0.0.5]: https://github.com/kennethreitz/responder/compare/v0.0.4..v0.0.5
|
||||
[v0.0.4]: https://github.com/kennethreitz/responder/compare/v0.0.3..v0.0.4
|
||||
[v0.0.3]: https://github.com/kennethreitz/responder/compare/v0.0.2..v0.0.3
|
||||
[v0.0.2]: https://github.com/kennethreitz/responder/compare/v0.0.1..v0.0.2
|
||||
[v0.0.1]: https://github.com/kennethreitz/responder/compare/v0.0.0..v0.0.1
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
# Development Sandbox
|
||||
|
||||
Set up a development sandbox.
|
||||
|
||||
Acquire sources and install project in editable mode.
|
||||
```shell
|
||||
git clone https://github.com/kennethreitz/responder
|
||||
cd responder
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install --editable '.[graphql,develop,release,test]'
|
||||
```
|
||||
|
||||
Invoke linter and software tests.
|
||||
```shell
|
||||
poe check
|
||||
```
|
||||
|
||||
Format code.
|
||||
```shell
|
||||
poe format
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
include LICENSE
|
||||
@@ -1,91 +1,109 @@
|
||||
# Responder: a familiar HTTP Service Framework for Python
|
||||
# Responder
|
||||
|
||||
[](https://github.com/kennethreitz/responder/actions/workflows/test.yaml)
|
||||
[](https://responder.kennethreitz.org/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://github.com/kennethreitz/responder/graphs/contributors)
|
||||
[](https://pepy.tech/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
[](https://pypi.org/project/responder/)
|
||||
A familiar HTTP Service Framework for Python, powered by [Starlette](https://www.starlette.io/).
|
||||
|
||||
[](https://responder.readthedocs.io)
|
||||
```python
|
||||
import responder
|
||||
|
||||
Powered by [Starlette](https://www.starlette.io/). That `async` declaration is optional.
|
||||
[View documentation](https://responder.readthedocs.io).
|
||||
api = responder.API()
|
||||
|
||||
This gets you a ASGI app, with a production static files server pre-installed, jinja2
|
||||
templating (without additional imports), and a production webserver based on uvloop,
|
||||
serving up requests with gzip compression automatically.
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
## Testimonials
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
```
|
||||
|
||||
> "Pleasantly very taken with python-responder.
|
||||
> [@kennethreitz](https://twitter.com/kennethreitz) at his absolute best." —Rudraksh
|
||||
> M.K.
|
||||
$ pip install responder
|
||||
|
||||
> "ASGI is going to enable all sorts of new high-performance web services. It's awesome
|
||||
> to see Responder starting to take advantage of that." — Tom Christie author of
|
||||
> [Django REST Framework](https://www.django-rest-framework.org/)
|
||||
That's it. Supports Python 3.9+.
|
||||
|
||||
> "I love that you are exploring new patterns. Go go go!" — Danny Greenfield, author of
|
||||
> [Two Scoops of Django]()
|
||||
## The Basics
|
||||
|
||||
## More Examples
|
||||
- `resp.text` sends back text. `resp.html` sends back HTML. `resp.content` sends back bytes.
|
||||
- `resp.media` sends back JSON (or YAML, with content negotiation).
|
||||
- `resp.file("path.pdf")` serves a file with automatic content-type detection.
|
||||
- `req.headers` is case-insensitive. `req.params` gives you query parameters.
|
||||
- Both sync and async views work — the `async` is optional.
|
||||
|
||||
See
|
||||
[the documentation's feature tour](https://responder.readthedocs.io/en/latest/tour.html)
|
||||
for more details on features available in Responder.
|
||||
## Highlights
|
||||
|
||||
# Installing Responder
|
||||
```python
|
||||
# Type-safe route parameters
|
||||
@api.route("/users/{user_id:int}")
|
||||
async def get_user(req, resp, *, user_id):
|
||||
resp.media = {"id": user_id}
|
||||
|
||||
Install the most recent stable release:
|
||||
# HTTP method filtering
|
||||
@api.route("/items", methods=["POST"])
|
||||
async def create_item(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"created": data}
|
||||
|
||||
pip install --upgrade responder
|
||||
# Class-based views
|
||||
@api.route("/things/{id}")
|
||||
class ThingResource:
|
||||
def on_get(self, req, resp, *, id):
|
||||
resp.media = {"id": id}
|
||||
def on_post(self, req, resp, *, id):
|
||||
resp.text = "created"
|
||||
|
||||
Or, install directly from the repository:
|
||||
# Before-request hooks (auth, rate limiting, etc.)
|
||||
@api.route(before_request=True)
|
||||
def check_auth(req, resp):
|
||||
if not req.headers.get("Authorization"):
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "unauthorized"}
|
||||
|
||||
pip install 'responder @ git+https://github.com/kennethreitz/responder.git'
|
||||
# Custom error handling
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle_error(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
Only **Python 3.6+** is supported.
|
||||
# Lifespan events
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# The Basic Idea
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
print("starting up")
|
||||
yield
|
||||
print("shutting down")
|
||||
|
||||
The primary concept here is to bring the niceties that are brought forth from both Flask
|
||||
and Falcon and unify them into a single framework, along with some new ideas I have. I
|
||||
also wanted to take some of the API primitives that are instilled in the Requests
|
||||
library and put them into a web framework. So, you'll find a lot of parallels here with
|
||||
Requests.
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
- Setting `resp.content` sends back bytes.
|
||||
- Setting `resp.text` sends back unicode, while setting `resp.html` sends back HTML.
|
||||
- Setting `resp.media` sends back JSON/YAML (`.text`/`.html`/`.content` override this).
|
||||
- Case-insensitive `req.headers` dict (from Requests directly).
|
||||
- `resp.status_code`, `req.method`, `req.url`, and other familiar friends.
|
||||
# GraphQL
|
||||
import graphene
|
||||
api.graphql("/graphql", schema=graphene.Schema(query=Query))
|
||||
|
||||
## Ideas
|
||||
# WebSockets
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
while True:
|
||||
name = await ws.receive_text()
|
||||
await ws.send_text(f"Hello {name}!")
|
||||
|
||||
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s
|
||||
new f-string syntax.
|
||||
- I love Falcon's "every request and response is passed into to each view and mutated"
|
||||
methodology, especially `response.media`, and have used it here. In addition to
|
||||
supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly
|
||||
taking over the world, and it uses YAML for all the things. Content-negotiation and
|
||||
all that.
|
||||
- **A built in testing client that uses the actual Requests you know and love**.
|
||||
- The ability to mount other WSGI apps easily.
|
||||
- Automatic gzipped-responses.
|
||||
- In addition to Falcon's `on_get`, `on_post`, etc methods, Responder features an
|
||||
`on_request` method, which gets called on every type of request, much like Requests.
|
||||
- A production static file server is built-in.
|
||||
- Uvicorn built-in as a production web server. I would have chosen Gunicorn, but it
|
||||
doesn't run on Windows. Plus, Uvicorn serves well to protect against slowloris
|
||||
attacks, making nginx unnecessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at
|
||||
any route, magically.
|
||||
- Provide an official way to run webpack.
|
||||
# Mount WSGI/ASGI apps
|
||||
from flask import Flask
|
||||
flask_app = Flask(__name__)
|
||||
api.mount("/flask", flask_app)
|
||||
|
||||
## Development
|
||||
# Background tasks
|
||||
@api.route("/work")
|
||||
def do_work(req, resp):
|
||||
@api.background.task
|
||||
def process():
|
||||
import time; time.sleep(10)
|
||||
process()
|
||||
resp.media = {"status": "processing"}
|
||||
```
|
||||
|
||||
See [Development Sandbox](DEVELOP.md).
|
||||
Built-in OpenAPI docs, cookie-based sessions, gzip compression, static file serving, Jinja2 templates, and a production uvicorn server.
|
||||
|
||||
Route convertors: `str`, `int`, `float`, `uuid`, `path`.
|
||||
|
||||
## Documentation
|
||||
|
||||
https://responder.kennethreitz.org
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
alabaster<0.8
|
||||
jinja2<3.2
|
||||
markupsafe<4
|
||||
readme-renderer<45
|
||||
sphinx>=5,<9
|
||||
sphinxcontrib-websupport<2.1
|
||||
@@ -1,7 +0,0 @@
|
||||
/* Hide module name and default value for environment variable section */
|
||||
div[id$="environment-variables"] code.descclassname {
|
||||
display: none;
|
||||
}
|
||||
div[id$="environment-variables"] em.property {
|
||||
display: none;
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
|
||||
|
||||
/*
|
||||
Copyright (C) 2011-2018 Hoefler & Co.
|
||||
This software is the property of Hoefler & Co. (H&Co).
|
||||
Your right to access and use this software is subject to the
|
||||
applicable License Agreement, or Terms of Service, that exists
|
||||
between you and H&Co. If no such agreement exists, you may not
|
||||
access or use this software for any purpose.
|
||||
This software may only be hosted at the locations specified in
|
||||
the applicable License Agreement or Terms of Service, and only
|
||||
for the purposes expressly set forth therein. You may not copy,
|
||||
modify, convert, create derivative works from or distribute this
|
||||
software in any way, or make it accessible to any third party,
|
||||
without first obtaining the written permission of H&Co.
|
||||
For more information, please visit us at http://typography.com.
|
||||
148887-130097-20181011
|
||||
*/
|
||||
|
||||
<!-- sorry your browser is not supported. -->
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1,177 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2011-2018 Hoefler & Co.
|
||||
This software is the property of Hoefler & Co. (H&Co).
|
||||
Your right to access and use this software is subject to the
|
||||
applicable License Agreement, or Terms of Service, that exists
|
||||
between you and H&Co. If no such agreement exists, you may not
|
||||
access or use this software for any purpose.
|
||||
This software may only be hosted at the locations specified in
|
||||
the applicable License Agreement or Terms of Service, and only
|
||||
for the purposes expressly set forth therein. You may not copy,
|
||||
modify, convert, create derivative works from or distribute this
|
||||
software in any way, or make it accessible to any third party,
|
||||
without first obtaining the written permission of H&Co.
|
||||
For more information, please visit us at http://typography.com.
|
||||
148887-130097-20181011
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/AA83D0999C9464BC6.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/AA83D0999C9464BC6.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 4r";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/AA83D0999C9464BC6.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/AA83D0999C9464BC6.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/005CED86771E6F899.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/005CED86771E6F899.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 4i";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/005CED86771E6F899.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/005CED86771E6F899.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/40351B0A9DF3B9622.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/40351B0A9DF3B9622.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 6r";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/40351B0A9DF3B9622.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/40351B0A9DF3B9622.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/3A39878E22934F8AD.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/3A39878E22934F8AD.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 6i";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/3A39878E22934F8AD.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/3A39878E22934F8AD.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/DD7AD6D5FDE05ABCA.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/DD7AD6D5FDE05ABCA.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 7r";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/DD7AD6D5FDE05ABCA.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/DD7AD6D5FDE05ABCA.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/777143010DB6642D4.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/777143010DB6642D4.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 7i";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/777143010DB6642D4.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/777143010DB6642D4.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/4A801A74B6CEC6B76.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/4A801A74B6CEC6B76.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm 4r";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/4A801A74B6CEC6B76.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/4A801A74B6CEC6B76.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7E9ADDBCA2C8BD433.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7E9ADDBCA2C8BD433.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm 4i";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7E9ADDBCA2C8BD433.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7E9ADDBCA2C8BD433.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/13B223000FA5C8685.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/13B223000FA5C8685.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm 7r";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/13B223000FA5C8685.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/13B223000FA5C8685.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/0AC552691F872135E.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/0AC552691F872135E.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm 7i";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/0AC552691F872135E.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/0AC552691F872135E.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -1,177 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2011-2018 Hoefler & Co.
|
||||
This software is the property of Hoefler & Co. (H&Co).
|
||||
Your right to access and use this software is subject to the
|
||||
applicable License Agreement, or Terms of Service, that exists
|
||||
between you and H&Co. If no such agreement exists, you may not
|
||||
access or use this software for any purpose.
|
||||
This software may only be hosted at the locations specified in
|
||||
the applicable License Agreement or Terms of Service, and only
|
||||
for the purposes expressly set forth therein. You may not copy,
|
||||
modify, convert, create derivative works from or distribute this
|
||||
software in any way, or make it accessible to any third party,
|
||||
without first obtaining the written permission of H&Co.
|
||||
For more information, please visit us at http://typography.com.
|
||||
148887-130097-20181011
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7D0605C11BA3A93EF.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7D0605C11BA3A93EF.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 4r";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7D0605C11BA3A93EF.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7D0605C11BA3A93EF.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/81A13EBFC10447CAC.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/81A13EBFC10447CAC.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 4i";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/81A13EBFC10447CAC.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/81A13EBFC10447CAC.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/25E3F5D50DDE1C555.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/25E3F5D50DDE1C555.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 6r";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/25E3F5D50DDE1C555.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/25E3F5D50DDE1C555.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/F526A6C670B9765E2.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/F526A6C670B9765E2.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 6i";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/F526A6C670B9765E2.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/F526A6C670B9765E2.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7DCD4B5CCAEE3223E.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7DCD4B5CCAEE3223E.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 7r";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7DCD4B5CCAEE3223E.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/7DCD4B5CCAEE3223E.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/C08332ABD7F145352.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/C08332ABD7F145352.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Mercury Text G1 7i";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/C08332ABD7F145352.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/C08332ABD7F145352.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/C579DF5B35B145D49.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/C579DF5B35B145D49.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm 4r";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/C579DF5B35B145D49.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/C579DF5B35B145D49.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/E3597D43523236FD8.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/E3597D43523236FD8.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm 4i";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/E3597D43523236FD8.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/E3597D43523236FD8.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/3CBFC66855DD9B6EA.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/3CBFC66855DD9B6EA.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm 7r";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/3CBFC66855DD9B6EA.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/3CBFC66855DD9B6EA.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm A";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/09E151FC31ECEF374.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/09E151FC31ECEF374.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Operator Mono SSm 7i";
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/09E151FC31ECEF374.eot");
|
||||
src: url("http://python-responder.org/en/latest/_static/fonts/692185/09E151FC31ECEF374.eot?#hco")
|
||||
format("embedded-opentype");
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,19 +0,0 @@
|
||||
|
||||
/*
|
||||
Copyright (C) 2011-2018 Hoefler & Co.
|
||||
This software is the property of Hoefler & Co. (H&Co).
|
||||
Your right to access and use this software is subject to the
|
||||
applicable License Agreement, or Terms of Service, that exists
|
||||
between you and H&Co. If no such agreement exists, you may not
|
||||
access or use this software for any purpose.
|
||||
This software may only be hosted at the locations specified in
|
||||
the applicable License Agreement or Terms of Service, and only
|
||||
for the purposes expressly set forth therein. You may not copy,
|
||||
modify, convert, create derivative works from or distribute this
|
||||
software in any way, or make it accessible to any third party,
|
||||
without first obtaining the written permission of H&Co.
|
||||
For more information, please visit us at http://typography.com.
|
||||
148887-130097-20181011
|
||||
*/
|
||||
|
||||
<!-- sorry your browser is not supported. -->
|
||||
File diff suppressed because one or more lines are too long
@@ -1,161 +0,0 @@
|
||||
/*
|
||||
* Konami-JS ~
|
||||
* :: Now with support for touch events and multiple instances for
|
||||
* :: those situations that call for multiple easter eggs!
|
||||
* Code: https://github.com/snaptortoise/konami-js
|
||||
* Copyright (c) 2009 George Mandis (georgemandis.com, snaptortoise.com)
|
||||
* Version: 1.6.2 (7/17/2018)
|
||||
* Licensed under the MIT License (http://opensource.org/licenses/MIT)
|
||||
* Tested in: Safari 4+, Google Chrome 4+, Firefox 3+, IE7+, Mobile Safari 2.2.1+ and Android
|
||||
*/
|
||||
|
||||
var Konami = function (callback) {
|
||||
var konami = {
|
||||
addEvent: function (obj, type, fn, ref_obj) {
|
||||
if (obj.addEventListener) obj.addEventListener(type, fn, false);
|
||||
else if (obj.attachEvent) {
|
||||
// IE
|
||||
obj["e" + type + fn] = fn;
|
||||
obj[type + fn] = function () {
|
||||
obj["e" + type + fn](window.event, ref_obj);
|
||||
};
|
||||
obj.attachEvent("on" + type, obj[type + fn]);
|
||||
}
|
||||
},
|
||||
removeEvent: function (obj, eventName, eventCallback) {
|
||||
if (obj.removeEventListener) {
|
||||
obj.removeEventListener(eventName, eventCallback);
|
||||
} else if (obj.attachEvent) {
|
||||
obj.detachEvent(eventName);
|
||||
}
|
||||
},
|
||||
input: "",
|
||||
pattern: "38384040373937396665",
|
||||
keydownHandler: function (e, ref_obj) {
|
||||
if (ref_obj) {
|
||||
konami = ref_obj;
|
||||
} // IE
|
||||
konami.input += e ? e.keyCode : event.keyCode;
|
||||
if (konami.input.length > konami.pattern.length) {
|
||||
konami.input = konami.input.substr(konami.input.length - konami.pattern.length);
|
||||
}
|
||||
if (konami.input === konami.pattern) {
|
||||
konami.code(konami._currentLink);
|
||||
konami.input = "";
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
},
|
||||
load: function (link) {
|
||||
this._currentLink = link;
|
||||
this.addEvent(document, "keydown", this.keydownHandler, this);
|
||||
this.iphone.load(link);
|
||||
},
|
||||
unload: function () {
|
||||
this.removeEvent(document, "keydown", this.keydownHandler);
|
||||
this.iphone.unload();
|
||||
},
|
||||
code: function (link) {
|
||||
window.location = link;
|
||||
},
|
||||
iphone: {
|
||||
start_x: 0,
|
||||
start_y: 0,
|
||||
stop_x: 0,
|
||||
stop_y: 0,
|
||||
tap: false,
|
||||
capture: false,
|
||||
orig_keys: "",
|
||||
keys: [
|
||||
"UP",
|
||||
"UP",
|
||||
"DOWN",
|
||||
"DOWN",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"TAP",
|
||||
"TAP",
|
||||
],
|
||||
input: [],
|
||||
code: function (link) {
|
||||
konami.code(link);
|
||||
},
|
||||
touchmoveHandler: function (e) {
|
||||
if (e.touches.length === 1 && konami.iphone.capture === true) {
|
||||
var touch = e.touches[0];
|
||||
konami.iphone.stop_x = touch.pageX;
|
||||
konami.iphone.stop_y = touch.pageY;
|
||||
konami.iphone.tap = false;
|
||||
konami.iphone.capture = false;
|
||||
konami.iphone.check_direction();
|
||||
}
|
||||
},
|
||||
touchendHandler: function () {
|
||||
konami.iphone.input.push(konami.iphone.check_direction());
|
||||
|
||||
if (konami.iphone.input.length > konami.iphone.keys.length)
|
||||
konami.iphone.input.shift();
|
||||
|
||||
if (konami.iphone.input.length === konami.iphone.keys.length) {
|
||||
var match = true;
|
||||
for (var i = 0; i < konami.iphone.keys.length; i++) {
|
||||
if (konami.iphone.input[i] !== konami.iphone.keys[i]) {
|
||||
match = false;
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
konami.iphone.code(konami._currentLink);
|
||||
}
|
||||
}
|
||||
},
|
||||
touchstartHandler: function (e) {
|
||||
konami.iphone.start_x = e.changedTouches[0].pageX;
|
||||
konami.iphone.start_y = e.changedTouches[0].pageY;
|
||||
konami.iphone.tap = true;
|
||||
konami.iphone.capture = true;
|
||||
},
|
||||
load: function (link) {
|
||||
this.orig_keys = this.keys;
|
||||
konami.addEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.addEvent(document, "touchend", this.touchendHandler, false);
|
||||
konami.addEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
unload: function () {
|
||||
konami.removeEvent(document, "touchmove", this.touchmoveHandler);
|
||||
konami.removeEvent(document, "touchend", this.touchendHandler);
|
||||
konami.removeEvent(document, "touchstart", this.touchstartHandler);
|
||||
},
|
||||
check_direction: function () {
|
||||
x_magnitude = Math.abs(this.start_x - this.stop_x);
|
||||
y_magnitude = Math.abs(this.start_y - this.stop_y);
|
||||
x = this.start_x - this.stop_x < 0 ? "RIGHT" : "LEFT";
|
||||
y = this.start_y - this.stop_y < 0 ? "DOWN" : "UP";
|
||||
result = x_magnitude > y_magnitude ? x : y;
|
||||
result = this.tap === true ? "TAP" : result;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
typeof callback === "string" && konami.load(callback);
|
||||
if (typeof callback === "function") {
|
||||
konami.code = callback;
|
||||
konami.load();
|
||||
}
|
||||
|
||||
return konami;
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && typeof module.exports !== "undefined") {
|
||||
module.exports = Konami;
|
||||
} else {
|
||||
if (typeof define === "function" && define.amd) {
|
||||
define([], function () {
|
||||
return Konami;
|
||||
});
|
||||
} else {
|
||||
window.Konami = Konami;
|
||||
}
|
||||
}
|
||||
@@ -1,240 +1,21 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="https://cloud.typography.com/7584432/7586812/css/fonts.css"
|
||||
/>
|
||||
<script type="text/javascript">
|
||||
$("#searchbox").hide(0);
|
||||
</script>
|
||||
<!--Alabaster (krTheme++) Hacks -->
|
||||
|
||||
<!-- CSS Adjustments (I'm very picky.) -->
|
||||
<style type="text/css">
|
||||
/* Rezzy requires precise alignment. */
|
||||
img.logo {
|
||||
margin-left: -20px !important;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: "Mercury Text G1 A", "Mercury Text G1 B" !important;
|
||||
font-style: normal !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.section {
|
||||
font-family: "Mercury Text G1 A", "Mercury Text G1 B" !important;
|
||||
font-style: normal !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
pre,
|
||||
.pre,
|
||||
.class em,
|
||||
.descname,
|
||||
.method em {
|
||||
font-family: "Operator Mono SSm A", "Operator Mono SSm B", monospace !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.property {
|
||||
color: lightgrey !important;
|
||||
}
|
||||
|
||||
.method .descname {
|
||||
color: #220a54;
|
||||
}
|
||||
|
||||
.method {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.si,
|
||||
.s2,
|
||||
.s1,
|
||||
.method em,
|
||||
.class em {
|
||||
font-style: italic !important;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.method em,
|
||||
.class em {
|
||||
margin-left: 0.3em;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
|
||||
.method p,
|
||||
.class p {
|
||||
font-family: "Mercury Text G1 A", "Mercury Text G1 B";
|
||||
font-style: italic !important;
|
||||
font-weight: 400 !important;
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
.method p:first,
|
||||
.class p:first {
|
||||
background: #fffcbf;
|
||||
}
|
||||
|
||||
.class .property {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#testimonials p.attribution {
|
||||
margin-top: -1em;
|
||||
}
|
||||
|
||||
/* "Quick Search" should be not be shown for now. */
|
||||
div#searchbox h3 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Make the document a little wider, less code is cut-off. */
|
||||
/* Make the document a little wider. */
|
||||
div.document {
|
||||
width: 1008px;
|
||||
}
|
||||
|
||||
/* Much-improved spacing around code blocks. */
|
||||
/* Better spacing around code blocks. */
|
||||
div.highlight pre {
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
/* Remain Responsive! */
|
||||
/* Responsive layout. */
|
||||
@media screen and (max-width: 1008px) {
|
||||
div.sphinxsidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.document {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Have code blocks escape the document right-margin. */
|
||||
div.highlight pre {
|
||||
margin-right: -30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Analytics tracking for Kenneth. -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-127383416-1"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "UA-127383416-1");
|
||||
</script>
|
||||
|
||||
<!-- There are no more hacks. -->
|
||||
<!-- இڿڰۣ-ڰۣ— -->
|
||||
<!-- Love, Kenneth Reitz -->
|
||||
|
||||
<script src="{{ pathto('_static/', 1) }}/konami.js"></script>
|
||||
<script>
|
||||
var easter_egg = new Konami(
|
||||
"https://www.myfortunecookie.co.uk/fortunes/" +
|
||||
(Math.floor(Math.random() * 152) + 1)
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.injected {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- GitHub Logo -->
|
||||
<a
|
||||
href="https://github.com/kennethreitz/responder"
|
||||
class="github-corner"
|
||||
aria-label="View source on GitHub"
|
||||
>
|
||||
<svg
|
||||
width="80"
|
||||
height="80"
|
||||
viewBox="0 0 250 250"
|
||||
style="fill: #151513; color: #fff; position: absolute; top: 0; border: 0; right: 0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
|
||||
<path
|
||||
d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
|
||||
fill="currentColor"
|
||||
style="transform-origin: 130px 106px"
|
||||
class="octo-arm"
|
||||
></path>
|
||||
<path
|
||||
d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
|
||||
fill="currentColor"
|
||||
class="octo-body"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<style>
|
||||
.github-corner:hover .octo-arm {
|
||||
animation: octocat-wave 560ms ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes octocat-wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
20%,
|
||||
60% {
|
||||
transform: rotate(-25deg);
|
||||
}
|
||||
|
||||
40%,
|
||||
80% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.github-corner:hover .octo-arm {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.github-corner .octo-arm {
|
||||
animation: octocat-wave 560ms ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- That was not a hack. That was art.
|
||||
|
||||
<!-- UserVoice JavaScript SDK (only needed once on a page) -->
|
||||
<script>
|
||||
(function () {
|
||||
var uv = document.createElement("script");
|
||||
uv.type = "text/javascript";
|
||||
uv.async = true;
|
||||
uv.src = "//widget.uservoice.com/f4AQraEfwInlMzkexfRLg.js";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(uv, s);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- A tab to launch the Classic Widget -->
|
||||
<script>
|
||||
UserVoice = window.UserVoice || [];
|
||||
UserVoice.push([
|
||||
"showTab",
|
||||
"classic_widget",
|
||||
{
|
||||
mode: "feedback",
|
||||
primary_color: "#fa8c28",
|
||||
link_color: "#0a8cc6",
|
||||
forum_id: 913660,
|
||||
tab_label: "Got feedback?",
|
||||
tab_color: "#00994f",
|
||||
tab_position: "bottom-left",
|
||||
tab_inverted: true,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -1,94 +1,14 @@
|
||||
<p class="logo">
|
||||
<a href="{{ pathto(master_doc) }}">
|
||||
<img
|
||||
class="logo"
|
||||
src="{{ pathto('_static/responder.png', 1) }}"
|
||||
title="https://kennethreitz.org/tattoos"
|
||||
/>
|
||||
<img class="logo" src="{{ pathto('_static/responder.png', 1) }}" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200px"
|
||||
height="35px"
|
||||
></iframe>
|
||||
<strong>Responder</strong> — a familiar HTTP service framework for Python.
|
||||
</p>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css"
|
||||
/>
|
||||
<style>
|
||||
.algolia-autocomplete {
|
||||
width: 100%;
|
||||
height: 1.5em;
|
||||
}
|
||||
.algolia-autocomplete a {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
#doc_search {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<input id="doc_search" placeholder="Search the doc" autofocus />
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js"
|
||||
onload="docsearch({
|
||||
apiKey: 'ac965312db252e0496283c75c6f76f0b',
|
||||
indexName: 'python-responder',
|
||||
inputSelector: '#doc_search',
|
||||
debug: false // Set debug to true if you want to inspect the dropdown
|
||||
})"
|
||||
async
|
||||
></script>
|
||||
|
||||
<p><strong>Responder</strong> is a web service framework, written for human beings.</p>
|
||||
|
||||
<h3>Stay Informed</h3>
|
||||
<p>Receive updates on new releases and upcoming projects.</p>
|
||||
|
||||
<p>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200"
|
||||
height="20"
|
||||
></iframe>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://twitter.com/kennethreitz"
|
||||
class="twitter-follow-button"
|
||||
data-show-count="false"
|
||||
>Follow @kennethreitz</a
|
||||
>
|
||||
<script>
|
||||
!(function (d, s, id) {
|
||||
var js,
|
||||
fjs = d.getElementsByTagName(s)[0],
|
||||
p = /^http:/.test(d.location) ? "http" : "https";
|
||||
if (!d.getElementById(id)) {
|
||||
js = d.createElement(s);
|
||||
js.id = id;
|
||||
js.src = p + "://platform.twitter.com/widgets.js";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}
|
||||
})(document, "script", "twitter-wjs");
|
||||
</script>
|
||||
</p>
|
||||
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
<li><a href="http://github.com/kennethreitz/responder">Responder @ GitHub</a></li>
|
||||
<li><a href="http://pypi.python.org/pypi/responder">Responder @ PyPI</a></li>
|
||||
<li><a href="http://github.com/kennethreitz/responder/issues">Issue Tracker</a></li>
|
||||
<li><a href="https://github.com/kennethreitz/responder">Responder @ GitHub</a></li>
|
||||
<li><a href="https://pypi.org/project/responder/">Responder @ PyPI</a></li>
|
||||
<li><a href="https://github.com/kennethreitz/responder/issues">Issue Tracker</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<p class="logo">
|
||||
<a href="{{ pathto(master_doc) }}">
|
||||
<img
|
||||
class="logo"
|
||||
src="{{ pathto('_static/responder.png', 1) }}"
|
||||
title="https://kennethreitz.org/tattoos"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&repo=responder&type=watch&count=true&size=large"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200px"
|
||||
height="35px"
|
||||
></iframe>
|
||||
</p>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.css"
|
||||
/>
|
||||
<style>
|
||||
.algolia-autocomplete {
|
||||
width: 100%;
|
||||
height: 1.5em;
|
||||
}
|
||||
.algolia-autocomplete a {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
#doc_search {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<input id="doc_search" placeholder="Search the doc" autofocus />
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.jsdelivr.net/npm/docsearch.js@2/dist/cdn/docsearch.min.js"
|
||||
onload="docsearch({
|
||||
apiKey: 'ac965312db252e0496283c75c6f76f0b',
|
||||
indexName: 'python-responder',
|
||||
inputSelector: '#doc_search',
|
||||
debug: false // Set debug to true if you want to inspect the dropdown
|
||||
})"
|
||||
async
|
||||
></script>
|
||||
|
||||
<p><strong>Responder</strong> is a web service framework, written for human beings.</p>
|
||||
|
||||
<h3>Stay Informed</h3>
|
||||
<p>Receive updates on new releases and upcoming projects.</p>
|
||||
|
||||
<p>
|
||||
<iframe
|
||||
src="https://ghbtns.com/github-btn.html?user=kennethreitz&type=follow&count=true"
|
||||
allowtransparency="true"
|
||||
frameborder="0"
|
||||
scrolling="0"
|
||||
width="200"
|
||||
height="20"
|
||||
></iframe>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://twitter.com/kennethreitz"
|
||||
class="twitter-follow-button"
|
||||
data-show-count="false"
|
||||
>Follow @kennethreitz</a
|
||||
>
|
||||
<script>
|
||||
!(function (d, s, id) {
|
||||
var js,
|
||||
fjs = d.getElementsByTagName(s)[0],
|
||||
p = /^http:/.test(d.location) ? "http" : "https";
|
||||
if (!d.getElementById(id)) {
|
||||
js = d.createElement(s);
|
||||
js.id = id;
|
||||
js.src = p + "://platform.twitter.com/widgets.js";
|
||||
fjs.parentNode.insertBefore(js, fjs);
|
||||
}
|
||||
})(document, "script", "twitter-wjs");
|
||||
</script>
|
||||
</p>
|
||||
|
||||
<h3>Useful Links</h3>
|
||||
<ul>
|
||||
<li><a href="http://github.com/kennethreitz/responder">Responder @ GitHub</a></li>
|
||||
<li><a href="http://pypi.python.org/pypi/responder">Responder @ PyPI</a></li>
|
||||
<li><a href="http://github.com/kennethreitz/responder/issues">Issue Tracker</a></li>
|
||||
</ul>
|
||||
@@ -0,0 +1,7 @@
|
||||
# Backlog
|
||||
|
||||
## Future Ideas
|
||||
- Consider adding `after_request` hooks (complement to `before_request`)
|
||||
- Explore WebSocket before_request short-circuit support
|
||||
- Add rate limiting middleware
|
||||
- Consider async template rendering by default
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../CHANGELOG.md
|
||||
@@ -0,0 +1,174 @@
|
||||
Responder CLI
|
||||
=============
|
||||
|
||||
Responder installs a command line program ``responder``. Use it to launch
|
||||
a Responder application from a file or module, either located on a local
|
||||
or remote filesystem, or object store.
|
||||
|
||||
Launch Module Entrypoint
|
||||
------------------------
|
||||
|
||||
For loading a Responder application from a Python module, you will refer to
|
||||
its ``API()`` instance using a `Python entry point object reference`_ that
|
||||
points to a Python object. It is either in the form ``importable.module``,
|
||||
or ``importable.module:object.attr``.
|
||||
|
||||
A basic invocation command to launch a Responder application:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run acme.app
|
||||
|
||||
The command above assumes a Python package ``acme`` including an ``app``
|
||||
module ``acme/app.py`` that includes an attribute ``api`` that refers
|
||||
to a ``responder.API`` instance, reflecting the typical layout of
|
||||
a standard Responder application.
|
||||
|
||||
Loading a Responder application using an entrypoint specification will
|
||||
inherit the capacities of `Python's import system`_, as implemented by
|
||||
`importlib`_.
|
||||
|
||||
Launch Local File
|
||||
-----------------
|
||||
|
||||
Acquire a minimal example single-file application, ``helloworld.py`` [1]_,
|
||||
to your local filesystem, giving you the chance to edit it, and launch the
|
||||
Responder HTTP service.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
wget https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
|
||||
responder run helloworld.py
|
||||
|
||||
.. note::
|
||||
|
||||
To validate the example application, invoke a HTTP request, for example using
|
||||
`curl`_, `HTTPie`_, or your favourite browser at hand.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
http http://127.0.0.1:5042/Hello
|
||||
|
||||
The response is no surprise.
|
||||
|
||||
::
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
content-length: 13
|
||||
content-type: text/plain
|
||||
date: Sat, 26 Oct 2024 13:16:55 GMT
|
||||
encoding: utf-8
|
||||
server: uvicorn
|
||||
|
||||
Hello, world!
|
||||
|
||||
.. [1] The Responder application `helloworld.py`_ implements a basic echo handler.
|
||||
|
||||
Launch Remote File
|
||||
------------------
|
||||
|
||||
You can also launch a single-file application where its Python file is stored
|
||||
on a remote location.
|
||||
|
||||
Responder supports all filesystem adapters compatible with `fsspec`_, and
|
||||
installs the adapters for Azure Blob Storage (az), Google Cloud Storage (gs),
|
||||
GitHub, HTTP, and AWS S3 by default.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# Works 1:1.
|
||||
responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
|
||||
responder run github://kennethreitz:responder@/examples/helloworld.py
|
||||
|
||||
If you need access other kinds of remote targets, see the `list of
|
||||
fsspec-supported filesystems and protocols`_. The next section enumerates
|
||||
a few synthetic examples. The corresponding storage buckets do not even
|
||||
exist, so don't expect those commands to work.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
# Azure Blob Storage, Google Cloud Storage, and AWS S3.
|
||||
responder run az://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run gs://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run s3://kennethreitz-assets/responder/examples/helloworld.py
|
||||
|
||||
# Hadoop Distributed File System (hdfs), SSH File Transfer Protocol (sftp),
|
||||
# Common Internet File System (smb), Web-based Distributed Authoring and
|
||||
# Versioning (webdav).
|
||||
responder run hdfs://kennethreitz-assets/responder/examples/helloworld.py
|
||||
responder run sftp://user@host/kennethreitz/responder/examples/helloworld.py
|
||||
responder run smb://workgroup;user:password@server:port/responder/examples/helloworld.py
|
||||
responder run webdav+https://user:password@server:port/responder/examples/helloworld.py
|
||||
|
||||
.. tip::
|
||||
|
||||
In order to install support for all filesystem types supported by fsspec, run:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
uv pip install 'fsspec[full]'
|
||||
|
||||
When using ``uv``, this concludes within an acceptable time of approx.
|
||||
25 seconds. If you need to be more selectively instead of using ``full``,
|
||||
choose from one or multiple of the available `fsspec extras`_, which are:
|
||||
|
||||
abfs, arrow, dask, dropbox, fuse, gcs, git, github, hdfs, http, oci, s3,
|
||||
sftp, smb, ssh.
|
||||
|
||||
Launch with Non-Standard Instance Name
|
||||
--------------------------------------
|
||||
|
||||
By default, Responder will acquire an ``responder.API`` instance using the
|
||||
symbol name ``api`` from the specified Python module.
|
||||
|
||||
If your main application file uses a different name than ``api``, please
|
||||
append the designated symbol name to the launch target address.
|
||||
|
||||
It works like this for module entrypoints and local files:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run acme.app:service
|
||||
responder run /path/to/acme/app.py:service
|
||||
|
||||
It works like this for URLs:
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder run http://app.server.local/path/to/acme/app.py#service
|
||||
|
||||
Within your ``app.py``, the instance would have been defined to use
|
||||
the ``service`` symbol name instead of ``api``, like this:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
service = responder.API()
|
||||
|
||||
Build JavaScript Application
|
||||
----------------------------
|
||||
|
||||
The ``build`` subcommand invokes ``npm run build``, optionally accepting
|
||||
a target directory. By default, it uses the current working directory,
|
||||
where it expects a regular NPM ``package.json`` file.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder build
|
||||
|
||||
When specifying a target directory, Responder will change to that
|
||||
directory beforehand.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
responder build /path/to/project
|
||||
|
||||
|
||||
.. _curl: https://curl.se/
|
||||
.. _fsspec: https://filesystem-spec.readthedocs.io/en/latest/
|
||||
.. _fsspec extras: https://github.com/fsspec/filesystem_spec/blob/2024.12.0/pyproject.toml#L27-L69
|
||||
.. _helloworld.py: https://github.com/kennethreitz/responder/blob/main/examples/helloworld.py
|
||||
.. _HTTPie: https://httpie.io/docs/cli
|
||||
.. _importlib: https://docs.python.org/3/library/importlib.html
|
||||
.. _list of fsspec-supported filesystems and protocols: https://github.com/fsspec/universal_pathlib#currently-supported-filesystems-and-protocols
|
||||
.. _Python entry point object reference: https://packaging.python.org/en/latest/specifications/entry-points/
|
||||
.. _Python's import system: https://docs.python.org/3/reference/import.html
|
||||
+19
-189
@@ -1,103 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# This file does only contain a selection of the most common options. For a
|
||||
# full list see the documentation:
|
||||
# http://www.sphinx-doc.org/en/master/config
|
||||
# Sphinx configuration for Responder documentation.
|
||||
|
||||
# -- Path setup --------------------------------------------------------------
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
# import os
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "responder"
|
||||
copyright = "2018, A Kenneth Reitz project"
|
||||
author = "Kenneth Reitz"
|
||||
|
||||
# The short X.Y version
|
||||
import os
|
||||
|
||||
# Path hackery to get current version number.
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
project = "responder"
|
||||
copyright = "2018-2026, Kenneth Reitz"
|
||||
author = "Kenneth Reitz"
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
about = {}
|
||||
with open(os.path.join(here, "..", "..", "responder", "__version__.py")) as f:
|
||||
exec(f.read(), about)
|
||||
|
||||
version = about["__version__"]
|
||||
# The full version, including alpha/beta/rc tags
|
||||
release = about["__version__"]
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
#
|
||||
# needs_sphinx = '1.0'
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.doctest",
|
||||
"sphinx.ext.intersphinx",
|
||||
"sphinx.ext.todo",
|
||||
"sphinx.ext.coverage",
|
||||
"sphinx.ext.mathjax",
|
||||
"sphinx.ext.ifconfig",
|
||||
"sphinx.ext.viewcode",
|
||||
"sphinx.ext.githubpages",
|
||||
"myst_parser",
|
||||
"sphinx_copybutton",
|
||||
"sphinx_design_elements",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ["_templates"]
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# You can specify multiple suffix as a list of string:
|
||||
#
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = ".rst"
|
||||
|
||||
# The master toctree document.
|
||||
source_suffix = {".rst": "restructuredtext"}
|
||||
master_doc = "index"
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = "en"
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = []
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = None
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
# Theme
|
||||
html_theme = "alabaster"
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#
|
||||
html_theme_options = {
|
||||
"show_powered_by": False,
|
||||
"github_user": "kennethreitz",
|
||||
@@ -105,118 +37,16 @@ html_theme_options = {
|
||||
"github_banner": False,
|
||||
"show_related": False,
|
||||
}
|
||||
|
||||
|
||||
html_sidebars = {
|
||||
"index": ["sidebarintro.html", "sourcelink.html", "searchbox.html", "hacks.html"],
|
||||
"**": [
|
||||
"sidebarlogo.html",
|
||||
"localtoc.html",
|
||||
"relations.html",
|
||||
"sourcelink.html",
|
||||
"searchbox.html",
|
||||
"hacks.html",
|
||||
],
|
||||
}
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ["_static"]
|
||||
|
||||
# Custom sidebar templates, must be a dictionary that maps document names
|
||||
# to template names.
|
||||
#
|
||||
# The default sidebars (for documents that don't match any pattern) are
|
||||
# defined by theme itself. Builtin themes are using these templates by
|
||||
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
|
||||
# 'searchbox.html']``.
|
||||
#
|
||||
# html_sidebars = {}
|
||||
|
||||
|
||||
# -- Options for HTMLHelp output ---------------------------------------------
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = "responderdoc"
|
||||
|
||||
|
||||
# -- Options for LaTeX output ------------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#
|
||||
# 'papersize': 'letterpaper',
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#
|
||||
# 'pointsize': '10pt',
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#
|
||||
# 'preamble': '',
|
||||
# Latex figure (float) alignment
|
||||
#
|
||||
# 'figure_align': 'htbp',
|
||||
html_sidebars = {
|
||||
"index": ["sidebarintro.html", "searchbox.html"],
|
||||
"**": ["sidebarintro.html", "localtoc.html", "searchbox.html"],
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, "responder.tex", "responder Documentation", "Kenneth Reitz", "manual")
|
||||
]
|
||||
# MyST
|
||||
myst_heading_anchors = 3
|
||||
|
||||
|
||||
# -- Options for manual page output ------------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [(master_doc, "responder", "responder Documentation", [author], 1)]
|
||||
|
||||
|
||||
# -- Options for Texinfo output ----------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(
|
||||
master_doc,
|
||||
"responder",
|
||||
"responder Documentation",
|
||||
author,
|
||||
"responder",
|
||||
"One line description of project.",
|
||||
"Miscellaneous",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# -- Options for Epub output -------------------------------------------------
|
||||
|
||||
# Bibliographic Dublin Core info.
|
||||
epub_title = project
|
||||
|
||||
# The unique identifier of the text. This can be a ISBN number
|
||||
# or the project homepage.
|
||||
#
|
||||
# epub_identifier = ''
|
||||
|
||||
# A unique identification for the text.
|
||||
#
|
||||
# epub_uid = ''
|
||||
|
||||
# A list of files that should not be packed into the epub file.
|
||||
epub_exclude_files = ["search.html"]
|
||||
|
||||
|
||||
# -- Extension configuration -------------------------------------------------
|
||||
|
||||
# -- Options for intersphinx extension ---------------------------------------
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {"https://docs.python.org/": None}
|
||||
|
||||
# -- Options for todo extension ----------------------------------------------
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = True
|
||||
# Copybutton
|
||||
copybutton_remove_prompts = True
|
||||
copybutton_prompt_text = r">>> |\.\.\. |\$ "
|
||||
copybutton_prompt_is_regexp = True
|
||||
|
||||
+68
-40
@@ -1,58 +1,86 @@
|
||||
Deploying Responder
|
||||
===================
|
||||
Deployment
|
||||
==========
|
||||
|
||||
You can deploy Responder anywhere you can deploy a basic Python application.
|
||||
Responder applications are standard ASGI apps. You can deploy them anywhere
|
||||
you'd deploy a Python web service.
|
||||
|
||||
Docker Deployment
|
||||
-----------------
|
||||
|
||||
Assuming existing ``api.py`` and ``Pipfile.lock`` containing ``responder``.
|
||||
Running Locally
|
||||
---------------
|
||||
|
||||
``Dockerfile``::
|
||||
|
||||
FROM kennethreitz/pipenv
|
||||
ENV PORT '80'
|
||||
COPY . /app
|
||||
CMD python3 api.py
|
||||
EXPOSE 80
|
||||
|
||||
That's it!
|
||||
|
||||
Heroku Deployment
|
||||
-----------------
|
||||
|
||||
The basics::
|
||||
|
||||
$ mkdir my-api
|
||||
$ cd my-api
|
||||
$ git init
|
||||
$ heroku create
|
||||
...
|
||||
|
||||
Install Responder::
|
||||
|
||||
$ pipenv install responder
|
||||
...
|
||||
|
||||
Write out an ``api.py``::
|
||||
The simplest way to run your application::
|
||||
|
||||
# api.py
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/")
|
||||
async def hello(req, resp):
|
||||
def hello(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Write out a ``Procfile``::
|
||||
This starts a production uvicorn server on ``127.0.0.1:5042``.
|
||||
|
||||
web: python api.py
|
||||
|
||||
That's it! Next, we commit and push to Heroku::
|
||||
Docker
|
||||
------
|
||||
|
||||
$ git add -A
|
||||
$ git commit -m 'initial commit'
|
||||
$ git push heroku master
|
||||
A minimal Dockerfile for deploying a Responder application::
|
||||
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pip install responder
|
||||
ENV PORT=80
|
||||
EXPOSE 80
|
||||
CMD ["python", "api.py"]
|
||||
|
||||
Build and run::
|
||||
|
||||
$ docker build -t myapi .
|
||||
$ docker run -p 8000:80 myapi
|
||||
|
||||
|
||||
Cloud Platforms
|
||||
---------------
|
||||
|
||||
Responder automatically honors the ``PORT`` environment variable, which is
|
||||
set by most cloud platforms. When ``PORT`` is set, Responder binds to
|
||||
``0.0.0.0`` on that port automatically.
|
||||
|
||||
This works out of the box with:
|
||||
|
||||
- **Fly.io**
|
||||
- **Railway**
|
||||
- **Render**
|
||||
- **Google Cloud Run**
|
||||
- **Azure Container Apps**
|
||||
- **AWS App Runner**
|
||||
|
||||
Just deploy your code and set the start command to ``python api.py``.
|
||||
|
||||
|
||||
Uvicorn Directly
|
||||
----------------
|
||||
|
||||
For more control over the production server, you can bypass ``api.run()``
|
||||
and use uvicorn directly::
|
||||
|
||||
$ uvicorn api:api --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
This gives you access to all of uvicorn's options: worker count, SSL
|
||||
certificates, access logging, and more. See the
|
||||
`uvicorn documentation <https://www.uvicorn.org/>`_ for details.
|
||||
|
||||
|
||||
Reverse Proxy
|
||||
-------------
|
||||
|
||||
In production, you may want to place Responder behind a reverse proxy like
|
||||
nginx or Caddy for SSL termination, load balancing, or serving static assets.
|
||||
|
||||
Responder's ``TrustedHostMiddleware`` and ``HTTPSRedirectMiddleware`` work
|
||||
correctly behind proxies that set standard forwarding headers.
|
||||
|
||||
+71
-102
@@ -1,25 +1,7 @@
|
||||
.. responder documentation master file, created by
|
||||
sphinx-quickstart on Thu Oct 11 12:58:34 2018.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
Responder
|
||||
=========
|
||||
|
||||
A familiar HTTP Service Framework
|
||||
=================================
|
||||
|
||||
|Build Status| |image1| |image2| |image3| |image4| |image5|
|
||||
|
||||
.. |Build Status| image:: https://github.com/kennethreitz/responder/actions/workflows/test.yaml/badge.svg
|
||||
:target: https://github.com/kennethreitz/responder/actions/workflows/test.yaml
|
||||
.. |image1| image:: https://img.shields.io/pypi/v/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |image2| image:: https://img.shields.io/pypi/l/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |image3| image:: https://img.shields.io/pypi/pyversions/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |image4| image:: https://img.shields.io/github/contributors/kennethreitz/responder.svg
|
||||
:target: https://github.com/kennethreitz/responder/graphs/contributors
|
||||
.. |image5| image:: https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg
|
||||
:target: https://saythanks.io/to/kennethreitz
|
||||
A familiar HTTP Service Framework for Python.
|
||||
|
||||
.. code:: python
|
||||
|
||||
@@ -34,109 +16,96 @@ A familiar HTTP Service Framework
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
|
||||
Powered by `Starlette <https://www.starlette.io/>`_. That ``async`` declaration is optional.
|
||||
Powered by `Starlette`_ and `uvicorn`_. The ``async`` is optional.
|
||||
|
||||
This gets you a ASGI app, with a production static files server
|
||||
(`WhiteNoise <http://whitenoise.evans.io/en/stable/>`_)
|
||||
pre-installed, jinja2 templating (without additional imports), and a
|
||||
production webserver based on uvloop, serving up requests with
|
||||
automatic gzip compression.
|
||||
|
||||
Features
|
||||
The Idea
|
||||
--------
|
||||
|
||||
- A pleasant API, with a single import statement.
|
||||
- Class-based views without inheritance.
|
||||
- `ASGI <https://asgi.readthedocs.io>`_ framework, the future of Python web services.
|
||||
- WebSocket support!
|
||||
- The ability to mount any ASGI / WSGI app at a subroute.
|
||||
- `f-string syntax <https://docs.python.org/3/whatsnew/3.6.html#pep-498-formatted-string-literals>`_ route declaration.
|
||||
- Mutable response object, passed into each view. No need to return anything.
|
||||
- Background tasks, spawned off in a ``ThreadPoolExecutor``.
|
||||
- GraphQL (with *GraphiQL*) support!
|
||||
- OpenAPI schema generation, with interactive documentation!
|
||||
- Single-page webapp support!
|
||||
Responder takes the best ideas from `Flask`_ and `Falcon`_ and brings them
|
||||
together into one clean framework.
|
||||
|
||||
Testimonials
|
||||
The request and response objects are passed into every view and mutated
|
||||
directly — no return values, no boilerplate. If you've used Requests,
|
||||
you'll feel right at home. If you've used Flask, the routing will look
|
||||
familiar. If you've used Falcon, the ``req`` / ``resp`` pattern will
|
||||
click immediately.
|
||||
|
||||
- ``resp.text`` sends back text. ``resp.html`` sends back HTML.
|
||||
- ``resp.media`` sends back JSON — or YAML, if the client asks for it.
|
||||
- ``resp.file("path")`` serves a file. ``resp.content`` sends raw bytes.
|
||||
- ``req.headers`` is case-insensitive. ``req.params`` holds query parameters.
|
||||
- ``resp.status_code``, ``req.method``, ``req.url`` — the usual suspects.
|
||||
|
||||
Content negotiation happens automatically. Set ``resp.media`` to a dict
|
||||
and Responder figures out the rest.
|
||||
|
||||
Responder and `FastAPI`_ share DNA — both are built on Starlette, both
|
||||
appeared around the same time, and both pushed Python's ASGI ecosystem
|
||||
forward. FastAPI went deep on type annotations and automatic validation.
|
||||
Responder went for a mutable request/response pattern and a simpler,
|
||||
more familiar API. Both projects are better for the other existing, and
|
||||
you should use whichever feels right for what you're building.
|
||||
|
||||
|
||||
What You Get
|
||||
------------
|
||||
|
||||
“Pleasantly very taken with python-responder.
|
||||
`@kennethreitz <https://twitter.com/kennethreitz>`_ at his absolute
|
||||
best.”
|
||||
One ``pip install``, batteries included:
|
||||
|
||||
—Rudraksh M.K.
|
||||
- Mount Flask, Django, or any WSGI/ASGI app at a subroute.
|
||||
- Gzip compression, HSTS, CORS, and trusted host validation.
|
||||
- Before-request hooks that can short-circuit for auth guards.
|
||||
- A test client for fast, in-process testing with pytest.
|
||||
- Route parameters with f-string syntax and type convertors.
|
||||
- Lifespan context managers for startup and shutdown logic.
|
||||
- Custom exception handlers for clean error responses.
|
||||
- `GraphQL`_ with Graphene and a built-in GraphiQL IDE.
|
||||
- File serving with automatic content-type detection.
|
||||
- Sync and async views — ``async`` is always optional.
|
||||
- Class-based views with ``on_get``, ``on_post``, ``on_request``.
|
||||
- A pleasant API with a single import statement.
|
||||
- OpenAPI schema generation with Swagger UI.
|
||||
- A production `uvicorn`_ server, ready to deploy.
|
||||
- HTTP method filtering for REST APIs.
|
||||
- Signed cookie-based sessions.
|
||||
- Background tasks in a thread pool.
|
||||
- WebSocket support.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
..
|
||||
.. code-block:: shell
|
||||
|
||||
"ASGI is going to enable all sorts of new high-performance web services. It's awesome to see Responder starting to take advantage of that."
|
||||
$ uv pip install responder
|
||||
|
||||
—Tom Christie, author of `Django REST Framework`_
|
||||
Python 3.9 and above. That's it.
|
||||
|
||||
..
|
||||
|
||||
|
||||
“I love that you are exploring new patterns. Go go go!”
|
||||
|
||||
— Danny Greenfield, author of `Two Scoops of Django`_
|
||||
|
||||
|
||||
.. _Django REST Framework: https://www.django-rest-framework.org/
|
||||
.. _Two Scoops of Django: https://www.feldroy.com/two-scoops-press#two-scoops-of-django
|
||||
|
||||
User Guides
|
||||
-----------
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: User Guide
|
||||
|
||||
quickstart
|
||||
tour
|
||||
deployment
|
||||
testing
|
||||
api
|
||||
cli
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Project
|
||||
|
||||
changes
|
||||
Sandbox <sandbox>
|
||||
backlog
|
||||
|
||||
|
||||
Installing Responder
|
||||
--------------------
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$ pipenv install responder
|
||||
✨🍰✨
|
||||
|
||||
Only **Python 3.6+** is supported.
|
||||
|
||||
|
||||
The Basic Idea
|
||||
--------------
|
||||
|
||||
The primary concept here is to bring the niceties that are brought forth from both Flask and Falcon and unify them into a single framework, along with some new ideas I have. I also wanted to take some of the API primitives that are instilled in the Requests library and put them into a web framework. So, you'll find a lot of parallels here with Requests.
|
||||
|
||||
- Setting ``resp.content`` sends back bytes.
|
||||
- Setting ``resp.text`` sends back unicode, while setting ``resp.html`` sends back HTML.
|
||||
- Setting ``resp.media`` sends back JSON/YAML (``.text``/``.html``/``.content`` override this).
|
||||
- Case-insensitive ``req.headers`` dict (from Requests directly).
|
||||
- ``resp.status_code``, ``req.method``, ``req.url``, and other familiar friends.
|
||||
|
||||
Ideas
|
||||
-----
|
||||
|
||||
- Flask-style route expression, with new capabilities -- all while using Python 3.6+'s new f-string syntax.
|
||||
- I love Falcon's "every request and response is passed into each view and mutated" methodology, especially ``response.media``, and have used it here. In addition to supporting JSON, I have decided to support YAML as well, as Kubernetes is slowly taking over the world, and it uses YAML for all the things. Content-negotiation and all that.
|
||||
- **A built in testing client that uses the actual Requests you know and love**.
|
||||
- The ability to mount other WSGI apps easily.
|
||||
- Automatic gzipped-responses.
|
||||
- In addition to Falcon's ``on_get``, ``on_post``, etc methods, Responder features an ``on_request`` method, which gets called on every type of request, much like Requests.
|
||||
- A production static files server is built-in.
|
||||
- `Uvicorn <https://www.uvicorn.org/>`_ is built-in as a production web server. I would have chosen Gunicorn, but it doesn't run on Windows. Plus, Uvicorn serves well to protect against `slowloris <https://en.wikipedia.org/wiki/Slowloris_(computer_security)>`_ attacks, making nginx unnecessary in production.
|
||||
- GraphQL support, via Graphene. The goal here is to have any GraphQL query exposable at any route, magically.
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
.. _Starlette: https://www.starlette.io/
|
||||
.. _uvicorn: https://www.uvicorn.org/
|
||||
.. _Flask: https://flask.palletsprojects.com/
|
||||
.. _Falcon: https://falconframework.org/
|
||||
.. _FastAPI: https://fastapi.tiangolo.com/
|
||||
.. _GraphQL: https://graphql.org/
|
||||
|
||||
+160
-103
@@ -1,177 +1,234 @@
|
||||
Quick Start!
|
||||
============
|
||||
Quick Start
|
||||
===========
|
||||
|
||||
This section of the documentation exists to provide an introduction to the Responder interface,
|
||||
as well as educate the user on basic functionality.
|
||||
This guide will walk you through the basics of building a web service with
|
||||
Responder. By the end, you'll know how to declare routes, handle requests,
|
||||
send responses, render templates, and process background tasks.
|
||||
|
||||
|
||||
Declare a Web Service
|
||||
---------------------
|
||||
Create a Web Service
|
||||
--------------------
|
||||
|
||||
The first thing you need to do is declare a web service::
|
||||
The first thing you need to do is declare a web service. This is the central
|
||||
object that holds all your routes, middleware, and configuration::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
Hello World!
|
||||
------------
|
||||
|
||||
Then, you can add a view / route to it.
|
||||
Hello World
|
||||
-----------
|
||||
|
||||
Here, we'll make the root URL say "hello world!"::
|
||||
Next, add a route. Here, we'll make the root URL say "hello, world!"::
|
||||
|
||||
@api.route("/")
|
||||
def hello_world(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
Every view receives a ``req`` (request) and ``resp`` (response) object. You
|
||||
don't need to return anything — just mutate the response directly.
|
||||
|
||||
|
||||
Run the Server
|
||||
--------------
|
||||
|
||||
Next, we can run our web service easily, with ``api.run()``::
|
||||
Start your web service with ``api.run()``::
|
||||
|
||||
api.run()
|
||||
|
||||
This will spin up a production web server on port ``5042``, ready for incoming HTTP requests.
|
||||
This spins up a production-grade uvicorn server on port ``5042``, ready for
|
||||
incoming HTTP requests.
|
||||
|
||||
Note: you can pass ``port=5000`` if you want to customize the port. The ``PORT`` environment variable for established web service providers (e.g. Heroku) will automatically be honored and will set the listening address to ``0.0.0.0`` automatically (also configurable through the ``address`` keyword argument).
|
||||
You can customize the port with ``api.run(port=8000)``. The ``PORT``
|
||||
environment variable is also honored automatically — when set, Responder
|
||||
binds to ``0.0.0.0`` on that port, which is what cloud platforms like
|
||||
Fly.io, Railway, and Google Cloud Run expect.
|
||||
|
||||
.. note::
|
||||
|
||||
Both sync and async views are supported. The ``async`` keyword is always
|
||||
optional — use it when you need to ``await`` something.
|
||||
|
||||
|
||||
Accept Route Arguments
|
||||
----------------------
|
||||
Route Parameters
|
||||
----------------
|
||||
|
||||
If you want dynamic URLs, you can use Python's familiar *f-string syntax* to declare variables in your routes::
|
||||
If you want dynamic URLs, use Python's familiar f-string syntax to declare
|
||||
variables in your routes::
|
||||
|
||||
@api.route("/hello/{who}")
|
||||
def hello_to(req, resp, *, who):
|
||||
resp.text = f"hello, {who}!"
|
||||
|
||||
A ``GET`` request to ``/hello/brettcannon`` will result in a response of ``hello, brettcannon!``.
|
||||
A ``GET`` request to ``/hello/world`` will respond with ``hello, world!``.
|
||||
|
||||
Type convertors are also available::
|
||||
Route parameters are passed as keyword-only arguments (after the ``*``).
|
||||
|
||||
|
||||
Type Convertors
|
||||
^^^^^^^^^^^^^^^
|
||||
|
||||
You can constrain route parameters to specific types. The parameter will be
|
||||
automatically converted before it reaches your view::
|
||||
|
||||
@api.route("/add/{a:int}/{b:int}")
|
||||
async def add(req, resp, *, a, b):
|
||||
resp.text = f"{a} + {b} = {a + b}"
|
||||
|
||||
Supported types: ``str``, ``int`` and ``float``.
|
||||
Supported types:
|
||||
|
||||
Returning JSON / YAML
|
||||
---------------------
|
||||
- ``str`` — matches any string without slashes (default)
|
||||
- ``int`` — matches digits, converts to ``int``
|
||||
- ``float`` — matches decimal numbers, converts to ``float``
|
||||
- ``uuid`` — matches UUID strings like ``550e8400-e29b-41d4-a716-446655440000``
|
||||
- ``path`` — matches any string *including* slashes, useful for file paths
|
||||
|
||||
If you want your API to send back JSON, simply set the ``resp.media`` property to a JSON-serializable Python object::
|
||||
|
||||
Sending Responses
|
||||
-----------------
|
||||
|
||||
Responder gives you several ways to send data back to the client. Just set
|
||||
the appropriate property on the response object.
|
||||
|
||||
**Text and HTML**::
|
||||
|
||||
resp.text = "plain text response"
|
||||
resp.html = "<h1>HTML response</h1>"
|
||||
|
||||
**JSON** — the most common pattern for APIs. Set ``resp.media`` to any
|
||||
JSON-serializable Python object::
|
||||
|
||||
@api.route("/hello/{who}/json")
|
||||
def hello_to(req, resp, *, who):
|
||||
def hello_json(req, resp, *, who):
|
||||
resp.media = {"hello": who}
|
||||
|
||||
A ``GET`` request to ``/hello/guido/json`` will result in a response of ``{'hello': 'guido'}``.
|
||||
If the client sends an ``Accept: application/x-yaml`` header, the same data
|
||||
will be returned as YAML instead. Content negotiation is automatic.
|
||||
|
||||
If the client requests YAML instead (with a header of ``Accept: application/x-yaml``), YAML will be sent.
|
||||
**Files** — serve a file from disk with automatic content-type detection::
|
||||
|
||||
Rendering a Template
|
||||
--------------------
|
||||
resp.file("reports/annual.pdf")
|
||||
|
||||
Responder provides a built-in light `jinja2 <http://jinja.pocoo.org/docs/>`_ wrapper ``templates.Templates``
|
||||
**Raw bytes**::
|
||||
|
||||
Usage::
|
||||
resp.content = b"\x89PNG\r\n..."
|
||||
|
||||
from responder.templates import Templates
|
||||
**Status codes and headers**::
|
||||
|
||||
templates = Templates()
|
||||
resp.status_code = 201
|
||||
resp.headers["X-Custom"] = "value"
|
||||
|
||||
@api.route("/hello/{name}/html")
|
||||
def hello(req, resp, name):
|
||||
resp.html = templates.render("hello.html", name=name)
|
||||
**Redirects**::
|
||||
|
||||
api.redirect(resp, location="/new-url")
|
||||
|
||||
|
||||
Also a ``render_async`` is available::
|
||||
Reading Requests
|
||||
----------------
|
||||
|
||||
templates = Templates(enable_async=True)
|
||||
resp.html = await templates.render_async("hello.html", who=who)
|
||||
The request object gives you access to everything the client sent.
|
||||
|
||||
You can also use the existing ``api.template(filename, *args, **kwargs)`` to render templates::
|
||||
**Method and URL**::
|
||||
|
||||
@api.route("/hello/{who}/html")
|
||||
def hello_html(req, resp, *, who):
|
||||
resp.html = api.template('hello.html', who=who)
|
||||
req.method # "get", "post", etc. (lowercase)
|
||||
req.full_url # "http://example.com/path?q=1"
|
||||
req.url # parsed URL object
|
||||
|
||||
**Headers** — case-insensitive, just like you'd expect::
|
||||
|
||||
req.headers["Content-Type"]
|
||||
req.headers["content-type"] # same thing
|
||||
|
||||
**Query parameters**::
|
||||
|
||||
# GET /search?q=python&page=2
|
||||
req.params["q"] # "python"
|
||||
req.params["page"] # "2"
|
||||
|
||||
**Path parameters** — also available on the request object::
|
||||
|
||||
req.path_params["user_id"] # same as the keyword argument
|
||||
|
||||
**Request body** — for POST/PUT/PATCH requests, you need to ``await`` the
|
||||
body content::
|
||||
|
||||
# JSON body
|
||||
data = await req.media()
|
||||
|
||||
# Form data
|
||||
data = await req.media("form")
|
||||
|
||||
# File uploads
|
||||
files = await req.media("files")
|
||||
|
||||
# Raw bytes
|
||||
body = await req.content
|
||||
|
||||
# Raw text
|
||||
text = await req.text
|
||||
|
||||
**Other useful properties**::
|
||||
|
||||
req.is_json # True if content type is JSON
|
||||
req.cookies # dict of cookies
|
||||
req.session # session data (dict)
|
||||
req.client # (host, port) tuple
|
||||
req.is_secure # True if HTTPS
|
||||
|
||||
|
||||
Setting Response Status Code
|
||||
----------------------------
|
||||
Rendering Templates
|
||||
-------------------
|
||||
|
||||
If you want to set the response status code, simply set ``resp.status_code``::
|
||||
Responder includes built-in `Jinja2 <https://jinja.palletsprojects.com/>`_
|
||||
support. Templates are loaded from the ``templates/`` directory by default.
|
||||
|
||||
@api.route("/416")
|
||||
def teapot(req, resp):
|
||||
resp.status_code = api.status_codes.HTTP_416 # ...or 416
|
||||
The simplest way is to use ``api.template()``::
|
||||
|
||||
@api.route("/hello/{name}/html")
|
||||
def hello_html(req, resp, *, name):
|
||||
resp.html = api.template("hello.html", name=name)
|
||||
|
||||
You can also use the ``Templates`` class directly for more control::
|
||||
|
||||
from responder.templates import Templates
|
||||
|
||||
templates = Templates(directory="templates")
|
||||
|
||||
@api.route("/page")
|
||||
def page(req, resp):
|
||||
resp.html = templates.render("page.html", title="Hello")
|
||||
|
||||
Async rendering is supported too::
|
||||
|
||||
templates = Templates(directory="templates", enable_async=True)
|
||||
resp.html = await templates.render_async("page.html", title="Hello")
|
||||
|
||||
You can render template strings without a file::
|
||||
|
||||
resp.html = api.template_string("Hello, {{ name }}!", name="world")
|
||||
|
||||
|
||||
Setting Response Headers
|
||||
------------------------
|
||||
Background Tasks
|
||||
----------------
|
||||
|
||||
If you want to set a response header, like ``X-Pizza: 42``, simply modify the ``resp.headers`` dictionary::
|
||||
|
||||
@api.route("/pizza")
|
||||
def pizza_pizza(req, resp):
|
||||
resp.headers['X-Pizza'] = '42'
|
||||
|
||||
That's it!
|
||||
|
||||
|
||||
Receiving Data & Background Tasks
|
||||
---------------------------------
|
||||
|
||||
If you're expecting to read any request data, on the server, you need to declare your view as async and await the content.
|
||||
|
||||
Here, we'll process our data in the background, while responding immediately to the client::
|
||||
|
||||
import time
|
||||
Sometimes you want to accept a request, respond immediately, and do the
|
||||
actual processing later. Responder makes this easy with background tasks::
|
||||
|
||||
@api.route("/incoming")
|
||||
async def receive_incoming(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def process_data(data):
|
||||
"""Just sleeps for three seconds, as a demo."""
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
# Parse the incoming data as form-encoded.
|
||||
# Note: 'json' and 'yaml' formats are also automatically supported.
|
||||
data = await req.media()
|
||||
|
||||
# Process the data (in the background).
|
||||
process_data(data)
|
||||
|
||||
# Immediately respond that upload was successful.
|
||||
resp.media = {'success': True}
|
||||
|
||||
A ``POST`` request to ``/incoming`` will result in an immediate response of ``{'success': true}``.
|
||||
|
||||
|
||||
Here's a sample code to post a file with background::
|
||||
|
||||
@api.route("/")
|
||||
async def upload_file(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def process_data(data):
|
||||
f = open('./{}'.format(data['file']['filename']), 'w')
|
||||
f.write(data['file']['content'].decode('utf-8'))
|
||||
f.close()
|
||||
"""This runs in a background thread."""
|
||||
import time
|
||||
time.sleep(10) # simulate heavy work
|
||||
|
||||
data = await req.media(format='files')
|
||||
process_data(data)
|
||||
|
||||
resp.media = {'success': 'ok'}
|
||||
# Respond immediately — processing continues in the background
|
||||
resp.media = {"status": "accepted"}
|
||||
|
||||
You can send a file easily with requests::
|
||||
|
||||
import requests
|
||||
|
||||
data = {'file': ('hello.txt', 'hello, world!', "text/plain")}
|
||||
r = requests.post('http://127.0.0.1:8210/file', files=data)
|
||||
|
||||
print(r.text)
|
||||
The ``@api.background.task`` decorator wraps any function to run in a thread
|
||||
pool. The client gets an immediate response while the work continues.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
(sandbox)=
|
||||
# Development Sandbox
|
||||
|
||||
## Setup
|
||||
Set up a development sandbox.
|
||||
|
||||
Acquire sources and create virtualenv.
|
||||
```shell
|
||||
git clone https://github.com/kennethreitz/responder.git
|
||||
cd responder
|
||||
uv venv
|
||||
```
|
||||
|
||||
Install project in editable mode, including
|
||||
all development tools.
|
||||
```shell
|
||||
uv pip install --upgrade --editable '.[develop,docs,release,test]'
|
||||
```
|
||||
|
||||
## Operations
|
||||
Run tests.
|
||||
```shell
|
||||
source .venv/bin/activate
|
||||
pytest
|
||||
```
|
||||
|
||||
Format code.
|
||||
```shell
|
||||
ruff format .
|
||||
ruff check --fix .
|
||||
```
|
||||
|
||||
Documentation authoring.
|
||||
```shell
|
||||
sphinx-autobuild --open-browser --watch docs/source docs/source docs/build
|
||||
```
|
||||
+117
-41
@@ -1,54 +1,46 @@
|
||||
Building and Testing with Responder
|
||||
===================================
|
||||
Testing
|
||||
=======
|
||||
|
||||
Responder comes with a first-class, well supported test client for your ASGI web services: **Requests**.
|
||||
Responder includes a built-in test client powered by Starlette's
|
||||
``TestClient``. You don't need to start a server — tests run in-process,
|
||||
making them fast and reliable.
|
||||
|
||||
Here, we'll go over the basics of setting up a proper Python package and adding testing to it.
|
||||
|
||||
The Basics
|
||||
----------
|
||||
Getting Started
|
||||
---------------
|
||||
|
||||
Your repository should look like this::
|
||||
|
||||
Pipfile Pipfile.lock api.py test_api.py
|
||||
|
||||
``$ cat api.py``::
|
||||
Given a simple application in ``api.py``::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/")
|
||||
def hello_world(req, resp):
|
||||
def hello(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
You can test it with pytest::
|
||||
|
||||
``$ cat Pipfile``::
|
||||
# test_api.py
|
||||
import api as service
|
||||
|
||||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
def test_hello():
|
||||
r = service.api.requests.get("/")
|
||||
assert r.text == "hello, world!"
|
||||
|
||||
[packages]
|
||||
responder = "*"
|
||||
Run your tests::
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
$ pytest
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
||||
|
||||
[pipenv]
|
||||
allow_prereleases = true
|
||||
Using Fixtures
|
||||
--------------
|
||||
|
||||
Writing Tests
|
||||
-------------
|
||||
|
||||
``$ cat test_api.py``::
|
||||
For larger test suites, use pytest fixtures to share the API instance
|
||||
across tests::
|
||||
|
||||
import pytest
|
||||
import api as service
|
||||
@@ -57,25 +49,109 @@ Writing Tests
|
||||
def api():
|
||||
return service.api
|
||||
|
||||
|
||||
def test_hello_world(api):
|
||||
def test_hello(api):
|
||||
r = api.requests.get("/")
|
||||
assert r.text == "hello, world!"
|
||||
|
||||
``$ pytest``::
|
||||
def test_json(api):
|
||||
@api.route("/data")
|
||||
def data(req, resp):
|
||||
resp.media = {"key": "value"}
|
||||
|
||||
...
|
||||
========================== 1 passed in 0.10 seconds ==========================
|
||||
r = api.requests.get(api.url_for(data))
|
||||
assert r.json() == {"key": "value"}
|
||||
|
||||
The ``api.url_for()`` method generates a URL for a given route endpoint,
|
||||
so you don't have to hard-code paths in your tests.
|
||||
|
||||
|
||||
(Optional) Proper Python Package
|
||||
--------------------------------
|
||||
Testing JSON APIs
|
||||
-----------------
|
||||
|
||||
Optionally, you can not rely on relative imports, and instead install your api as a proper package. This requires:
|
||||
Send JSON data and check the response::
|
||||
|
||||
1. A `proper setup.py <https://github.com/kennethreitz/setup.py>`_ file.
|
||||
2. ``$ pipenv install -e . --dev``
|
||||
def test_create_item(api):
|
||||
@api.route("/items")
|
||||
async def create(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"created": data}
|
||||
resp.status_code = 201
|
||||
|
||||
This will allow you to only specify your dependencies once: in ``setup.py``. ``$ pipenv lock`` will automatically lock your transitive dependencies (e.g. Responder), even if it's not specified in the ``Pipfile``.
|
||||
r = api.requests.post(api.url_for(create), json={"name": "widget"})
|
||||
assert r.status_code == 201
|
||||
assert r.json() == {"created": {"name": "widget"}}
|
||||
|
||||
This will ensure that your application gets installed in every developer's environment, using Pipenv.
|
||||
|
||||
Testing File Uploads
|
||||
--------------------
|
||||
|
||||
Send files using the ``files`` parameter::
|
||||
|
||||
def test_upload(api):
|
||||
@api.route("/upload")
|
||||
async def upload(req, resp):
|
||||
files = await req.media("files")
|
||||
resp.media = {"received": list(files.keys())}
|
||||
|
||||
files = {"doc": ("report.pdf", b"content", "application/pdf")}
|
||||
r = api.requests.post(api.url_for(upload), files=files)
|
||||
assert r.json() == {"received": ["doc"]}
|
||||
|
||||
|
||||
Testing WebSockets
|
||||
------------------
|
||||
|
||||
Use Starlette's ``TestClient`` directly for WebSocket connections::
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
def test_websocket(api):
|
||||
@api.route("/ws", websocket=True)
|
||||
async def ws(ws):
|
||||
await ws.accept()
|
||||
await ws.send_text("hello")
|
||||
await ws.close()
|
||||
|
||||
client = TestClient(api)
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
assert ws.receive_text() == "hello"
|
||||
|
||||
|
||||
Testing Error Handling
|
||||
----------------------
|
||||
|
||||
To test error responses without pytest raising the exception, disable
|
||||
server exception propagation::
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
def test_500(api):
|
||||
@api.route("/fail")
|
||||
def fail(req, resp):
|
||||
raise ValueError("something broke")
|
||||
|
||||
client = TestClient(api, raise_server_exceptions=False)
|
||||
r = client.get(api.url_for(fail))
|
||||
assert r.status_code == 500
|
||||
|
||||
|
||||
Testing Lifespan Events
|
||||
-----------------------
|
||||
|
||||
The test client supports lifespan events. Use ``with`` to ensure startup
|
||||
and shutdown hooks run::
|
||||
|
||||
def test_with_lifespan(api):
|
||||
started = {"value": False}
|
||||
|
||||
@api.on_event("startup")
|
||||
async def on_startup():
|
||||
started["value"] = True
|
||||
|
||||
@api.route("/")
|
||||
def check(req, resp):
|
||||
resp.media = {"started": started["value"]}
|
||||
|
||||
with api.requests as session:
|
||||
r = session.get("http://;/")
|
||||
assert r.json() == {"started": True}
|
||||
|
||||
+424
-343
@@ -1,41 +1,177 @@
|
||||
Feature Tour
|
||||
============
|
||||
|
||||
This section walks through Responder's features in detail. Each section
|
||||
includes working code examples you can copy into your application.
|
||||
|
||||
|
||||
Method Filtering
|
||||
----------------
|
||||
|
||||
By default, a route matches all HTTP methods. If you want to restrict a
|
||||
route to specific methods, pass the ``methods`` parameter::
|
||||
|
||||
@api.route("/items", methods=["GET"])
|
||||
def list_items(req, resp):
|
||||
resp.media = {"items": []}
|
||||
|
||||
@api.route("/items", methods=["POST"], check_existing=False)
|
||||
async def create_item(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"created": data}
|
||||
|
||||
Note the ``check_existing=False`` — this allows you to register multiple
|
||||
handlers for the same path with different methods.
|
||||
|
||||
|
||||
Class-Based Views
|
||||
-----------------
|
||||
|
||||
Class-based views (and setting some headers and stuff)::
|
||||
For more complex resources, you can use class-based views. Responder will
|
||||
dispatch to the appropriate method handler based on the HTTP method::
|
||||
|
||||
@api.route("/{greeting}")
|
||||
class GreetingResource:
|
||||
def on_request(self, req, resp, *, greeting): # or on_get...
|
||||
def on_get(self, req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
resp.headers.update({'X-Life': '42'})
|
||||
resp.status_code = api.status_codes.HTTP_416
|
||||
|
||||
def on_post(self, req, resp, *, greeting):
|
||||
resp.media = {"received": greeting}
|
||||
|
||||
def on_request(self, req, resp, *, greeting):
|
||||
"""Called on EVERY request, before the method-specific handler."""
|
||||
resp.headers["X-Greeting"] = greeting
|
||||
|
||||
The ``on_request`` method is called for all HTTP methods, much like
|
||||
middleware scoped to a single route. Method-specific handlers (``on_get``,
|
||||
``on_post``, ``on_put``, ``on_delete``, etc.) are called after.
|
||||
|
||||
No inheritance required — just define a class with the right method names.
|
||||
|
||||
|
||||
Background Tasks
|
||||
----------------
|
||||
Lifespan Events
|
||||
---------------
|
||||
|
||||
Here, you can spawn off a background thread to run any function, out-of-request::
|
||||
Modern applications often need to set up resources on startup (database
|
||||
connections, caches, ML models) and tear them down on shutdown. Responder
|
||||
supports the lifespan context manager pattern::
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@api.background.task
|
||||
def sleep(s=10):
|
||||
time.sleep(s)
|
||||
print("slept!")
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup — runs before the first request
|
||||
print("connecting to database...")
|
||||
yield
|
||||
# Shutdown — runs after the server stops
|
||||
print("closing connections...")
|
||||
|
||||
sleep()
|
||||
resp.content = "processing"
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
You can also use the traditional event decorator style::
|
||||
|
||||
@api.on_event("startup")
|
||||
async def startup():
|
||||
print("starting up")
|
||||
|
||||
@api.on_event("shutdown")
|
||||
async def shutdown():
|
||||
print("shutting down")
|
||||
|
||||
The context manager approach is preferred for new code — it makes the
|
||||
startup/shutdown relationship explicit and keeps related code together.
|
||||
|
||||
|
||||
Serving Files
|
||||
-------------
|
||||
|
||||
Serve files from disk with automatic content-type detection. Responder
|
||||
uses Python's ``mimetypes`` module to figure out the right ``Content-Type``
|
||||
header for you::
|
||||
|
||||
@api.route("/download")
|
||||
def download(req, resp):
|
||||
resp.file("reports/annual.pdf")
|
||||
|
||||
You can override the content type if needed::
|
||||
|
||||
@api.route("/image")
|
||||
def image(req, resp):
|
||||
resp.file("photos/cat.jpg", content_type="image/jpeg")
|
||||
|
||||
|
||||
Custom Error Handling
|
||||
---------------------
|
||||
|
||||
By default, unhandled exceptions result in a 500 Internal Server Error.
|
||||
You can register custom handlers for specific exception types to return
|
||||
structured error responses::
|
||||
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle_value_error(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
Now, any route that raises a ``ValueError`` will return a clean 400 response
|
||||
with a JSON error message instead of a generic 500 page.
|
||||
|
||||
|
||||
Before-Request Hooks
|
||||
--------------------
|
||||
|
||||
Run code before every request. This is useful for logging, adding common
|
||||
headers, or setting up per-request state::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def add_headers(req, resp):
|
||||
resp.headers["X-API-Version"] = "3.1"
|
||||
|
||||
**Short-circuiting:** If your hook sets ``resp.status_code``, the route
|
||||
handler will be skipped entirely and the response will be sent immediately.
|
||||
This is the pattern for authentication guards::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def auth_check(req, resp):
|
||||
if "Authorization" not in req.headers:
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "unauthorized"}
|
||||
|
||||
If the ``Authorization`` header is missing, the client gets a 401 response
|
||||
and the actual route handler never runs.
|
||||
|
||||
WebSocket hooks work the same way::
|
||||
|
||||
@api.before_request(websocket=True)
|
||||
async def ws_auth(ws):
|
||||
await ws.accept()
|
||||
|
||||
|
||||
WebSocket Support
|
||||
-----------------
|
||||
|
||||
Responder supports WebSockets for real-time, bidirectional communication::
|
||||
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
while True:
|
||||
name = await ws.receive_text()
|
||||
await ws.send_text(f"Hello {name}!")
|
||||
await ws.close()
|
||||
|
||||
You can send and receive in multiple formats:
|
||||
|
||||
- ``send_text`` / ``receive_text`` — plain text
|
||||
- ``send_json`` / ``receive_json`` — JSON objects
|
||||
- ``send_bytes`` / ``receive_bytes`` — raw binary data
|
||||
|
||||
|
||||
GraphQL
|
||||
-------
|
||||
|
||||
Serve a GraphQL API::
|
||||
Responder includes built-in GraphQL support via
|
||||
`Graphene <https://graphene-python.org/>`_. Set up a full GraphQL endpoint
|
||||
with a single method call::
|
||||
|
||||
import graphene
|
||||
|
||||
@@ -45,383 +181,328 @@ Serve a GraphQL API::
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
view = responder.ext.GraphQLView(api=api, schema=schema)
|
||||
api.graphql("/graphql", schema=graphene.Schema(query=Query))
|
||||
|
||||
api.add_route("/graph", view)
|
||||
Visiting ``/graphql`` in a browser renders the GraphiQL interactive IDE,
|
||||
where you can explore your schema and test queries. Programmatic clients
|
||||
can POST JSON queries to the same endpoint.
|
||||
|
||||
Visiting the endpoint will render a *GraphiQL* instance, in the browser.
|
||||
|
||||
You can make use of Responder's Request and Response objects in your GraphQL resolvers through ``info.context['request']`` and ``info.context['response']``.
|
||||
You can access the Responder request and response objects in your resolvers
|
||||
through ``info.context["request"]`` and ``info.context["response"]``.
|
||||
|
||||
|
||||
OpenAPI Schema Support
|
||||
----------------------
|
||||
OpenAPI Documentation
|
||||
---------------------
|
||||
|
||||
Responder comes with built-in support for OpenAPI / marshmallow
|
||||
|
||||
New in Responder `1.4.0`::
|
||||
|
||||
import responder
|
||||
from responder.ext.schema import Schema as OpenAPISchema
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
contact = {
|
||||
"name": "API Support",
|
||||
"url": "http://www.example.com/support",
|
||||
"email": "support@example.com",
|
||||
}
|
||||
license = {
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
}
|
||||
|
||||
api = responder.API()
|
||||
|
||||
schema = OpenAPISchema(
|
||||
app=api,
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
description="A simple pet store",
|
||||
terms_of_service="http://example.com/terms/",
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
@schema.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
"""A cute furry animal endpoint.
|
||||
---
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
|
||||
|
||||
Old way *It's recommended to use the code above* ::
|
||||
|
||||
import responder
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
contact = {
|
||||
"name": "API Support",
|
||||
"url": "http://www.example.com/support",
|
||||
"email": "support@example.com",
|
||||
}
|
||||
license = {
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
||||
}
|
||||
Responder can generate an OpenAPI schema and serve interactive API
|
||||
documentation automatically::
|
||||
|
||||
api = responder.API(
|
||||
title="Web Service",
|
||||
title="Pet Store",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
description="A simple pet store",
|
||||
terms_of_service="http://example.com/terms/",
|
||||
contact=contact,
|
||||
license=license,
|
||||
docs_route="/docs",
|
||||
)
|
||||
|
||||
This gives you:
|
||||
|
||||
- An OpenAPI schema at ``/schema.yml``
|
||||
- Interactive Swagger UI documentation at ``/docs``
|
||||
|
||||
There are three ways to document your endpoints.
|
||||
|
||||
**Pydantic models** — the recommended approach for new APIs. Use
|
||||
``request_model`` and ``response_model`` to annotate your routes, and
|
||||
Responder will generate the schema automatically::
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class PetIn(BaseModel):
|
||||
name: str
|
||||
age: int = 0
|
||||
|
||||
class PetOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
age: int
|
||||
|
||||
@api.route("/pets", methods=["POST"],
|
||||
request_model=PetIn, response_model=PetOut)
|
||||
async def create_pet(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"id": 1, **data}
|
||||
|
||||
This generates a full OpenAPI path with ``requestBody`` and ``responses``
|
||||
schemas, all linked by ``$ref`` to your Pydantic models in
|
||||
``components/schemas``.
|
||||
|
||||
You can also register standalone schemas with the ``@api.schema`` decorator::
|
||||
|
||||
@api.schema("Pet")
|
||||
class Pet(BaseModel):
|
||||
name: str
|
||||
age: int = 0
|
||||
|
||||
**YAML docstrings** — inline your OpenAPI spec directly in the docstring.
|
||||
This gives you full control over every detail::
|
||||
|
||||
@api.route("/pets")
|
||||
def list_pets(req, resp):
|
||||
"""A list of pets.
|
||||
---
|
||||
get:
|
||||
description: Get all pets
|
||||
responses:
|
||||
200:
|
||||
description: A list of pets
|
||||
"""
|
||||
resp.media = [{"name": "Fido"}]
|
||||
|
||||
**Marshmallow schemas** — if you're already using marshmallow for
|
||||
validation, Responder integrates with it via the apispec plugin::
|
||||
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
@api.route("/")
|
||||
def route(req, resp):
|
||||
"""A cute furry animal endpoint.
|
||||
---
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200:
|
||||
description: A pet to be returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
"""
|
||||
resp.media = PetSchema().dump({"name": "little orange"})
|
||||
All three approaches can be mixed in the same API. Pydantic models,
|
||||
marshmallow schemas, and YAML docstrings all contribute to the same
|
||||
generated OpenAPI specification.
|
||||
|
||||
::
|
||||
|
||||
>>> r = api.session().get("http://;/schema.yml")
|
||||
|
||||
>>> print(r.text)
|
||||
components:
|
||||
parameters: {}
|
||||
responses: {}
|
||||
schemas:
|
||||
Pet:
|
||||
properties:
|
||||
name: {type: string}
|
||||
type: object
|
||||
securitySchemes: {}
|
||||
info:
|
||||
contact: {email: support@example.com, name: API Support, url: 'http://www.example.com/support'}
|
||||
description: This is a sample server for a pet store.
|
||||
license: {name: Apache 2.0, url: 'https://www.apache.org/licenses/LICENSE-2.0.html'}
|
||||
termsOfService: http://example.com/terms/
|
||||
title: Web Service
|
||||
version: 1.0
|
||||
openapi: 3.0.2
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
description: Get a random pet
|
||||
responses:
|
||||
200: {description: A pet to be returned, schema: $ref: "#/components/schemas/Pet"}
|
||||
tags: []
|
||||
You can choose from multiple documentation themes:
|
||||
``swagger_ui`` (default), ``redoc``, ``rapidoc``, or ``elements``.
|
||||
|
||||
|
||||
Interactive Documentation
|
||||
-------------------------
|
||||
Mounting Other Apps
|
||||
-------------------
|
||||
|
||||
Responder can automatically supply API Documentation for you. Using the example above
|
||||
Responder can mount any WSGI or ASGI application at a subroute. This means
|
||||
you can gradually migrate from Flask, or run multiple frameworks side by side::
|
||||
|
||||
The new and recommended way::
|
||||
|
||||
...
|
||||
from responder.ext.schema import Schema
|
||||
...
|
||||
api = responder.API()
|
||||
|
||||
schema = Schema(
|
||||
app=api,
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
...
|
||||
docs_route='/docs',
|
||||
...
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
|
||||
The old way ::
|
||||
|
||||
api = responder.API(
|
||||
title="Web Service",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
docs_route='/docs',
|
||||
description=description,
|
||||
terms_of_service=terms_of_service,
|
||||
contact=contact,
|
||||
license=license,
|
||||
)
|
||||
|
||||
This will make ``/docs`` render interactive documentation for your API.
|
||||
|
||||
Mount a WSGI / ASGI Apps (e.g. Flask, Starlette,...)
|
||||
----------------------------------------------------
|
||||
|
||||
Responder gives you the ability to mount another ASGI / WSGI app at a subroute::
|
||||
|
||||
import responder
|
||||
from flask import Flask
|
||||
|
||||
api = responder.API()
|
||||
flask = Flask(__name__)
|
||||
flask_app = Flask(__name__)
|
||||
|
||||
@flask.route('/')
|
||||
@flask_app.route("/")
|
||||
def hello():
|
||||
return 'hello'
|
||||
return "Hello from Flask!"
|
||||
|
||||
api.mount('/flask', flask)
|
||||
api.mount("/flask", flask_app)
|
||||
|
||||
That's it!
|
||||
Requests to ``/flask/`` will be handled by Flask. Everything else goes
|
||||
through Responder. Both WSGI and ASGI apps are supported — Responder
|
||||
wraps WSGI apps automatically.
|
||||
|
||||
Single-Page Web Apps
|
||||
--------------------
|
||||
|
||||
If you have a single-page webapp, you can tell Responder to serve up your ``static/index.html`` at a route, like so::
|
||||
Cookies
|
||||
-------
|
||||
|
||||
Reading and writing cookies is straightforward::
|
||||
|
||||
# Read cookies from the request
|
||||
session_id = req.cookies.get("session_id")
|
||||
|
||||
# Set a cookie on the response
|
||||
resp.cookies["hello"] = "world"
|
||||
|
||||
For more control over cookie directives, use ``set_cookie``::
|
||||
|
||||
resp.set_cookie(
|
||||
"token",
|
||||
value="abc123",
|
||||
max_age=3600,
|
||||
secure=True,
|
||||
httponly=True,
|
||||
path="/",
|
||||
)
|
||||
|
||||
Supported directives: ``key``, ``value``, ``expires``, ``max_age``,
|
||||
``domain``, ``path``, ``secure``, ``httponly``.
|
||||
|
||||
|
||||
Cookie-Based Sessions
|
||||
---------------------
|
||||
|
||||
Responder has built-in support for signed, cookie-based sessions. Just
|
||||
read from and write to the ``session`` dictionary::
|
||||
|
||||
@api.route("/login")
|
||||
def login(req, resp):
|
||||
resp.session["username"] = "alice"
|
||||
|
||||
@api.route("/profile")
|
||||
def profile(req, resp):
|
||||
resp.media = {"user": req.session.get("username")}
|
||||
|
||||
The session data is stored in a cookie called ``Responder-Session``. It's
|
||||
signed for tamper protection, so you can trust that the data originated
|
||||
from your server.
|
||||
|
||||
.. warning::
|
||||
|
||||
For production use, always set a secret key::
|
||||
|
||||
api = responder.API(secret_key="your-secret-key-here")
|
||||
|
||||
|
||||
Static Files
|
||||
------------
|
||||
|
||||
Static files are served from the ``static/`` directory by default::
|
||||
|
||||
api = responder.API(static_dir="static", static_route="/static")
|
||||
|
||||
Place your CSS, JavaScript, images, and other assets in the ``static/``
|
||||
directory and they'll be served automatically.
|
||||
|
||||
For single-page applications, you can serve ``index.html`` as the default
|
||||
response for all unmatched routes::
|
||||
|
||||
api.add_route("/", static=True)
|
||||
|
||||
This will make ``index.html`` the default response to all undefined routes.
|
||||
You can add additional static directories at runtime::
|
||||
|
||||
Reading / Writing Cookies
|
||||
-------------------------
|
||||
api.static_app.add_directory("extra_assets")
|
||||
|
||||
Responder makes it very easy to interact with cookies from a Request, or add some to a Response::
|
||||
|
||||
>>> resp.cookies["hello"] = "world"
|
||||
|
||||
>>> req.cookies
|
||||
{"hello": "world"}
|
||||
|
||||
|
||||
To set cookies directives, you should use `resp.set_cookie`::
|
||||
|
||||
>>> resp.set_cookie("hello", value="world", max_age=60)
|
||||
|
||||
Supported directives:
|
||||
|
||||
* ``key`` - **Required**
|
||||
* ``value`` - [OPTIONAL] - Defaults to ``""``.
|
||||
* ``expires`` - Defaults to ``None``.
|
||||
* ``max_age`` - Defaults to ``None``.
|
||||
* ``domain`` - Defaults to ``None``.
|
||||
* ``path`` - Defaults to ``"/"``.
|
||||
* ``secure`` - Defaults to ``False``.
|
||||
* ``httponly`` - Defaults to ``True``.
|
||||
|
||||
For more information see `directives <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie>`_
|
||||
|
||||
|
||||
Using Cookie-Based Sessions
|
||||
---------------------------
|
||||
|
||||
Responder has built-in support for cookie-based sessions. To enable cookie-based sessions, simply add something to the ``resp.session`` dictionary::
|
||||
|
||||
>>> resp.session['username'] = 'kennethreitz'
|
||||
|
||||
A cookie called ``Responder-Session`` will be set, which contains all the data in ``resp.session``. It is signed, for verification purposes.
|
||||
|
||||
You can easily read a Request's session data, that can be trusted to have originated from the API::
|
||||
|
||||
>>> req.session
|
||||
{'username': 'kennethreitz'}
|
||||
|
||||
**Note**: if you are using this in production, you should pass the ``secret_key`` argument to ``API(...)``::
|
||||
|
||||
api = responder.API(secret_key=os.environ['SECRET_KEY'])
|
||||
|
||||
Using ``before_request``
|
||||
------------------------
|
||||
|
||||
If you'd like a view to be executed before every request, simply do the following::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def prepare_response(req, resp):
|
||||
resp.headers["X-Pizza"] = "42"
|
||||
|
||||
Now all requests to your HTTP Service will include an ``X-Pizza`` header.
|
||||
|
||||
For ``websockets``::
|
||||
|
||||
@api.route(before_request=True, websocket=True)
|
||||
def prepare_response(ws):
|
||||
await ws.accept()
|
||||
|
||||
|
||||
WebSocket Support
|
||||
-----------------
|
||||
|
||||
Responder supports WebSockets::
|
||||
|
||||
@api.route('/ws', websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
while True:
|
||||
name = await ws.receive_text()
|
||||
await ws.send_text(f"Hello {name}!")
|
||||
await ws.close()
|
||||
|
||||
Accepting the connection::
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
Sending and receiving data::
|
||||
|
||||
await websocket.send_{format}(data)
|
||||
await websocket.receive_{format}(data)
|
||||
|
||||
Supported formats: ``text``, ``json``, ``bytes``.
|
||||
|
||||
Closing the connection::
|
||||
|
||||
await websocket.close()
|
||||
|
||||
Using Requests Test Client
|
||||
--------------------------
|
||||
|
||||
Responder comes with a first-class, well supported test client for your ASGI web services: **Requests**.
|
||||
|
||||
Here's an example of a test (written with pytest)::
|
||||
|
||||
import myapi
|
||||
|
||||
@pytest.fixture
|
||||
def api():
|
||||
return myapi.api
|
||||
|
||||
def test_response(api):
|
||||
hello = "hello, world!"
|
||||
|
||||
@api.route('/some-url')
|
||||
def some_view(req, resp):
|
||||
resp.text = hello
|
||||
|
||||
r = api.requests.get(url=api.url_for(some_view))
|
||||
assert r.text == hello
|
||||
|
||||
HSTS (Redirect to HTTPS)
|
||||
------------------------
|
||||
|
||||
Want HSTS (to redirect all traffic to HTTPS)?
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(enable_hsts=True)
|
||||
|
||||
|
||||
Boom.
|
||||
|
||||
CORS
|
||||
----
|
||||
|
||||
Want `CORS <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/>`_ ?
|
||||
Enable Cross-Origin Resource Sharing for your API::
|
||||
|
||||
::
|
||||
api = responder.API(cors=True, cors_params={
|
||||
"allow_origins": ["https://example.com"],
|
||||
"allow_methods": ["GET", "POST"],
|
||||
"allow_headers": ["*"],
|
||||
"allow_credentials": True,
|
||||
"max_age": 600,
|
||||
})
|
||||
|
||||
api = responder.API(cors=True)
|
||||
The default CORS policy is restrictive — you must explicitly enable the
|
||||
origins, methods, and headers your frontend needs.
|
||||
|
||||
|
||||
The default parameters used by **Responder** are restrictive by default, so you'll need to explicitly enable particular origins, methods, or headers, in order for browsers to be permitted to use them in a Cross-Domain context.
|
||||
HSTS
|
||||
----
|
||||
|
||||
In order to set custom parameters, you need to set the ``cors_params`` argument of ``api``, a dictionary containing the following entries:
|
||||
Force all traffic to HTTPS with a single flag::
|
||||
|
||||
api = responder.API(enable_hsts=True)
|
||||
|
||||
This adds the ``Strict-Transport-Security`` header and redirects HTTP
|
||||
requests to HTTPS.
|
||||
|
||||
* ``allow_origins`` - A list of origins that should be permitted to make cross-origin requests. eg. ``['https://example.org', 'https://www.example.org']``. You can use ``['*']`` to allow any origin.
|
||||
* ``allow_origin_regex`` - A regex string to match against origins that should be permitted to make cross-origin requests. eg. ``'https://.*\.example\.org'``.
|
||||
* ``allow_methods`` - A list of HTTP methods that should be allowed for cross-origin requests. Defaults to `['GET']`. You can use ``['*']`` to allow all standard methods.
|
||||
* ``allow_headers`` - A list of HTTP request headers that should be supported for cross-origin requests. Defaults to ``[]``. You can use ``['*']`` to allow all headers. The ``Accept``, ``Accept-Language``, ``Content-Language`` and ``Content-Type`` headers are always allowed for CORS requests.
|
||||
* ``allow_credentials`` - Indicate that cookies should be supported for cross-origin requests. Defaults to ``False``.
|
||||
* ``expose_headers`` - Indicate any response headers that should be made accessible to the browser. Defaults to ``[]``.
|
||||
* ``max_age`` - Sets a maximum time in seconds for browsers to cache CORS responses. Defaults to ``60``.
|
||||
|
||||
Trusted Hosts
|
||||
-------------
|
||||
|
||||
Make sure that all the incoming requests headers have a valid ``host``, that matches one of the provided patterns in the ``allowed_hosts`` attribute, in order to prevent HTTP Host Header attacks.
|
||||
Protect against HTTP Host header attacks by restricting which hostnames
|
||||
your application will respond to::
|
||||
|
||||
A 400 response will be raised, if a request does not match any of the provided patterns in the ``allowed_hosts`` attribute.
|
||||
api = responder.API(allowed_hosts=["example.com", "*.example.com"])
|
||||
|
||||
::
|
||||
Requests with a ``Host`` header that doesn't match any of the patterns
|
||||
will receive a 400 Bad Request response. Wildcard domains are supported.
|
||||
|
||||
api = responder.API(allowed_hosts=['example.com', 'tenant.example.com'])
|
||||
By default, all hostnames are allowed.
|
||||
|
||||
* ``allowed_hosts`` - A list of allowed hostnames.
|
||||
|
||||
Note:
|
||||
Server-Sent Events (SSE)
|
||||
------------------------
|
||||
|
||||
* By default, all hostnames are allowed.
|
||||
* Wildcard domains such as ``*.example.com`` are supported.
|
||||
* To allow any hostname use ``allowed_hosts=["*"]``.
|
||||
Stream real-time updates to the client using Server-Sent Events. This is
|
||||
great for live feeds, progress updates, and AI streaming responses::
|
||||
|
||||
@api.route("/events")
|
||||
async def events(req, resp):
|
||||
@resp.sse
|
||||
async def stream():
|
||||
for i in range(10):
|
||||
yield {"data": f"message {i}"}
|
||||
|
||||
Each yielded value can be a string (treated as data) or a dict with
|
||||
``data``, ``event``, ``id``, and ``retry`` fields::
|
||||
|
||||
yield {"event": "update", "data": "hello", "id": "1"}
|
||||
yield "simple string message"
|
||||
|
||||
|
||||
Streaming Files
|
||||
---------------
|
||||
|
||||
For large files, use ``resp.stream_file()`` to stream the content without
|
||||
loading the entire file into memory::
|
||||
|
||||
@api.route("/download")
|
||||
def download(req, resp):
|
||||
resp.stream_file("large-dataset.csv")
|
||||
|
||||
For small files where memory isn't a concern, ``resp.file()`` loads the
|
||||
entire file at once — simpler but less efficient for large files.
|
||||
|
||||
|
||||
After-Request Hooks
|
||||
-------------------
|
||||
|
||||
Run code after every request, useful for logging, adding headers, or
|
||||
cleanup::
|
||||
|
||||
@api.after_request()
|
||||
def log_response(req, resp):
|
||||
print(f"{req.method} {req.full_url} -> {resp.status_code}")
|
||||
|
||||
|
||||
Route Groups
|
||||
------------
|
||||
|
||||
Organize related routes with a shared URL prefix. Useful for API versioning
|
||||
and logical grouping::
|
||||
|
||||
v1 = api.group("/v1")
|
||||
|
||||
@v1.route("/users")
|
||||
def list_users(req, resp):
|
||||
resp.media = []
|
||||
|
||||
@v1.route("/users/{user_id:int}")
|
||||
def get_user(req, resp, *, user_id):
|
||||
resp.media = {"id": user_id}
|
||||
|
||||
|
||||
Request ID
|
||||
----------
|
||||
|
||||
Auto-generate unique request IDs for tracing and debugging. If the client
|
||||
sends an ``X-Request-ID`` header, it's forwarded; otherwise a new UUID is
|
||||
generated::
|
||||
|
||||
api = responder.API(request_id=True)
|
||||
|
||||
|
||||
Rate Limiting
|
||||
-------------
|
||||
|
||||
Built-in token bucket rate limiter::
|
||||
|
||||
from responder.ext.ratelimit import RateLimiter
|
||||
|
||||
limiter = RateLimiter(requests=100, period=60) # 100 req/min
|
||||
limiter.install(api)
|
||||
|
||||
When the limit is exceeded, clients receive a ``429 Too Many Requests``
|
||||
response with ``Retry-After`` and ``X-RateLimit-Remaining`` headers.
|
||||
|
||||
|
||||
MessagePack
|
||||
-----------
|
||||
|
||||
In addition to JSON and YAML, Responder supports MessagePack for efficient
|
||||
binary serialization::
|
||||
|
||||
# Decode MessagePack request body
|
||||
data = await req.media("msgpack")
|
||||
|
||||
# Content negotiation also works — clients can send
|
||||
# Accept: application/x-msgpack to receive MessagePack responses.
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# Example HTTP service definition, using Responder.
|
||||
# https://pypi.org/project/responder/
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
async def index(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Example showing the lifespan context manager pattern.
|
||||
# https://pypi.org/project/responder/
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import responder
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup: initialize resources
|
||||
print("Starting up...")
|
||||
yield
|
||||
# Shutdown: clean up resources
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
|
||||
@api.route("/{greeting}")
|
||||
async def greet_world(req, resp, *, greeting):
|
||||
resp.text = f"{greeting}, world!"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
@@ -0,0 +1,25 @@
|
||||
# Example HTTP service definition, using Responder.
|
||||
# https://pypi.org/project/responder/
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
async def index(req, resp):
|
||||
resp.text = "Welcome"
|
||||
|
||||
|
||||
@api.route("/user")
|
||||
async def user_create(req, resp):
|
||||
data = await req.media()
|
||||
resp.text = f"Hello, {data['username']}"
|
||||
|
||||
|
||||
@api.route("/user/{identifier}")
|
||||
async def user_get(req, resp, *, identifier):
|
||||
resp.text = f"Hello, user {identifier}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
+109
-42
@@ -1,15 +1,98 @@
|
||||
[build-system]
|
||||
build-backend = "setuptools.build_meta"
|
||||
requires = [
|
||||
"setuptools>=42", # At least v42 of setuptools required.
|
||||
"setuptools>=42",
|
||||
]
|
||||
|
||||
[project]
|
||||
name = "responder"
|
||||
description = "A familiar HTTP Service Framework for Python."
|
||||
readme = "README.md"
|
||||
license = {text = "Apache 2.0"}
|
||||
authors = [
|
||||
{ name = "Kenneth Reitz", email = "me@kennethreitz.org" },
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Web Environment",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
dependencies = [
|
||||
"a2wsgi",
|
||||
"apispec>=1.0.0",
|
||||
"chardet",
|
||||
"docopt-ng",
|
||||
"graphene>=3",
|
||||
"graphql-core>=3.1",
|
||||
"marshmallow",
|
||||
"msgpack",
|
||||
"pueblo[sfa-full]>=0.0.11",
|
||||
"pydantic>=2",
|
||||
"python-multipart",
|
||||
"starlette[full]>=0.40",
|
||||
"uvicorn[standard]",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
develop = [
|
||||
"pyproject-fmt",
|
||||
"ruff",
|
||||
"validate-pyproject",
|
||||
]
|
||||
docs = [
|
||||
"alabaster<1.1",
|
||||
"myst-parser",
|
||||
"sphinx>=5,<9",
|
||||
"sphinx-autobuild",
|
||||
"sphinx-copybutton",
|
||||
"sphinx-design-elements",
|
||||
]
|
||||
release = ["build", "twine"]
|
||||
test = [
|
||||
"flask",
|
||||
"mypy",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-mock",
|
||||
"pytest-rerunfailures",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
responder = "responder.ext.cli:cli"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/kennethreitz/responder"
|
||||
Documentation = "https://responder.kennethreitz.org"
|
||||
Repository = "https://github.com/kennethreitz/responder"
|
||||
Issues = "https://github.com/kennethreitz/responder/issues"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "responder.__version__.__version__"}
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
responder = ["py.typed"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
exclude = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 90
|
||||
|
||||
extend-exclude = [
|
||||
"docs/source/conf.py",
|
||||
"setup.py",
|
||||
]
|
||||
|
||||
lint.select = [
|
||||
@@ -44,6 +127,8 @@ lint.extend-ignore = [
|
||||
"S101", # Allow use of `assert`.
|
||||
]
|
||||
|
||||
lint.per-file-ignores."responder/util/cmd.py" = [ "A005" ] # Module shadows a Python standard-library module
|
||||
|
||||
lint.per-file-ignores."tests/*" = [
|
||||
"ERA001", # Found commented-out code.
|
||||
"S101", # Allow use of `assert`, and `print`.
|
||||
@@ -69,49 +154,31 @@ markers = [
|
||||
]
|
||||
xfail_strict = true
|
||||
|
||||
[tool.poe.tasks]
|
||||
|
||||
check = [
|
||||
"lint",
|
||||
"test",
|
||||
[tool.coverage.run]
|
||||
branch = false
|
||||
omit = [
|
||||
"*.html",
|
||||
"tests/*",
|
||||
]
|
||||
|
||||
docs-autobuild = [
|
||||
{ cmd = "sphinx-autobuild --open-browser --watch docs/source docs/build" },
|
||||
]
|
||||
docs-html = [
|
||||
{ cmd = "sphinx-build -W --keep-going docs/source docs/build" },
|
||||
]
|
||||
docs-linkcheck = [
|
||||
{ cmd = "sphinx-build -W --keep-going -b linkcheck docs/source docs/build" },
|
||||
[tool.coverage.report]
|
||||
fail_under = 0
|
||||
show_missing = true
|
||||
exclude_lines = [
|
||||
"# pragma: no cover",
|
||||
"raise NotImplemented",
|
||||
]
|
||||
|
||||
format = [
|
||||
{ cmd = "ruff format ." },
|
||||
# Configure Ruff not to auto-fix (remove!):
|
||||
# unused imports (F401), unused variables (F841), `print` statements (T201), and commented-out code (ERA001).
|
||||
{ cmd = "ruff check --fix --ignore=ERA --ignore=F401 --ignore=F841 --ignore=T20 --ignore=ERA001 ." },
|
||||
{ cmd = "pyproject-fmt --keep-full-version pyproject.toml" },
|
||||
[tool.mypy]
|
||||
packages = [
|
||||
"responder",
|
||||
]
|
||||
|
||||
lint = [
|
||||
{ cmd = "ruff format --check ." },
|
||||
{ cmd = "ruff check ." },
|
||||
{ cmd = "validate-pyproject pyproject.toml" },
|
||||
# { cmd = "mypy" },
|
||||
exclude = [
|
||||
]
|
||||
|
||||
release = [
|
||||
{ cmd = "python -m build" },
|
||||
{ cmd = "twine upload --skip-existing dist/*" },
|
||||
]
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
cmd = "pytest"
|
||||
help = "Invoke software tests"
|
||||
|
||||
[tool.poe.tasks.test.args.expression]
|
||||
options = [ "-k" ]
|
||||
|
||||
[tool.poe.tasks.test.args.marker]
|
||||
options = [ "-m" ]
|
||||
check_untyped_defs = true
|
||||
explicit_package_bases = true
|
||||
ignore_missing_imports = true
|
||||
implicit_optional = true
|
||||
install_types = true
|
||||
namespace_packages = true
|
||||
non_interactive = true
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
build:
|
||||
image: latest
|
||||
|
||||
python:
|
||||
version: 3.6
|
||||
@@ -1,9 +1,18 @@
|
||||
"""
|
||||
Responder - a familiar HTTP Service Framework.
|
||||
|
||||
This module exports the core functionality of the Responder framework,
|
||||
including the API, Request, and Response classes.
|
||||
"""
|
||||
|
||||
from . import ext
|
||||
from .__version__ import __version__
|
||||
from .core import API, Request, Response
|
||||
|
||||
__all__ = [
|
||||
"API",
|
||||
"Request",
|
||||
"Response",
|
||||
"__version__",
|
||||
"ext",
|
||||
]
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.0.7"
|
||||
__version__ = "3.2.0"
|
||||
|
||||
+227
-50
@@ -1,20 +1,22 @@
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["API"]
|
||||
|
||||
import uvicorn
|
||||
from starlette.exceptions import ExceptionMiddleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.errors import ServerErrorMiddleware
|
||||
from starlette.middleware.exceptions import ExceptionMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from . import status_codes
|
||||
from .background import BackgroundQueue
|
||||
from .ext.schema import OpenAPISchema as OpenAPISchema
|
||||
from .formats import get_formats
|
||||
from .models import Request, Response
|
||||
from .routes import Router
|
||||
from .staticfiles import StaticFiles
|
||||
from .statics import DEFAULT_CORS_PARAMS, DEFAULT_OPENAPI_THEME, DEFAULT_SECRET_KEY
|
||||
@@ -56,17 +58,19 @@ class API:
|
||||
cors_params=DEFAULT_CORS_PARAMS,
|
||||
allowed_hosts=None,
|
||||
openapi_theme=DEFAULT_OPENAPI_THEME,
|
||||
lifespan=None,
|
||||
request_id=False,
|
||||
):
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
self.secret_key = secret_key
|
||||
|
||||
self.router = Router()
|
||||
self.router = Router(lifespan=lifespan)
|
||||
|
||||
if static_dir is not None:
|
||||
if static_route is None:
|
||||
static_route = static_dir
|
||||
static_dir = Path(os.path.abspath(static_dir))
|
||||
static_route = ""
|
||||
static_dir = Path(static_dir).resolve()
|
||||
|
||||
self.static_dir = static_dir
|
||||
self.static_route = static_route
|
||||
@@ -77,22 +81,15 @@ class API:
|
||||
self.debug = debug
|
||||
|
||||
if not allowed_hosts:
|
||||
# if not debug:
|
||||
# raise RuntimeError(
|
||||
# "You need to specify `allowed_hosts` when debug is set to False"
|
||||
# ) # noqa: ERA001
|
||||
allowed_hosts = ["*"]
|
||||
self.allowed_hosts = allowed_hosts
|
||||
|
||||
if self.static_dir is not None:
|
||||
os.makedirs(self.static_dir, exist_ok=True)
|
||||
|
||||
if self.static_dir is not None:
|
||||
self.static_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.mount(self.static_route, self.static_app)
|
||||
|
||||
self.formats = get_formats()
|
||||
|
||||
# Cached requests session.
|
||||
self._session = None
|
||||
|
||||
self.default_endpoint = None
|
||||
@@ -110,6 +107,14 @@ class API:
|
||||
self.add_middleware(SessionMiddleware, secret_key=self.secret_key)
|
||||
|
||||
if openapi or docs_route:
|
||||
try:
|
||||
from .ext.openapi import OpenAPISchema
|
||||
except ImportError as ex:
|
||||
raise ImportError(
|
||||
"The dependencies for the OpenAPI extension are not installed. "
|
||||
"Install them using: pip install responder"
|
||||
) from ex
|
||||
|
||||
self.openapi = OpenAPISchema(
|
||||
app=self,
|
||||
title=title,
|
||||
@@ -125,11 +130,23 @@ class API:
|
||||
openapi_theme=openapi_theme,
|
||||
)
|
||||
|
||||
# TODO: Update docs for templates
|
||||
self.templates = Templates(directory=templates_dir)
|
||||
self.requests = (
|
||||
self.session()
|
||||
) #: A Requests session that is connected to the ASGI app.
|
||||
|
||||
if request_id:
|
||||
import uuid as _uuid
|
||||
|
||||
def _add_request_id(req, resp):
|
||||
rid = req.headers.get(
|
||||
"X-Request-ID", str(_uuid.uuid4())
|
||||
)
|
||||
resp.headers["X-Request-ID"] = rid
|
||||
|
||||
self.router.after_request(_add_request_id)
|
||||
|
||||
@property
|
||||
def requests(self):
|
||||
"""A test client connected to the ASGI app. Lazily initialized."""
|
||||
return self.session()
|
||||
|
||||
@property
|
||||
def static_app(self):
|
||||
@@ -145,12 +162,79 @@ class API:
|
||||
|
||||
return decorator
|
||||
|
||||
def after_request(self):
|
||||
"""Register a function to run after every request.
|
||||
|
||||
Usage::
|
||||
|
||||
@api.after_request()
|
||||
def add_request_id(req, resp):
|
||||
resp.headers["X-Request-ID"] = str(uuid.uuid4())
|
||||
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
self.router.after_request(f)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
def add_middleware(self, middleware_cls, **middleware_config):
|
||||
self.app = middleware_cls(self.app, **middleware_config)
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
def exception_handler(self, exception_cls):
|
||||
"""Register a handler for a specific exception type.
|
||||
|
||||
Usage::
|
||||
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle_value_error(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
async def _handler(request, exc):
|
||||
from starlette.responses import Response as StarletteResp
|
||||
|
||||
req = Request(request.scope, request.receive, formats=get_formats())
|
||||
resp = Response(req=req, formats=get_formats())
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
await func(req, resp, exc)
|
||||
else:
|
||||
func(req, resp, exc)
|
||||
if resp.status_code is None:
|
||||
resp.status_code = 500
|
||||
body, headers = await resp.body
|
||||
return StarletteResp(
|
||||
content=body, status_code=resp.status_code, headers=headers
|
||||
)
|
||||
|
||||
# Register with the ExceptionMiddleware
|
||||
self.router._exception_handlers = getattr(
|
||||
self.router, "_exception_handlers", {}
|
||||
)
|
||||
self.router._exception_handlers[exception_cls] = _handler
|
||||
# Also register on the ASGI app chain
|
||||
from starlette.middleware.exceptions import ExceptionMiddleware as EM
|
||||
|
||||
app = self.app
|
||||
while app is not None:
|
||||
if isinstance(app, EM):
|
||||
app.add_exception_handler(exception_cls, _handler)
|
||||
break
|
||||
app = getattr(app, "app", None)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""
|
||||
Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
|
||||
from marshmallow import Schema, fields
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
@@ -184,6 +268,7 @@ class API:
|
||||
check_existing=True,
|
||||
websocket=False,
|
||||
before_request=False,
|
||||
methods=None,
|
||||
):
|
||||
"""Adds a route to the API.
|
||||
|
||||
@@ -192,9 +277,9 @@ class API:
|
||||
:param default: If ``True``, all unknown requests will route to this view.
|
||||
:param static: If ``True``, and no endpoint was passed, render "static/index.html".
|
||||
Also, it will become a default route.
|
||||
:param methods: Optional list of HTTP methods (e.g. ``["GET", "POST"]``).
|
||||
""" # noqa: E501
|
||||
|
||||
# Path
|
||||
if static:
|
||||
assert self.static_dir is not None
|
||||
if not endpoint:
|
||||
@@ -208,23 +293,30 @@ class API:
|
||||
websocket=websocket,
|
||||
before_request=before_request,
|
||||
check_existing=check_existing,
|
||||
methods=methods,
|
||||
)
|
||||
|
||||
async def _static_response(self, req, resp):
|
||||
assert self.static_dir is not None
|
||||
|
||||
index = (self.static_dir / "index.html").resolve()
|
||||
if os.path.exists(index):
|
||||
with open(index, "r") as f:
|
||||
resp.html = f.read()
|
||||
if index.exists():
|
||||
resp.html = index.read_text()
|
||||
else:
|
||||
resp.status_code = status_codes.HTTP_404
|
||||
resp.status_code = status_codes.HTTP_404 # type: ignore[attr-defined]
|
||||
resp.text = "Not found."
|
||||
|
||||
def redirect(
|
||||
self, resp, location, *, set_text=True, status_code=status_codes.HTTP_301
|
||||
self,
|
||||
resp,
|
||||
location,
|
||||
*,
|
||||
set_text=True,
|
||||
status_code=status_codes.HTTP_301, # type: ignore[attr-defined]
|
||||
):
|
||||
"""Redirects a given response to a given location.
|
||||
"""
|
||||
Redirects a given response to a given location.
|
||||
|
||||
:param resp: The Response to mutate.
|
||||
:param location: The location of the redirect.
|
||||
:param set_text: If ``True``, sets the Redirect body content automatically.
|
||||
@@ -264,7 +356,7 @@ class API:
|
||||
|
||||
self.router.add_event_handler(event_type, handler)
|
||||
|
||||
def route(self, route=None, **options):
|
||||
def route(self, route=None, *, request_model=None, response_model=None, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
|
||||
Usage::
|
||||
@@ -273,14 +365,66 @@ class API:
|
||||
def hello(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
With Pydantic models for OpenAPI documentation::
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ItemIn(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
class ItemOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
price: float
|
||||
|
||||
@api.route("/items", methods=["POST"],
|
||||
request_model=ItemIn, response_model=ItemOut)
|
||||
async def create_item(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"id": 1, **data}
|
||||
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
if request_model is not None:
|
||||
f._request_model = request_model
|
||||
if hasattr(self, "openapi"):
|
||||
self.openapi.add_schema(
|
||||
request_model.__name__, request_model, check_existing=False
|
||||
)
|
||||
if response_model is not None:
|
||||
f._response_model = response_model
|
||||
if hasattr(self, "openapi"):
|
||||
self.openapi.add_schema(
|
||||
response_model.__name__, response_model, check_existing=False
|
||||
)
|
||||
self.add_route(route, f, **options)
|
||||
return f
|
||||
|
||||
return decorator
|
||||
|
||||
def graphql(self, route="/graphql", *, schema):
|
||||
"""Mount a GraphQL API at the given route.
|
||||
|
||||
Usage::
|
||||
|
||||
import graphene
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
api.graphql("/graphql", schema=graphene.Schema(query=Query))
|
||||
|
||||
:param route: The URL path for the GraphQL endpoint.
|
||||
:param schema: A Graphene schema instance.
|
||||
"""
|
||||
from .ext.graphql import GraphQLView
|
||||
|
||||
self.add_route(route, GraphQLView(api=self, schema=schema))
|
||||
|
||||
def mount(self, route, app):
|
||||
"""Mounts an WSGI / ASGI application at a given route.
|
||||
|
||||
@@ -291,18 +435,19 @@ class API:
|
||||
self.router.apps.update({route: app})
|
||||
|
||||
def session(self, base_url="http://;"):
|
||||
"""Testing HTTP client. Returns a Requests session object,
|
||||
"""Testing HTTP client. Returns a Starlette TestClient instance,
|
||||
able to send HTTP requests to the Responder application.
|
||||
|
||||
:param base_url: The URL to mount the connection adaptor to.
|
||||
:param base_url: The base URL for the test client.
|
||||
"""
|
||||
|
||||
if self._session is None:
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
self._session = TestClient(self, base_url=base_url)
|
||||
return self._session
|
||||
|
||||
def url_for(self, endpoint, **params):
|
||||
# TODO: Absolute_url
|
||||
"""Given an endpoint, returns a rendered URL for its route.
|
||||
|
||||
:param endpoint: The route endpoint you're searching for.
|
||||
@@ -311,29 +456,29 @@ class API:
|
||||
return self.router.url_for(endpoint, **params)
|
||||
|
||||
def template(self, filename, *args, **kwargs):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template, with provided values supplied.
|
||||
|
||||
Note: The current ``api`` instance is by default passed into the view. This is set in the dict ``api.jinja_values_base``.
|
||||
r"""Render a Jinja2 template file with the provided values.
|
||||
|
||||
:param filename: The filename of the jinja2 template, in ``templates_dir``.
|
||||
:param *args: Data to pass into the template.
|
||||
:param *kwargs: Date to pass into the template.
|
||||
""" # noqa: E501
|
||||
:param \*args: Data to pass into the template.
|
||||
:param \*\*kwargs: Data to pass into the template.
|
||||
"""
|
||||
return self.templates.render(filename, *args, **kwargs)
|
||||
|
||||
def template_string(self, source, *args, **kwargs):
|
||||
"""Renders the given `jinja2 <http://jinja.pocoo.org/docs/>`_ template string, with provided values supplied.
|
||||
Note: The current ``api`` instance is by default passed into the view. This is set in the dict ``api.jinja_values_base``.
|
||||
:param source: The template to use.
|
||||
:param *args: Data to pass into the template.
|
||||
:param **kwargs: Data to pass into the template.
|
||||
""" # noqa: E501
|
||||
r"""Render a Jinja2 template string with the provided values.
|
||||
|
||||
:param source: The template to use, a Jinja2 template string.
|
||||
:param \*args: Data to pass into the template.
|
||||
:param \*\*kwargs: Data to pass into the template.
|
||||
"""
|
||||
return self.templates.render_string(source, *args, **kwargs)
|
||||
|
||||
def serve(self, *, address=None, port=None, debug=False, **options):
|
||||
"""Runs the application with uvicorn. If the ``PORT`` environment
|
||||
variable is set, requests will be served on that port automatically to all
|
||||
known hosts.
|
||||
"""
|
||||
Run the application with uvicorn.
|
||||
|
||||
If the ``PORT`` environment variable is set, requests will be served on that port
|
||||
automatically to all known hosts.
|
||||
|
||||
:param address: The address to bind to.
|
||||
:param port: The port to bind to. If none is provided, one will be selected at random.
|
||||
@@ -350,16 +495,48 @@ class API:
|
||||
address = "127.0.0.1"
|
||||
if port is None:
|
||||
port = 5042
|
||||
if debug:
|
||||
options["log_level"] = "debug"
|
||||
|
||||
def spawn():
|
||||
uvicorn.run(self, host=address, port=port, debug=debug, **options)
|
||||
|
||||
spawn()
|
||||
uvicorn.run(self, host=address, port=port, **options)
|
||||
|
||||
def run(self, **kwargs):
|
||||
if "debug" not in kwargs:
|
||||
kwargs.update({"debug": self.debug})
|
||||
self.serve(**kwargs)
|
||||
|
||||
def group(self, prefix):
|
||||
"""Create a route group with a shared URL prefix.
|
||||
|
||||
Usage::
|
||||
|
||||
v1 = api.group("/v1")
|
||||
|
||||
@v1.route("/users")
|
||||
def list_users(req, resp):
|
||||
resp.media = []
|
||||
|
||||
@v1.route("/users/{id:int}")
|
||||
def get_user(req, resp, *, id):
|
||||
resp.media = {"id": id}
|
||||
|
||||
"""
|
||||
return RouteGroup(api=self, prefix=prefix)
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
class RouteGroup:
|
||||
"""A group of routes with a shared URL prefix."""
|
||||
|
||||
def __init__(self, api, prefix):
|
||||
self.api = api
|
||||
self.prefix = prefix.rstrip("/")
|
||||
|
||||
def route(self, route=None, **options):
|
||||
full_route = f"{self.prefix}{route}"
|
||||
return self.api.route(full_route, **options)
|
||||
|
||||
def before_request(self, **kwargs):
|
||||
return self.api.before_request(**kwargs)
|
||||
|
||||
@@ -5,6 +5,8 @@ import traceback
|
||||
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
__all__ = ["BackgroundQueue"]
|
||||
|
||||
|
||||
class BackgroundQueue:
|
||||
def __init__(self, n=None):
|
||||
@@ -16,9 +18,6 @@ class BackgroundQueue:
|
||||
self.results = []
|
||||
|
||||
def run(self, f, *args, **kwargs):
|
||||
self.pool._max_workers = self.n
|
||||
self.pool._adjust_thread_count()
|
||||
|
||||
f = self.pool.submit(f, *args, **kwargs)
|
||||
self.results.append(f)
|
||||
return f
|
||||
@@ -39,5 +38,5 @@ class BackgroundQueue:
|
||||
|
||||
async def __call__(self, func, *args, **kwargs) -> None:
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return await asyncio.ensure_future(func(*args, **kwargs))
|
||||
return await asyncio.create_task(func(*args, **kwargs))
|
||||
return await run_in_threadpool(func, *args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Responder CLI.
|
||||
|
||||
A web framework for Python.
|
||||
|
||||
Commands:
|
||||
run Start the application server
|
||||
build Build frontend assets using npm
|
||||
|
||||
Usage:
|
||||
responder
|
||||
responder run [--debug] [--limit-max-requests=] <target>
|
||||
responder build [<target>]
|
||||
responder --version
|
||||
|
||||
Options:
|
||||
-h --help Show this screen.
|
||||
-v --version Show version.
|
||||
--debug Enable debug mode with verbose logging.
|
||||
--limit-max-requests=<n> Maximum number of requests to handle before shutting down.
|
||||
|
||||
Arguments:
|
||||
<target> For run: Python module specifier (e.g., "app:api" loads api from app.py)
|
||||
Format: "module.submodule:variable_name" where variable_name is your API instance
|
||||
For build: Directory containing package.json (default: current directory)
|
||||
|
||||
Examples:
|
||||
responder run app:api # Run the 'api' instance from app.py
|
||||
responder run myapp/core.py:application # Run the 'application' instance from myapp/core.py
|
||||
responder build # Build frontend assets
|
||||
""" # noqa: E501
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import typing as t
|
||||
from pathlib import Path
|
||||
|
||||
import docopt
|
||||
|
||||
from responder.__version__ import __version__
|
||||
from responder.util.python import InvalidTarget, load_target
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
"""
|
||||
Main entry point for the Responder CLI.
|
||||
|
||||
Parses command line arguments and executes the appropriate command.
|
||||
Supports running the application, building assets, and displaying version info.
|
||||
"""
|
||||
args = docopt.docopt(__doc__, argv=None, version=__version__, options_first=False)
|
||||
setup_logging(args["--debug"])
|
||||
|
||||
target: t.Optional[str] = args["<target>"]
|
||||
build: bool = args["build"]
|
||||
debug: bool = args["--debug"]
|
||||
run: bool = args["run"]
|
||||
|
||||
if build:
|
||||
target_path = Path(target).resolve() if target else Path.cwd()
|
||||
if not target_path.is_dir() or not (target_path / "package.json").exists():
|
||||
logger.error(
|
||||
f"Invalid target directory or missing package.json: {target_path}"
|
||||
)
|
||||
sys.exit(1)
|
||||
npm_cmd = "npm.cmd" if platform.system() == "Windows" else "npm"
|
||||
try:
|
||||
logger.info("Starting frontend asset build")
|
||||
# S603, S607 are addressed by validating the target directory.
|
||||
subprocess.check_call( # noqa: S603, S607
|
||||
[npm_cmd, "run", "build"],
|
||||
cwd=target_path,
|
||||
timeout=300,
|
||||
)
|
||||
logger.info("Frontend asset build completed successfully")
|
||||
except FileNotFoundError:
|
||||
logger.error("npm not found. Please install Node.js and npm.")
|
||||
sys.exit(1)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Build failed with exit code {e.returncode}")
|
||||
sys.exit(1)
|
||||
|
||||
if run:
|
||||
if not target:
|
||||
logger.error("Target argument is required for run command")
|
||||
sys.exit(1)
|
||||
|
||||
# Maximum request limit. Terminating afterward. Suitable for software testing.
|
||||
limit_max_requests = args["--limit-max-requests"]
|
||||
if limit_max_requests is not None:
|
||||
try:
|
||||
limit_max_requests = int(limit_max_requests)
|
||||
if limit_max_requests <= 0:
|
||||
logger.error("limit-max-requests must be a positive integer")
|
||||
sys.exit(1)
|
||||
except ValueError:
|
||||
logger.error("limit-max-requests must be a valid integer")
|
||||
sys.exit(1)
|
||||
|
||||
# Load application from target.
|
||||
try:
|
||||
api = load_target(target=target)
|
||||
except InvalidTarget as ex:
|
||||
raise ValueError(
|
||||
f"{ex}. "
|
||||
"Use either a Python module entrypoint specification, "
|
||||
"a filesystem path, or a remote URL. "
|
||||
"See also https://responder.kennethreitz.org/cli.html."
|
||||
) from ex
|
||||
|
||||
# Launch Responder API server (uvicorn).
|
||||
api.run(debug=debug, limit_max_requests=limit_max_requests)
|
||||
|
||||
|
||||
def setup_logging(debug: bool) -> None:
|
||||
"""
|
||||
Configure logging based on debug mode.
|
||||
|
||||
Args:
|
||||
debug: When True, sets logging level to DEBUG; otherwise, sets to INFO
|
||||
"""
|
||||
log_level = logging.DEBUG if debug else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
|
||||
from .templates import GRAPHIQL
|
||||
|
||||
|
||||
class GraphQLView:
|
||||
def __init__(self, *, api, schema):
|
||||
self.api = api
|
||||
self.schema = schema
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_graphql_query(req, resp):
|
||||
if "json" in req.mimetype:
|
||||
json_media = await req.media("json")
|
||||
if "query" not in json_media:
|
||||
resp.status_code = 400
|
||||
resp.media = {"errors": ["'query' key is required in the JSON payload"]}
|
||||
return None, None, None
|
||||
return (
|
||||
json_media["query"],
|
||||
json_media.get("variables"),
|
||||
json_media.get("operationName"),
|
||||
)
|
||||
|
||||
# Support query/q in params.
|
||||
if "query" in req.params:
|
||||
return req.params["query"], None, None
|
||||
if "q" in req.params:
|
||||
return req.params["q"], None, None
|
||||
|
||||
# Otherwise, the request text is used (typical).
|
||||
return await req.text, None, None
|
||||
|
||||
async def graphql_response(self, req, resp):
|
||||
show_graphiql = req.method == "get" and req.accepts("text/html")
|
||||
|
||||
if show_graphiql:
|
||||
resp.content = self.api.templates.render_string(
|
||||
GRAPHIQL, endpoint=req.url.path
|
||||
)
|
||||
return None
|
||||
|
||||
query, variables, operation_name = await self._resolve_graphql_query(req, resp)
|
||||
if query is None:
|
||||
return None
|
||||
|
||||
context = {"request": req, "response": resp}
|
||||
result = self.schema.execute(
|
||||
query, variables=variables, operation_name=operation_name, context=context
|
||||
)
|
||||
|
||||
response_data = {}
|
||||
if result.errors:
|
||||
response_data["errors"] = [{"message": str(e)} for e in result.errors]
|
||||
if result.data is not None:
|
||||
response_data["data"] = result.data
|
||||
|
||||
resp.media = response_data
|
||||
status_code = 200 if not result.errors else 400
|
||||
return (query, json.dumps(response_data), status_code)
|
||||
|
||||
async def on_request(self, req, resp):
|
||||
await self.graphql_response(req, resp)
|
||||
|
||||
async def __call__(self, req, resp):
|
||||
await self.on_request(req, resp)
|
||||
@@ -0,0 +1,34 @@
|
||||
# ruff: noqa: E501
|
||||
GRAPHIQL = """
|
||||
{% set GRAPHIQL_VERSION = '3.0.6' %}
|
||||
{% set REACT_VERSION = '18.2.0' %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#graphiql {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<link href="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="graphiql">Loading...</div>
|
||||
<script crossorigin src="//cdn.jsdelivr.net/npm/react@{{ REACT_VERSION }}/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="//cdn.jsdelivr.net/npm/react-dom@{{ REACT_VERSION }}/umd/react-dom.production.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/graphiql@{{ GRAPHIQL_VERSION }}/graphiql.min.js"></script>
|
||||
<script>
|
||||
const fetcher = GraphiQL.createFetcher({ url: '{{ endpoint }}' });
|
||||
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
|
||||
root.render(React.createElement(GraphiQL, { fetcher: fetcher }));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".strip()
|
||||
@@ -8,6 +8,38 @@ from responder.statics import API_THEMES, DEFAULT_OPENAPI_THEME
|
||||
from responder.templates import Templates
|
||||
|
||||
|
||||
def _is_pydantic_model(obj):
|
||||
"""Check if obj is a Pydantic model class."""
|
||||
try:
|
||||
from pydantic import BaseModel
|
||||
|
||||
return isinstance(obj, type) and issubclass(obj, BaseModel)
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
class PydanticPlugin:
|
||||
"""APISpec plugin that resolves Pydantic models to JSON Schema."""
|
||||
|
||||
def __init__(self):
|
||||
self._schemas = {}
|
||||
|
||||
def definition_helper(self, name, definition, **kwargs):
|
||||
schema = kwargs.get("schema")
|
||||
if schema is not None and _is_pydantic_model(schema):
|
||||
return schema.model_json_schema()
|
||||
return None
|
||||
|
||||
def resolve_schemas(self, spec):
|
||||
pass
|
||||
|
||||
def init_spec(self, spec):
|
||||
pass
|
||||
|
||||
def operation_helper(self, **kwargs):
|
||||
return {}
|
||||
|
||||
|
||||
class OpenAPISchema:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -27,6 +59,7 @@ class OpenAPISchema:
|
||||
):
|
||||
self.app = app
|
||||
self.schemas = {}
|
||||
self.pydantic_schemas = {}
|
||||
self.title = title
|
||||
self.version = version
|
||||
self.description = description
|
||||
@@ -80,9 +113,56 @@ class OpenAPISchema:
|
||||
operations = yaml_utils.load_operations_from_docstring(route.description)
|
||||
spec.path(path=route.route, operations=operations)
|
||||
|
||||
# Check for Pydantic-annotated routes
|
||||
endpoint = route.endpoint
|
||||
req_model = getattr(endpoint, "_request_model", None)
|
||||
resp_model = getattr(endpoint, "_response_model", None)
|
||||
|
||||
if req_model or resp_model:
|
||||
operations = {}
|
||||
methods = getattr(route, "methods", None) or ["get"]
|
||||
|
||||
for method in [m.lower() for m in methods]:
|
||||
op = {}
|
||||
if req_model and method in ("post", "put", "patch"):
|
||||
model_name = req_model.__name__
|
||||
op["requestBody"] = {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": f"#/components/schemas/{model_name}"}
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp_model:
|
||||
model_name = resp_model.__name__
|
||||
op["responses"] = {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": f"#/components/schemas/{model_name}"
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if op:
|
||||
operations[method] = op
|
||||
|
||||
if operations and not route.description:
|
||||
spec.path(path=route.route, operations=operations)
|
||||
|
||||
# Register marshmallow schemas
|
||||
for name, schema in self.schemas.items():
|
||||
spec.components.schema(name, schema=schema)
|
||||
|
||||
# Register Pydantic schemas
|
||||
for name, model in self.pydantic_schemas.items():
|
||||
json_schema = model.model_json_schema()
|
||||
json_schema.pop("title", None)
|
||||
spec.components.schema(name, component=json_schema)
|
||||
|
||||
return spec
|
||||
|
||||
@property
|
||||
@@ -90,14 +170,18 @@ class OpenAPISchema:
|
||||
return self._apispec.to_yaml()
|
||||
|
||||
def add_schema(self, name, schema, check_existing=True):
|
||||
"""Adds a marshmallow schema to the API specification."""
|
||||
"""Adds a marshmallow or Pydantic schema to the API specification."""
|
||||
if check_existing:
|
||||
assert name not in self.schemas
|
||||
assert name not in self.pydantic_schemas
|
||||
|
||||
self.schemas[name] = schema
|
||||
if _is_pydantic_model(schema):
|
||||
self.pydantic_schemas[name] = schema
|
||||
else:
|
||||
self.schemas[name] = schema
|
||||
|
||||
def schema(self, name, **options):
|
||||
"""Decorator for creating new routes around function and class definitions.
|
||||
"""Decorator for registering schemas (marshmallow or Pydantic).
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -107,6 +191,15 @@ class OpenAPISchema:
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
Or with Pydantic::
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@api.schema("Pet")
|
||||
class Pet(BaseModel):
|
||||
name: str
|
||||
age: int = 0
|
||||
|
||||
"""
|
||||
|
||||
def decorator(f):
|
||||
@@ -133,6 +226,6 @@ class OpenAPISchema:
|
||||
resp.html = self.docs
|
||||
|
||||
def schema_response(self, req, resp):
|
||||
resp.status_code = status_codes.HTTP_200
|
||||
resp.status_code = status_codes.HTTP_200 # type: ignore[attr-defined]
|
||||
resp.headers["Content-Type"] = "application/x-yaml"
|
||||
resp.content = self.openapi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user