mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 23:00:17 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c2b1acc19 | |||
| a3b49ab9fd | |||
| bf17b02653 | |||
| 9383cd0f16 | |||
| 226bd63ed3 | |||
| b3c55f68d9 | |||
| a2a2ae21ff | |||
| 84074860aa | |||
| 21baa03640 | |||
| 0c552e25cb | |||
| 24958bff51 | |||
| 364f6b67f7 | |||
| 2cab7b5af7 | |||
| 1bfd85b003 | |||
| 33ebc77f10 |
@@ -11,6 +11,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
|
||||
documentation:
|
||||
@@ -40,3 +43,11 @@ jobs:
|
||||
|
||||
- name: Build static HTML documentation
|
||||
run: sphinx-build -W --keep-going docs/source docs/build
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs/build
|
||||
cname: responder.kennethreitz.org
|
||||
|
||||
@@ -7,6 +7,44 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v3.2.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
- Pydantic auto-validation: `request_model` validates input, returns 422 on failure
|
||||
- Pydantic auto-serialization: `response_model` strips extra fields from responses
|
||||
- Server-Sent Events: `@resp.sse` for real-time streaming
|
||||
- `resp.stream_file()` for streaming large files without loading into memory
|
||||
- `@api.after_request()` hooks
|
||||
- `api.group("/prefix")` for route groups and API versioning
|
||||
- `api.graphql("/path", schema=schema)` one-liner GraphQL setup
|
||||
- `api = responder.API(request_id=True)` for automatic request ID generation
|
||||
- Built-in rate limiter: `RateLimiter(requests=100, period=60).install(api)`
|
||||
- MessagePack format support: `await req.media("msgpack")`
|
||||
- `req.is_json`, `req.path_params`, `req.client` properties
|
||||
- `api.exception_handler()` decorator for custom error handling
|
||||
- Lifespan context manager support
|
||||
- `uuid` and `path` route convertors
|
||||
- PEP 561 `py.typed` marker
|
||||
- Pydantic support for OpenAPI schema generation
|
||||
|
||||
### Changed
|
||||
|
||||
- Dependencies flattened: `pip install responder` gets everything
|
||||
- Core deps reduced to starlette + uvicorn
|
||||
- TestClient lazy-loaded (no httpx import in production)
|
||||
- Before-request hooks can short-circuit by setting status code
|
||||
- Removed poethepoet task runner
|
||||
|
||||
### Fixed
|
||||
|
||||
- Multipart parser losing headers when parts have multiple headers
|
||||
- `url_for()` with typed route params (`{id:int}`)
|
||||
- `resp.body` encoding crash on bytes content
|
||||
- GraphQL text query missing `await`
|
||||
- Streaming responses not sending Content-Type headers
|
||||
- Python 3.9 compatibility for union type syntax
|
||||
|
||||
## [v3.0.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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,242 +1,21 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="https://cloud.typography.com/7584432/7586812/css/fonts.css"
|
||||
/>
|
||||
<script async defer src="https://buttons.github.io/buttons.js"></script>
|
||||
|
||||
<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,89 +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>
|
||||
<a class="github-button"
|
||||
href="https://github.com/kennethreitz/responder"
|
||||
data-color-scheme="no-preference: light; light: light; dark: light;"
|
||||
data-size="large"
|
||||
data-show-count="true"
|
||||
aria-label="Star kennethreitz/responder on GitHub">Star</a>
|
||||
<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>
|
||||
<a class="github-button"
|
||||
href="https://github.com/kennethreitz"
|
||||
data-color-scheme="no-preference: light; light: light; dark: light;"
|
||||
data-size="medium"
|
||||
data-show-count="true"
|
||||
aria-label="Follow @kennethreitz on GitHub">Follow @kennethreitz</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a
|
||||
href="https://x.com/kennethreitz42"
|
||||
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://x.com/kennethreitz42"
|
||||
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>
|
||||
+38
-13
@@ -1,35 +1,60 @@
|
||||
API Reference
|
||||
=============
|
||||
|
||||
API Documentation
|
||||
=================
|
||||
This page documents Responder's public Python API. For usage examples
|
||||
and explanations, see the :doc:`quickstart` and :doc:`tour`.
|
||||
|
||||
|
||||
Web Service (API) Class
|
||||
-----------------------
|
||||
The API Class
|
||||
-------------
|
||||
|
||||
The central object of every Responder application. It holds your routes,
|
||||
middleware, templates, and configuration. Create one at the top of your
|
||||
module and use it to define your entire web service.
|
||||
|
||||
.. module:: responder
|
||||
|
||||
.. autoclass:: API
|
||||
:inherited-members:
|
||||
|
||||
Requests & Responses
|
||||
--------------------
|
||||
|
||||
Request
|
||||
-------
|
||||
|
||||
The request object is passed into every view as the first argument. It
|
||||
gives you access to everything the client sent — headers, query
|
||||
parameters, the request body, cookies, and more.
|
||||
|
||||
Most properties are synchronous, but reading the body requires ``await``
|
||||
because it involves I/O.
|
||||
|
||||
.. autoclass:: Request
|
||||
:inherited-members:
|
||||
|
||||
|
||||
Response
|
||||
--------
|
||||
|
||||
The response object is passed into every view as the second argument.
|
||||
Mutate it to control what gets sent back to the client — the body,
|
||||
status code, headers, and cookies.
|
||||
|
||||
.. autoclass:: Response
|
||||
:inherited-members:
|
||||
|
||||
|
||||
Utility Functions
|
||||
-----------------
|
||||
Status Code Helpers
|
||||
-------------------
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_100
|
||||
Convenience functions for checking which category a status code falls
|
||||
into. Useful in middleware and after-request hooks.
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_200
|
||||
.. autofunction:: responder.status_codes.is_100
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_300
|
||||
.. autofunction:: responder.status_codes.is_200
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_400
|
||||
.. autofunction:: responder.status_codes.is_300
|
||||
|
||||
.. autofunction:: responder.API.status_codes.is_500
|
||||
.. autofunction:: responder.status_codes.is_400
|
||||
|
||||
.. autofunction:: responder.status_codes.is_500
|
||||
|
||||
+78
-171
@@ -1,174 +1,81 @@
|
||||
Responder CLI
|
||||
=============
|
||||
Command Line Interface
|
||||
======================
|
||||
|
||||
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
|
||||
Responder installs a ``responder`` command that lets you launch
|
||||
applications from the terminal. You can point it at a Python module,
|
||||
a local file, or even a URL — and it will find your ``API`` instance
|
||||
and start serving.
|
||||
|
||||
|
||||
.. _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
|
||||
Launching from a Module
|
||||
-----------------------
|
||||
|
||||
The most common way to run a Responder application in production. Use
|
||||
Python's standard dotted module path::
|
||||
|
||||
$ responder run acme.app
|
||||
|
||||
This imports ``acme.app`` and looks for an attribute called ``api``
|
||||
(a ``responder.API`` instance). It's the same import system Python
|
||||
uses everywhere — your ``PYTHONPATH`` and virtual environment are
|
||||
respected.
|
||||
|
||||
|
||||
Launching from a File
|
||||
---------------------
|
||||
|
||||
During development, you often have a single file you want to run::
|
||||
|
||||
$ responder run helloworld.py
|
||||
|
||||
This loads the file directly and starts the server. Quick and easy for
|
||||
prototyping and single-file applications.
|
||||
|
||||
You can test it with a simple HTTP request::
|
||||
|
||||
$ curl http://127.0.0.1:5042/hello
|
||||
hello, world!
|
||||
|
||||
|
||||
Launching from a URL
|
||||
--------------------
|
||||
|
||||
Responder can fetch and run a Python file from any URL — great for
|
||||
demos, sharing examples, and running code from GitHub::
|
||||
|
||||
$ responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
|
||||
|
||||
This also works with ``github://`` URLs and any filesystem protocol
|
||||
supported by `fsspec <https://filesystem-spec.readthedocs.io/>`_::
|
||||
|
||||
$ responder run github://kennethreitz:responder@/examples/helloworld.py
|
||||
|
||||
Cloud storage is supported too — Azure Blob Storage, Google Cloud
|
||||
Storage, S3, HDFS, SFTP, and more. Install ``fsspec[full]`` for all
|
||||
protocols::
|
||||
|
||||
$ uv pip install 'fsspec[full]'
|
||||
|
||||
|
||||
Custom Instance Names
|
||||
---------------------
|
||||
|
||||
By default, Responder looks for an attribute called ``api``. If your
|
||||
application uses a different name, specify it with a colon::
|
||||
|
||||
$ responder run acme.app:service
|
||||
$ responder run myapp.py:application
|
||||
|
||||
For URLs, use a fragment::
|
||||
|
||||
$ responder run https://example.com/app.py#service
|
||||
|
||||
|
||||
Building Frontend Assets
|
||||
-------------------------
|
||||
|
||||
If your project includes a JavaScript frontend with a ``package.json``,
|
||||
the ``build`` subcommand runs ``npm run build``::
|
||||
|
||||
$ responder build
|
||||
$ responder build /path/to/frontend
|
||||
|
||||
+12
-252
@@ -1,108 +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-2026, 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",
|
||||
"sphinx_design_elements",
|
||||
"sphinxext.opengraph",
|
||||
]
|
||||
|
||||
# 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": "restructuredtext"}
|
||||
|
||||
# The master toctree document.
|
||||
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",
|
||||
@@ -110,183 +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")
|
||||
]
|
||||
|
||||
|
||||
# -- 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 link checker ----------------------------------------------
|
||||
linkcheck_ignore = [
|
||||
# Feldroy.com links are ignored because it blocks GHA.
|
||||
r"https://www.feldroy.com/.*",
|
||||
]
|
||||
linkcheck_anchors_ignore_for_url = [
|
||||
# Requires JavaScript.
|
||||
# After opting-in to new GitHub issues, Sphinx can no longer grok the HTML anchor references.
|
||||
r"https://github.com",
|
||||
]
|
||||
|
||||
# -- Options for intersphinx extension ---------------------------------------
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
|
||||
|
||||
# -- Options for todo extension ----------------------------------------------
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = True
|
||||
|
||||
# -- Options for MyST --------------------------------------------------------
|
||||
|
||||
# MyST
|
||||
myst_heading_anchors = 3
|
||||
myst_enable_extensions = [
|
||||
"attrs_block",
|
||||
"attrs_inline",
|
||||
"colon_fence",
|
||||
"deflist",
|
||||
"fieldlist",
|
||||
"html_admonition",
|
||||
"html_image",
|
||||
"linkify",
|
||||
"replacements",
|
||||
"strikethrough",
|
||||
"substitution",
|
||||
"tasklist",
|
||||
]
|
||||
myst_substitutions = {}
|
||||
|
||||
# -- Options for OpenGraph ---------------------------------------------------
|
||||
#
|
||||
# When making changes, check them using the RTD PR preview URL on https://www.opengraph.xyz/.
|
||||
#
|
||||
# About text lengths
|
||||
#
|
||||
# Original documentation says:
|
||||
# - ogp_description_length
|
||||
# Configure the amount of characters taken from a page. The default of 200 is probably good
|
||||
# for most people. If something other than a number is used, it defaults back to 200.
|
||||
# -- https://sphinxext-opengraph.readthedocs.io/en/latest/#options
|
||||
#
|
||||
# Other people say:
|
||||
# - og:title 40 chars
|
||||
# - og:description has 2 max lengths:
|
||||
# When the link is used in a Post, it's 300 chars. When a link is used in a Comment, it's 110 chars.
|
||||
# So you can either treat it as 110, or, write your Descriptions to 300 but make sure the first 110
|
||||
# is the critical part and still makes sense when it gets cut off.
|
||||
# -- https://stackoverflow.com/questions/8914476/facebook-open-graph-meta-tags-maximum-content-length
|
||||
ogp_site_url = "https://responder.kennethreitz.org/"
|
||||
ogp_description_length = 300
|
||||
ogp_site_name = "Responder Documentation"
|
||||
ogp_image = "https://responder.kennethreitz.org/_static/responder.png"
|
||||
ogp_image_alt = False
|
||||
ogp_use_first_image = False
|
||||
ogp_type = "website"
|
||||
ogp_enable_meta_description = True
|
||||
|
||||
# -- Options for sphinx-copybutton -------------------------------------------
|
||||
|
||||
# Copybutton
|
||||
copybutton_remove_prompts = True
|
||||
copybutton_line_continuation_character = "\\"
|
||||
copybutton_prompt_text = r">>> |\.\.\. |\$ |sh\$ |PS> |cr> |mysql> |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: "
|
||||
copybutton_prompt_text = r">>> |\.\.\. |\$ "
|
||||
copybutton_prompt_is_regexp = True
|
||||
|
||||
+78
-30
@@ -1,14 +1,32 @@
|
||||
Deploying Responder
|
||||
===================
|
||||
Deployment
|
||||
==========
|
||||
|
||||
You can deploy Responder anywhere you can deploy a basic Python application.
|
||||
Responder applications are standard `ASGI <https://asgi.readthedocs.io/>`_
|
||||
apps. ASGI (Asynchronous Server Gateway Interface) is the modern successor
|
||||
to WSGI — it supports async, WebSockets, and HTTP/2. This means you can
|
||||
deploy a Responder app anywhere that runs Python, using any ASGI server.
|
||||
|
||||
Docker Deployment
|
||||
-----------------
|
||||
|
||||
Assuming an existing ``api.py`` containing your Responder application.
|
||||
Running Locally
|
||||
---------------
|
||||
|
||||
``Dockerfile``::
|
||||
During development, ``api.run()`` is all you need::
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
This starts a `uvicorn <https://www.uvicorn.org/>`_ server on
|
||||
``127.0.0.1:5042``. Uvicorn is a lightning-fast ASGI server built on
|
||||
`uvloop <https://uvloop.readthedocs.io/>`_ — it handles thousands of
|
||||
concurrent connections efficiently and protects against slowloris attacks,
|
||||
making a reverse proxy like nginx optional for many deployments.
|
||||
|
||||
|
||||
Docker
|
||||
------
|
||||
|
||||
Docker is the most common way to package and deploy web applications.
|
||||
Here's a minimal Dockerfile::
|
||||
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
@@ -18,38 +36,68 @@ Assuming an existing ``api.py`` containing your Responder application.
|
||||
EXPOSE 80
|
||||
CMD ["python", "api.py"]
|
||||
|
||||
That's it!
|
||||
Build and run::
|
||||
|
||||
Cloud Deployment
|
||||
$ docker build -t myapi .
|
||||
$ docker run -p 8000:80 myapi
|
||||
|
||||
The ``python:3.13-slim`` image is about 150MB — small enough for fast
|
||||
deploys but includes everything you need. For even smaller images, you
|
||||
can use ``python:3.13-alpine``, though some packages may need extra
|
||||
build dependencies.
|
||||
|
||||
|
||||
Cloud Platforms
|
||||
---------------
|
||||
|
||||
Responder automatically honors the ``PORT`` environment variable. When
|
||||
``PORT`` is set, the server binds to ``0.0.0.0`` on that port — this is
|
||||
the convention that virtually every cloud platform uses.
|
||||
|
||||
This means zero configuration on:
|
||||
|
||||
- **Fly.io** — ``fly launch`` and you're done
|
||||
- **Railway** — push your code, Railway sets ``PORT``
|
||||
- **Render** — set start command to ``python api.py``
|
||||
- **Google Cloud Run** — containerize and deploy
|
||||
- **Azure Container Apps** — same pattern
|
||||
- **AWS App Runner** — and here too
|
||||
|
||||
The pattern is always the same: deploy your code, set the start command
|
||||
to ``python api.py``, and the platform handles the rest.
|
||||
|
||||
|
||||
Uvicorn Directly
|
||||
----------------
|
||||
|
||||
Responder automatically honors the ``PORT`` environment variable, which is
|
||||
set by most cloud platforms (Fly.io, Railway, Render, Google Cloud Run, etc.).
|
||||
For production deployments where you want more control, bypass
|
||||
``api.run()`` and use uvicorn directly::
|
||||
|
||||
The basics::
|
||||
$ uvicorn api:api --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
$ mkdir my-api
|
||||
$ cd my-api
|
||||
The ``--workers`` flag spawns multiple processes, each handling requests
|
||||
independently. A good starting point is 2-4 workers per CPU core.
|
||||
|
||||
Write out an ``api.py``::
|
||||
Uvicorn supports many options — SSL certificates, access logging, graceful
|
||||
shutdown timeouts, and more. See the
|
||||
`uvicorn documentation <https://www.uvicorn.org/deployment/>`_ for details.
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
Reverse Proxy
|
||||
-------------
|
||||
|
||||
@api.route("/")
|
||||
async def hello(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
For high-traffic production deployments, you may want a reverse proxy like
|
||||
`nginx <https://nginx.org/>`_ or `Caddy <https://caddyserver.com/>`_ in
|
||||
front of your application for:
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
- **SSL/TLS termination** — let the proxy handle HTTPS certificates
|
||||
- **Load balancing** — distribute traffic across multiple app instances
|
||||
- **Static asset serving** — offload static files to the proxy
|
||||
- **Rate limiting** — at the infrastructure level
|
||||
|
||||
Deploy with your platform of choice. Responder will bind to ``0.0.0.0``
|
||||
on the port specified by ``PORT`` automatically.
|
||||
Responder's ``TrustedHostMiddleware`` and ``HTTPSRedirectMiddleware`` work
|
||||
correctly behind proxies that set standard forwarding headers
|
||||
(``X-Forwarded-For``, ``X-Forwarded-Proto``).
|
||||
|
||||
Running with Uvicorn Directly
|
||||
-----------------------------
|
||||
|
||||
For production deployments, you can also run your app directly with uvicorn::
|
||||
|
||||
uvicorn api:api --host 0.0.0.0 --port 8000 --workers 4
|
||||
That said, uvicorn is production-ready on its own. Many applications run
|
||||
uvicorn directly without a reverse proxy and do just fine.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
Configuration
|
||||
=============
|
||||
|
||||
Every application needs different settings for different environments —
|
||||
debug mode in development, real secrets in production, different database
|
||||
URLs for testing. This guide covers how to manage configuration cleanly.
|
||||
|
||||
|
||||
Environment Variables
|
||||
---------------------
|
||||
|
||||
The simplest and most universal approach. Environment variables work
|
||||
everywhere — locally, in Docker, on cloud platforms — and keep secrets
|
||||
out of your source code::
|
||||
|
||||
import os
|
||||
import responder
|
||||
|
||||
api = responder.API(
|
||||
debug=os.getenv("DEBUG", "false").lower() == "true",
|
||||
secret_key=os.environ["SECRET_KEY"],
|
||||
cors=os.getenv("CORS_ENABLED", "false").lower() == "true",
|
||||
)
|
||||
|
||||
Some variables Responder handles automatically:
|
||||
|
||||
- ``PORT`` — when set, the server binds to ``0.0.0.0`` on this port
|
||||
|
||||
Set variables in your shell::
|
||||
|
||||
$ export SECRET_KEY="your-secret-here"
|
||||
$ export DEBUG=true
|
||||
$ python app.py
|
||||
|
||||
Or in a ``.env`` file (don't commit this to git)::
|
||||
|
||||
SECRET_KEY=your-secret-here
|
||||
DEBUG=true
|
||||
|
||||
|
||||
Using .env Files
|
||||
----------------
|
||||
|
||||
For local development, a ``.env`` file is convenient. Install
|
||||
``python-dotenv`` and load it at the top of your app::
|
||||
|
||||
$ uv pip install python-dotenv
|
||||
|
||||
::
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
import os
|
||||
import responder
|
||||
|
||||
api = responder.API(
|
||||
secret_key=os.environ["SECRET_KEY"],
|
||||
)
|
||||
|
||||
Add ``.env`` to your ``.gitignore`` — never commit secrets.
|
||||
|
||||
|
||||
Configuration Class Pattern
|
||||
----------------------------
|
||||
|
||||
For larger applications, a configuration class keeps things organized::
|
||||
|
||||
import os
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret")
|
||||
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///dev.db")
|
||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",")
|
||||
|
||||
config = Config()
|
||||
|
||||
api = responder.API(
|
||||
debug=config.DEBUG,
|
||||
secret_key=config.SECRET_KEY,
|
||||
cors=bool(config.CORS_ORIGINS[0]),
|
||||
cors_params={"allow_origins": config.CORS_ORIGINS},
|
||||
)
|
||||
|
||||
This makes it easy to see all your settings in one place.
|
||||
|
||||
|
||||
Secret Key
|
||||
----------
|
||||
|
||||
The ``secret_key`` is used to sign session cookies. If someone knows your
|
||||
secret key, they can forge session data and impersonate any user.
|
||||
|
||||
Rules:
|
||||
|
||||
- **Never use the default** in production
|
||||
- **Generate a random key**: ``python -c "import secrets; print(secrets.token_hex(32))"``
|
||||
- **Store it in an environment variable**, not in code
|
||||
- **Rotate it** if it's ever compromised (this invalidates all sessions)
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(secret_key=os.environ["SECRET_KEY"])
|
||||
|
||||
|
||||
Debug Mode
|
||||
----------
|
||||
|
||||
Debug mode controls error page behavior:
|
||||
|
||||
- **On** (``debug=True``): detailed error pages with tracebacks. Never
|
||||
use this in production — it exposes your source code.
|
||||
- **Off** (``debug=False``): generic error pages. This is the default.
|
||||
|
||||
::
|
||||
|
||||
api = responder.API(debug=True) # development only
|
||||
|
||||
A common pattern is to read it from the environment::
|
||||
|
||||
api = responder.API(debug=os.getenv("DEBUG") == "true")
|
||||
|
||||
|
||||
Allowed Hosts
|
||||
-------------
|
||||
|
||||
In production, always set ``allowed_hosts`` to prevent Host header
|
||||
attacks. This should match the domain names your application serves::
|
||||
|
||||
api = responder.API(
|
||||
allowed_hosts=["example.com", "www.example.com"],
|
||||
)
|
||||
|
||||
In development, you can use ``["*"]`` (the default) or specific local
|
||||
addresses::
|
||||
|
||||
api = responder.API(allowed_hosts=["localhost", "127.0.0.1"])
|
||||
|
||||
|
||||
Putting It All Together
|
||||
-----------------------
|
||||
|
||||
A production-ready configuration setup::
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API(
|
||||
debug=os.getenv("DEBUG", "false") == "true",
|
||||
secret_key=os.environ["SECRET_KEY"],
|
||||
allowed_hosts=os.getenv("ALLOWED_HOSTS", "*").split(","),
|
||||
cors=bool(os.getenv("CORS_ORIGINS")),
|
||||
cors_params={
|
||||
"allow_origins": os.getenv("CORS_ORIGINS", "").split(","),
|
||||
"allow_methods": ["GET", "POST", "PUT", "DELETE"],
|
||||
},
|
||||
)
|
||||
|
||||
With a ``.env`` file for local development::
|
||||
|
||||
SECRET_KEY=dev-secret-do-not-use-in-prod
|
||||
DEBUG=true
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
And environment variables set properly in production (via your cloud
|
||||
platform's dashboard, Docker secrets, or a secrets manager).
|
||||
+82
-127
@@ -1,29 +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
|
||||
=================================
|
||||
|
||||
|ci-tests| |version| |license| |python-versions| |downloads| |contributors| |say-thanks|
|
||||
|
||||
.. |ci-tests| image:: https://github.com/kennethreitz/responder/actions/workflows/test.yaml/badge.svg
|
||||
:target: https://github.com/kennethreitz/responder/actions/workflows/test.yaml
|
||||
.. |ci-docs| image:: https://github.com/kennethreitz/responder/actions/workflows/docs.yaml/badge.svg
|
||||
:target: https://github.com/kennethreitz/responder/actions/workflows/docs.yaml
|
||||
.. |version| image:: https://img.shields.io/pypi/v/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |license| image:: https://img.shields.io/pypi/l/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |python-versions| image:: https://img.shields.io/pypi/pyversions/responder.svg
|
||||
:target: https://pypi.org/project/responder/
|
||||
.. |downloads| image:: https://static.pepy.tech/badge/responder/month
|
||||
:target: https://www.pepy.tech/projects/responder
|
||||
.. |contributors| image:: https://img.shields.io/github/contributors/kennethreitz/responder.svg
|
||||
:target: https://github.com/kennethreitz/responder/graphs/contributors
|
||||
.. |say-thanks| 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
|
||||
|
||||
@@ -38,56 +16,82 @@ A familiar HTTP Service Framework
|
||||
if __name__ == '__main__':
|
||||
api.run()
|
||||
|
||||
Responder is powered by `Starlette`_.
|
||||
Powered by `Starlette`_, `uvicorn`_, and good intentions. The ``async`` is optional.
|
||||
|
||||
The example program demonstrates an `ASGI`_ application using `Responder`_,
|
||||
including production-ready components like the `uvicorn`_ webserver, based
|
||||
on `uvloop`_, and the `Jinja`_ templating library pre-installed.
|
||||
The ``async`` declaration within the example program is optional.
|
||||
|
||||
Features
|
||||
The Idea
|
||||
--------
|
||||
|
||||
- A pleasant API, with a single import statement.
|
||||
- Class-based views without inheritance.
|
||||
- `ASGI`_, the future of Python web services.
|
||||
- Asynchronous Python frameworks and applications.
|
||||
- Automatic gzip compression.
|
||||
- WebSocket support!
|
||||
- The ability to mount any ASGI / WSGI app at a subroute.
|
||||
- `f-string syntax`_ 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!
|
||||
If you've ever used `Flask`_, the routing will look familiar. If you've
|
||||
used `Falcon`_, the request/response pattern will click immediately. And
|
||||
if you've used `Requests`_ — well, you'll feel right at home.
|
||||
|
||||
Testimonials
|
||||
Responder takes these ideas and brings them together. Every view receives
|
||||
a request and a response. You read from one and write to the other. No
|
||||
return values, no special response classes, no boilerplate.
|
||||
|
||||
- ``resp.text`` sends text. ``resp.html`` sends HTML. ``resp.media`` sends JSON.
|
||||
- ``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 familiar ones.
|
||||
|
||||
Set ``resp.media`` to a dict and the right thing happens. If the client
|
||||
asks for YAML, it gets YAML. Content negotiation is automatic.
|
||||
|
||||
Responder and `FastAPI`_ are siblings — both built on Starlette, both
|
||||
born around the same time, both part of the push that made ASGI the
|
||||
future of Python web services. FastAPI went deep on type annotations
|
||||
and automatic validation. Responder went for simplicity and a mutable
|
||||
request/response pattern. Both projects are better for the other
|
||||
existing. Use whichever feels right.
|
||||
|
||||
This is a passion project. It exists because building a web framework
|
||||
from scratch is one of the best ways to understand how the web works.
|
||||
It's a great fit for personal projects, prototyping, teaching, research,
|
||||
and anyone who values a clean API over a sprawling ecosystem. If you
|
||||
need battle-tested infrastructure at scale, FastAPI and Django will
|
||||
serve you well. If you want something small, expressive, and fun to
|
||||
work with — welcome.
|
||||
|
||||
|
||||
What You Get
|
||||
------------
|
||||
|
||||
“Pleasantly very taken with python-responder.
|
||||
`@kennethreitz`_ at his absolute best.”
|
||||
One ``pip install``, batteries included:
|
||||
|
||||
— Rudraksh M.K.
|
||||
|
||||
..
|
||||
|
||||
"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`_
|
||||
|
||||
..
|
||||
|
||||
“I love that you are exploring new patterns. Go go go!”
|
||||
|
||||
— Danny Greenfield, author of `Two Scoops of Django`_
|
||||
- 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.
|
||||
|
||||
|
||||
User Guides
|
||||
-----------
|
||||
Installation
|
||||
------------
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
$ uv pip install responder
|
||||
|
||||
Python 3.9 and above. That's it.
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: User Guide
|
||||
|
||||
quickstart
|
||||
tour
|
||||
@@ -96,80 +100,31 @@ User Guides
|
||||
api
|
||||
cli
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Tutorials
|
||||
|
||||
Installing Responder
|
||||
--------------------
|
||||
|
||||
Use ``uv`` for fast installation.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
uv pip install --upgrade 'responder'
|
||||
|
||||
Or use standard pip where ``uv`` is not available.
|
||||
|
||||
.. code-block:: shell
|
||||
|
||||
pip install --upgrade 'responder'
|
||||
|
||||
Responder supports **Python 3.9+**.
|
||||
|
||||
Development
|
||||
-----------
|
||||
|
||||
If you are looking at installing Responder
|
||||
for hacking on it, please refer to the :ref:`sandbox` documentation.
|
||||
tutorial-rest
|
||||
tutorial-sqlalchemy
|
||||
tutorial-auth
|
||||
tutorial-websockets
|
||||
tutorial-middleware
|
||||
tutorial-flask
|
||||
guide-config
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Project
|
||||
|
||||
changes
|
||||
Sandbox <sandbox>
|
||||
backlog
|
||||
|
||||
|
||||
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 -- using Python's 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** powered by Starlette's TestClient.
|
||||
- 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`_ 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`_ 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`
|
||||
|
||||
|
||||
.. _@kennethreitz: https://x.com/kennethreitz42
|
||||
.. _ASGI: https://en.wikipedia.org/wiki/Asynchronous_Server_Gateway_Interface
|
||||
.. _Django REST Framework: https://www.django-rest-framework.org/
|
||||
.. _f-string syntax: https://docs.python.org/3/whatsnew/3.6.html#pep-498-formatted-string-literals
|
||||
.. _Jinja: https://jinja.palletsprojects.com/en/stable/
|
||||
.. _ServeStatic: https://archmonger.github.io/ServeStatic/latest/
|
||||
.. _Slowloris: https://en.wikipedia.org/wiki/Slowloris_(computer_security)
|
||||
.. _Starlette: https://www.starlette.io/
|
||||
.. _Responder: https://responder.kennethreitz.org/
|
||||
.. _Two Scoops of Django: https://www.feldroy.com/two-scoops-press#two-scoops-of-django
|
||||
.. _uvicorn: https://www.uvicorn.org/
|
||||
.. _uvloop: https://uvloop.readthedocs.io/
|
||||
.. _Flask: https://flask.palletsprojects.com/
|
||||
.. _Falcon: https://falconframework.org/
|
||||
.. _FastAPI: https://fastapi.tiangolo.com/
|
||||
.. _GraphQL: https://graphql.org/
|
||||
.. _Requests: https://requests.readthedocs.io/
|
||||
|
||||
+300
-98
@@ -1,176 +1,378 @@
|
||||
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 understand how HTTP requests and responses
|
||||
work, how to define routes, read data from clients, send data back, render
|
||||
HTML templates, and process work in the background.
|
||||
|
||||
|
||||
Declare a Web Service
|
||||
---------------------
|
||||
Create a Web Service
|
||||
--------------------
|
||||
|
||||
The first thing you need to do is declare a web service::
|
||||
Every web application starts with a single object — the application
|
||||
instance. In Responder, this is the ``API`` class. It holds your routes,
|
||||
middleware, templates, and configuration. Think of it as the central
|
||||
nervous system of your web service::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
Hello World!
|
||||
------------
|
||||
That's it. One import, one line. You now have a fully functional ASGI
|
||||
application with gzip compression, static file serving, session support,
|
||||
and a production-ready server — all wired up and ready to go.
|
||||
|
||||
Then, you can add a view / route to it.
|
||||
|
||||
Here, we'll make the root URL say "hello world!"::
|
||||
Hello World
|
||||
-----------
|
||||
|
||||
A web service isn't very useful until it can respond to requests. In HTTP,
|
||||
a *route* maps a URL path to a function that handles it. When a client
|
||||
(like a browser or ``curl``) sends a request to that path, your function
|
||||
runs and produces a response.
|
||||
|
||||
Here's the simplest possible route::
|
||||
|
||||
@api.route("/")
|
||||
def hello_world(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
Two things to notice:
|
||||
|
||||
1. Every view function receives two arguments: ``req`` (the incoming
|
||||
request) and ``resp`` (the outgoing response).
|
||||
2. You don't return anything. Instead, you *mutate* the response object
|
||||
directly. This is a deliberate design choice — it keeps the API
|
||||
consistent whether you're setting text, JSON, headers, cookies, or
|
||||
status codes.
|
||||
|
||||
|
||||
Run the Server
|
||||
--------------
|
||||
|
||||
Next, we can run our web service easily, with ``api.run()``::
|
||||
Start your web service with a single call::
|
||||
|
||||
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 <https://www.uvicorn.org/>`_
|
||||
server on port ``5042``, ready for incoming HTTP requests. Open
|
||||
``http://localhost:5042`` in your browser and you'll see your hello world
|
||||
response.
|
||||
|
||||
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 expect.
|
||||
|
||||
.. note::
|
||||
|
||||
Both sync and async views are supported. The ``async`` keyword is always
|
||||
optional — use it when you need to ``await`` something, like reading a
|
||||
request body or querying a database.
|
||||
|
||||
|
||||
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::
|
||||
Static URLs like ``/about`` are useful, but most applications need dynamic
|
||||
routes — URLs that contain variable data, like a user ID or a product slug.
|
||||
|
||||
In Responder, you declare route parameters using Python's f-string syntax::
|
||||
|
||||
@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!``.
|
||||
A request to ``/hello/guido`` will respond with ``hello, guido!``.
|
||||
|
||||
Type convertors are also available::
|
||||
Route parameters are passed as *keyword-only* arguments (after the ``*``
|
||||
in the function signature). This is a Python feature that makes the
|
||||
interface explicit — you always know which arguments come from the URL.
|
||||
|
||||
|
||||
Type Convertors
|
||||
^^^^^^^^^^^^^^^
|
||||
|
||||
By default, route parameters are strings. But often you want them as
|
||||
integers, UUIDs, or other types. Responder can convert them automatically
|
||||
using type annotations in the route pattern::
|
||||
|
||||
@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``, ``float``, ``uuid``, and ``path``.
|
||||
Here, ``a`` and ``b`` will arrive as Python ``int`` objects, not strings.
|
||||
If someone requests ``/add/3/hello``, they'll get a 404 — the route won't
|
||||
match because ``hello`` isn't a valid integer.
|
||||
|
||||
Returning JSON / YAML
|
||||
---------------------
|
||||
Supported types:
|
||||
|
||||
If you want your API to send back JSON, simply set the ``resp.media`` property to a JSON-serializable Python object::
|
||||
- ``str`` — matches any string without slashes (this is the default)
|
||||
- ``int`` — matches digits and converts to ``int``
|
||||
- ``float`` — matches decimal numbers and converts to ``float``
|
||||
- ``uuid`` — matches UUID strings like ``550e8400-e29b-41d4-a716-446655440000``
|
||||
- ``path`` — matches any string *including* slashes, useful for file paths
|
||||
like ``/files/{filepath:path}``
|
||||
|
||||
|
||||
Sending Responses
|
||||
-----------------
|
||||
|
||||
When an HTTP server receives a request, it must send back a response. Every
|
||||
HTTP response has three parts: a status code (like ``200 OK`` or ``404 Not
|
||||
Found``), headers (metadata like ``Content-Type``), and a body (the actual
|
||||
data).
|
||||
|
||||
Responder lets you set all three by mutating the response object.
|
||||
|
||||
**Text and HTML** — the simplest response types. ``resp.text`` sets the
|
||||
``Content-Type`` to ``text/plain``, while ``resp.html`` sets it to
|
||||
``text/html``::
|
||||
|
||||
resp.text = "plain text response"
|
||||
resp.html = "<h1>HTML response</h1>"
|
||||
|
||||
**JSON** — the lingua franca of web APIs. Set ``resp.media`` to any
|
||||
JSON-serializable Python object — a dict, a list, whatever — and Responder
|
||||
will serialize it to JSON and set the right headers::
|
||||
|
||||
@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. This is called *content negotiation* —
|
||||
the server and client agree on a format. It happens automatically.
|
||||
|
||||
If the client requests YAML instead (with a header of ``Accept: application/x-yaml``), YAML will be sent.
|
||||
**Files** — serve a file from disk. Responder uses Python's ``mimetypes``
|
||||
module to figure out the ``Content-Type`` from the file extension::
|
||||
|
||||
Rendering a Template
|
||||
--------------------
|
||||
resp.file("reports/annual.pdf")
|
||||
|
||||
Responder provides a built-in light `Jinja`_ wrapper ``templates.Templates``
|
||||
**Raw bytes** — for binary data like images or protocol buffers::
|
||||
|
||||
Usage::
|
||||
resp.content = b"\x89PNG\r\n..."
|
||||
|
||||
from responder.templates import Templates
|
||||
**Status codes** — HTTP status codes tell the client what happened. ``200``
|
||||
means success, ``201`` means something was created, ``404`` means not found,
|
||||
``500`` means the server broke. Set it directly::
|
||||
|
||||
templates = Templates()
|
||||
resp.status_code = 201
|
||||
|
||||
@api.route("/hello/{name}/html")
|
||||
def hello(req, resp, name):
|
||||
resp.html = templates.render("hello.html", name=name)
|
||||
**Headers** — HTTP headers carry metadata. Common ones include
|
||||
``Content-Type``, ``Cache-Control``, ``Authorization``, and custom
|
||||
application headers::
|
||||
|
||||
resp.headers["X-Custom"] = "value"
|
||||
|
||||
**Redirects** — tell the client to go somewhere else::
|
||||
|
||||
api.redirect(resp, location="/new-url")
|
||||
|
||||
This sends a ``301 Moved Permanently`` response by default. The client's
|
||||
browser will automatically follow the redirect.
|
||||
|
||||
|
||||
Also a ``render_async`` is available::
|
||||
Reading Requests
|
||||
----------------
|
||||
|
||||
templates = Templates(enable_async=True)
|
||||
resp.html = await templates.render_async("hello.html", who=who)
|
||||
The other half of HTTP is the request — the data the client sends to your
|
||||
server. This includes the HTTP method (GET, POST, PUT, DELETE), the URL,
|
||||
headers, query parameters, cookies, and optionally a body.
|
||||
|
||||
You can also use the existing ``api.template(filename, *args, **kwargs)`` to render templates::
|
||||
Responder wraps all of this in the ``req`` object.
|
||||
|
||||
@api.route("/hello/{who}/html")
|
||||
def hello_html(req, resp, *, who):
|
||||
resp.html = api.template('hello.html', who=who)
|
||||
**Method and URL** — every HTTP request has a method (what the client wants
|
||||
to do) and a URL (what resource it's about)::
|
||||
|
||||
req.method # "get", "post", etc. (lowercase)
|
||||
req.full_url # "http://example.com/path?q=1"
|
||||
req.url # parsed URL object
|
||||
|
||||
**Headers** — HTTP headers carry metadata from the client, like what
|
||||
content types it accepts, authentication tokens, and more. Responder's
|
||||
headers dict is case-insensitive, because the HTTP spec says header names
|
||||
are case-insensitive::
|
||||
|
||||
req.headers["Content-Type"]
|
||||
req.headers["content-type"] # same thing
|
||||
|
||||
**Query parameters** — the part of the URL after the ``?``. These are
|
||||
commonly used for search, filtering, and pagination::
|
||||
|
||||
# GET /search?q=python&page=2
|
||||
req.params["q"] # "python"
|
||||
req.params["page"] # "2"
|
||||
|
||||
Note that query parameters are always strings. If you need an integer,
|
||||
you'll need to convert it yourself: ``int(req.params["page"])``.
|
||||
|
||||
**Path parameters** — the dynamic parts of the URL that matched your route
|
||||
pattern. These are also available on the request object, which is useful
|
||||
in before-request hooks where they aren't passed as function arguments::
|
||||
|
||||
req.path_params["user_id"] # same as the keyword argument
|
||||
|
||||
**Request body** — for POST, PUT, and PATCH requests, the client sends
|
||||
data in the body. Since reading the body is an I/O operation, you need to
|
||||
``await`` it::
|
||||
|
||||
# JSON body (the most common format for APIs)
|
||||
data = await req.media()
|
||||
|
||||
# Form data (from HTML forms)
|
||||
data = await req.media("form")
|
||||
|
||||
# File uploads (multipart)
|
||||
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 the content type is JSON
|
||||
req.cookies # dict of cookies sent by the client
|
||||
req.session # session data (a signed, server-side dict)
|
||||
req.client # (host, port) tuple — the client's IP address
|
||||
req.is_secure # True if the request came over HTTPS
|
||||
|
||||
|
||||
Setting Response Status Code
|
||||
----------------------------
|
||||
Rendering Templates
|
||||
-------------------
|
||||
|
||||
If you want to set the response status code, simply set ``resp.status_code``::
|
||||
While APIs typically return JSON, many web applications need to render
|
||||
HTML pages. Responder includes built-in support for
|
||||
`Jinja2 <https://jinja.palletsprojects.com/>`_, one of the most popular
|
||||
templating engines in the Python ecosystem.
|
||||
|
||||
@api.route("/416")
|
||||
def teapot(req, resp):
|
||||
resp.status_code = api.status_codes.HTTP_416 # ...or 416
|
||||
Templates let you write HTML with placeholders that get filled in with
|
||||
dynamic data. This keeps your presentation logic (HTML) separate from
|
||||
your application logic (Python) — a pattern called
|
||||
*separation of concerns*.
|
||||
|
||||
The simplest way to render a template is ``api.template()``. Templates
|
||||
are loaded from the ``templates/`` directory by default::
|
||||
|
||||
@api.route("/hello/{name}/html")
|
||||
def hello_html(req, resp, *, name):
|
||||
resp.html = api.template("hello.html", name=name)
|
||||
|
||||
The template file ``templates/hello.html`` might look like::
|
||||
|
||||
<h1>Hello, {{ name }}!</h1>
|
||||
|
||||
The ``{{ name }}`` part is a Jinja2 expression — it gets replaced with
|
||||
the value you passed in.
|
||||
|
||||
You can also use the ``Templates`` class directly for more control over
|
||||
the template directory and configuration::
|
||||
|
||||
from responder.templates import Templates
|
||||
|
||||
templates = Templates(directory="my_templates")
|
||||
|
||||
@api.route("/page")
|
||||
def page(req, resp):
|
||||
resp.html = templates.render("page.html", title="Hello")
|
||||
|
||||
For applications that need non-blocking template rendering (rare, but
|
||||
useful under extreme load), async rendering is supported::
|
||||
|
||||
templates = Templates(directory="templates", enable_async=True)
|
||||
resp.html = await templates.render_async("page.html", title="Hello")
|
||||
|
||||
And for quick one-off templates, you can render a string directly 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::
|
||||
Sometimes you want to accept a request, respond immediately, and do the
|
||||
actual processing later. This is a common pattern for operations that take
|
||||
a long time — sending emails, processing images, updating caches, or
|
||||
calling slow external APIs.
|
||||
|
||||
@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
|
||||
Responder makes this easy with background tasks. Decorate any function
|
||||
with ``@api.background.task`` and it will run in a thread pool, separate
|
||||
from the request/response cycle::
|
||||
|
||||
@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):
|
||||
with open(f"./{data['file']['filename']}", 'wb') as f:
|
||||
f.write(data['file']['content'])
|
||||
"""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'}
|
||||
# This response is sent immediately, while process_data
|
||||
# continues running in the background.
|
||||
resp.media = {"status": "accepted"}
|
||||
|
||||
You can test file uploads using the built-in test client::
|
||||
The client gets an instant response — the heavy lifting happens after.
|
||||
This is the same pattern used by task queues like Celery, but much simpler
|
||||
for lightweight use cases where you don't need a full message broker.
|
||||
|
||||
files = {'file': ('hello.txt', b'hello, world!', 'text/plain')}
|
||||
r = api.requests.post(api.url_for(upload_file), files=files)
|
||||
print(r.json())
|
||||
.. note::
|
||||
|
||||
Background tasks run in threads, not processes. They share memory with
|
||||
your application, which makes them fast to start but means CPU-intensive
|
||||
work will block the event loop. For heavy computation, consider a proper
|
||||
task queue.
|
||||
|
||||
|
||||
.. _Jinja: https://jinja.palletsprojects.com/en/stable/
|
||||
Putting It All Together
|
||||
-----------------------
|
||||
|
||||
Here's a complete, working Responder application that combines everything
|
||||
from this guide::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
@api.route("/")
|
||||
def index(req, resp):
|
||||
resp.text = "Welcome to the API"
|
||||
|
||||
@api.route("/hello/{name}")
|
||||
def greet(req, resp, *, name):
|
||||
resp.media = {"message": f"hello, {name}!"}
|
||||
|
||||
@api.route("/add/{a:int}/{b:int}")
|
||||
def add(req, resp, *, a, b):
|
||||
resp.media = {"result": a + b}
|
||||
|
||||
@api.route("/echo", methods=["POST"])
|
||||
async def echo(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"received": data}
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Save this as ``app.py``, run it with ``python app.py``, and try::
|
||||
|
||||
$ curl http://localhost:5042/
|
||||
$ curl http://localhost:5042/hello/world
|
||||
$ curl http://localhost:5042/add/3/4
|
||||
$ curl -X POST http://localhost:5042/echo \
|
||||
-H "Content-Type: application/json" -d '{"key": "value"}'
|
||||
|
||||
From here, explore the :doc:`tour` for the full range of features, or
|
||||
jump into the tutorials:
|
||||
|
||||
- :doc:`tutorial-rest` — build a full CRUD API with validation
|
||||
- :doc:`tutorial-sqlalchemy` — connect to a database
|
||||
- :doc:`tutorial-auth` — add authentication
|
||||
|
||||
+258
-20
@@ -1,34 +1,53 @@
|
||||
Building and Testing with Responder
|
||||
===================================
|
||||
Testing
|
||||
=======
|
||||
|
||||
Responder comes with a first-class, well supported test client for your ASGI web services (powered by Starlette's TestClient).
|
||||
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. There's no separate test server to manage,
|
||||
no ports to allocate, and no race conditions to worry about. Just import
|
||||
your app and start making requests.
|
||||
|
||||
Here, we'll go over the basics of setting up and testing a Responder application.
|
||||
|
||||
The Basics
|
||||
----------
|
||||
Getting Started
|
||||
---------------
|
||||
|
||||
Your project should look like this::
|
||||
|
||||
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()
|
||||
|
||||
Writing Tests
|
||||
-------------
|
||||
You can test it with pytest. Every Responder ``API`` instance has a
|
||||
``requests`` property that gives you a test client — use it exactly like
|
||||
you'd use ``requests`` or ``httpx``::
|
||||
|
||||
``$ cat test_api.py``::
|
||||
# test_api.py
|
||||
import api as service
|
||||
|
||||
def test_hello():
|
||||
r = service.api.requests.get("/")
|
||||
assert r.text == "hello, world!"
|
||||
|
||||
Run your tests::
|
||||
|
||||
$ pytest
|
||||
|
||||
That's really all there is to it. No configuration, no test server setup.
|
||||
|
||||
|
||||
Using Fixtures
|
||||
--------------
|
||||
|
||||
For larger test suites, pytest fixtures keep things organized. Create a
|
||||
fixture that returns your API instance, and every test gets a fresh
|
||||
reference to it::
|
||||
|
||||
import pytest
|
||||
import api as service
|
||||
@@ -37,12 +56,231 @@ 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. If you rename a route
|
||||
later, your tests won't break.
|
||||
|
||||
|
||||
Testing JSON APIs
|
||||
-----------------
|
||||
|
||||
Most APIs send and receive JSON. The test client makes this natural — pass
|
||||
``json=`` to send a JSON body, and call ``.json()`` on the response to
|
||||
parse it::
|
||||
|
||||
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
|
||||
|
||||
r = api.requests.post(api.url_for(create), json={"name": "widget"})
|
||||
assert r.status_code == 201
|
||||
assert r.json() == {"created": {"name": "widget"}}
|
||||
|
||||
You can also test content negotiation by setting the ``Accept`` header::
|
||||
|
||||
r = api.requests.get("/data", headers={"Accept": "application/x-yaml"})
|
||||
assert "key: value" in r.text
|
||||
|
||||
|
||||
Testing Request Validation
|
||||
--------------------------
|
||||
|
||||
If you're using Pydantic models for request validation, you can test
|
||||
that invalid inputs are properly rejected::
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
def test_validation(api):
|
||||
@api.route("/items", methods=["POST"], request_model=Item)
|
||||
async def create(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = data
|
||||
|
||||
# Valid request
|
||||
r = api.requests.post("/items", json={"name": "thing", "price": 9.99})
|
||||
assert r.status_code == 200
|
||||
|
||||
# Missing required field
|
||||
r = api.requests.post("/items", json={"name": "thing"})
|
||||
assert r.status_code == 422
|
||||
assert "errors" in r.json()
|
||||
|
||||
|
||||
Testing File Uploads
|
||||
--------------------
|
||||
|
||||
File uploads use the ``files`` parameter, just like the ``requests``
|
||||
library. Each file is a tuple of ``(filename, content, content_type)``::
|
||||
|
||||
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 Headers and Cookies
|
||||
----------------------------
|
||||
|
||||
Check response headers and cookies just like you would with any HTTP
|
||||
client::
|
||||
|
||||
def test_headers(api):
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.headers["X-Custom"] = "hello"
|
||||
resp.cookies["session"] = "abc123"
|
||||
|
||||
r = api.requests.get("/")
|
||||
assert r.headers["X-Custom"] == "hello"
|
||||
assert "session" in r.cookies
|
||||
|
||||
|
||||
Testing WebSockets
|
||||
------------------
|
||||
|
||||
WebSocket tests use Starlette's ``TestClient`` directly, since WebSocket
|
||||
connections require a different protocol. The ``websocket_connect`` context
|
||||
manager gives you a connection you can send and receive on::
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
def test_websocket(api):
|
||||
@api.route("/ws", websocket=True)
|
||||
async def ws(ws):
|
||||
await ws.accept()
|
||||
name = await ws.receive_text()
|
||||
await ws.send_text(f"hello, {name}!")
|
||||
await ws.close()
|
||||
|
||||
client = TestClient(api)
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
ws.send_text("world")
|
||||
assert ws.receive_text() == "hello, world!"
|
||||
|
||||
|
||||
Testing Error Handling
|
||||
----------------------
|
||||
|
||||
By default, the test client raises exceptions from your route handlers,
|
||||
which is usually what you want — it makes bugs obvious. But when you're
|
||||
testing error handling specifically, you want to see the error response
|
||||
instead. Disable exception propagation with ``raise_server_exceptions``::
|
||||
|
||||
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
|
||||
|
||||
If you've registered a custom exception handler, you can test that too::
|
||||
|
||||
def test_custom_error(api):
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
@api.route("/fail")
|
||||
def fail(req, resp):
|
||||
raise ValueError("bad input")
|
||||
|
||||
client = TestClient(api, raise_server_exceptions=False)
|
||||
r = client.get(api.url_for(fail))
|
||||
assert r.status_code == 400
|
||||
assert r.json() == {"error": "bad input"}
|
||||
|
||||
|
||||
Testing Lifespan Events
|
||||
-----------------------
|
||||
|
||||
If your app uses startup and shutdown events (for database connections,
|
||||
caches, etc.), you need the test client to trigger them. Wrap the client
|
||||
in a ``with`` block — startup runs on enter, shutdown runs on exit::
|
||||
|
||||
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}
|
||||
|
||||
Without the ``with`` block, lifespan events won't fire, which can lead to
|
||||
confusing test failures if your routes depend on startup initialization.
|
||||
|
||||
|
||||
Testing Before and After Hooks
|
||||
------------------------------
|
||||
|
||||
Before-request and after-request hooks run automatically during tests,
|
||||
just like in production. You can verify their effects on the response::
|
||||
|
||||
def test_hooks(api):
|
||||
@api.route(before_request=True)
|
||||
def add_version(req, resp):
|
||||
resp.headers["X-Version"] = "3.2"
|
||||
|
||||
@api.after_request()
|
||||
def add_timing(req, resp):
|
||||
resp.headers["X-Served-By"] = "responder"
|
||||
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.text = "ok"
|
||||
|
||||
r = api.requests.get("/")
|
||||
assert r.headers["X-Version"] == "3.2"
|
||||
assert r.headers["X-Served-By"] == "responder"
|
||||
|
||||
|
||||
Tips
|
||||
----
|
||||
|
||||
- **Keep tests fast.** The in-process test client is already fast — no
|
||||
network overhead. Avoid ``time.sleep()`` in tests.
|
||||
|
||||
- **One API per test** when testing configuration. If you need a specific
|
||||
``API()`` configuration (like ``cors=True``), create a new instance
|
||||
in the test rather than sharing the fixture.
|
||||
|
||||
- Use ``api.url_for()`` instead of hard-coded paths. It's a small
|
||||
thing, but it makes refactoring painless.
|
||||
|
||||
- **Test the contract, not the implementation.** Assert on status codes,
|
||||
response bodies, and headers — not on internal state.
|
||||
|
||||
+485
-395
@@ -1,413 +1,216 @@
|
||||
Feature Tour
|
||||
============
|
||||
|
||||
This section walks through Responder's features in depth. Each section
|
||||
explains the concept, shows working code, and explains the design choices
|
||||
behind it. If you're new to web development, this is a good place to learn
|
||||
how modern web frameworks work under the hood.
|
||||
|
||||
Route Method Filtering
|
||||
----------------------
|
||||
|
||||
You can restrict routes to specific HTTP methods::
|
||||
Method Filtering
|
||||
----------------
|
||||
|
||||
HTTP defines several *methods* (also called verbs) that describe what a
|
||||
client wants to do with a resource. The most common are:
|
||||
|
||||
- ``GET`` — retrieve data
|
||||
- ``POST`` — create something new
|
||||
- ``PUT`` — replace something entirely
|
||||
- ``PATCH`` — update part of something
|
||||
- ``DELETE`` — remove something
|
||||
|
||||
By default, a Responder route matches all methods. This is fine for simple
|
||||
endpoints, but REST APIs typically map different methods to different
|
||||
operations. Use the ``methods`` parameter to restrict a route::
|
||||
|
||||
@api.route("/items", methods=["GET"])
|
||||
def list_items(req, resp):
|
||||
resp.media = {"items": [...]}
|
||||
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`` — Responder normally prevents you from
|
||||
registering two routes with the same path (to catch typos). When you
|
||||
intentionally want multiple handlers for the same path with different
|
||||
methods, you need to opt in.
|
||||
|
||||
|
||||
Class-Based Views
|
||||
-----------------
|
||||
|
||||
Class-based views (and setting some headers and stuff)::
|
||||
Function-based views are great for simple endpoints, but sometimes you want
|
||||
to group related HTTP methods together into a single resource. This is
|
||||
where class-based views come in — a pattern popularized by
|
||||
`Falcon <https://falconframework.org/>`_.
|
||||
|
||||
Responder dispatches 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.
|
||||
This is simpler than Django's ``View`` classes and more Pythonic than
|
||||
framework-specific base classes.
|
||||
|
||||
|
||||
Lifespan Events
|
||||
---------------
|
||||
|
||||
Use the lifespan context manager for startup and shutdown logic::
|
||||
Real applications need to set up resources when they start (database
|
||||
connection pools, ML models, caches) and tear them down when they stop.
|
||||
This is called the application *lifespan*.
|
||||
|
||||
The modern approach is the *context manager* pattern, where startup and
|
||||
shutdown are two halves of the same block::
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup: connect to database, etc.
|
||||
print("Starting up...")
|
||||
# Startup — runs before the first request
|
||||
print("connecting to database...")
|
||||
yield
|
||||
# Shutdown: clean up resources
|
||||
print("Shutting down...")
|
||||
# Shutdown — runs after the server stops
|
||||
print("closing connections...")
|
||||
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
You can also use the traditional event decorators::
|
||||
Everything before ``yield`` runs at startup. Everything after runs at
|
||||
shutdown. If startup fails, the server won't start. If shutdown raises,
|
||||
it's logged but the server still exits.
|
||||
|
||||
@api.on_event('startup')
|
||||
The traditional event decorator style also works::
|
||||
|
||||
@api.on_event("startup")
|
||||
async def startup():
|
||||
print("Starting up...")
|
||||
print("starting up")
|
||||
|
||||
@api.on_event('shutdown')
|
||||
@api.on_event("shutdown")
|
||||
async def shutdown():
|
||||
print("Shutting down...")
|
||||
print("shutting down")
|
||||
|
||||
The context manager is preferred for new code — it keeps related startup
|
||||
and shutdown logic together and makes resource cleanup more explicit.
|
||||
|
||||
|
||||
Serving Files
|
||||
-------------
|
||||
|
||||
Serve files from disk with automatic content-type detection::
|
||||
Web applications often need to serve files — downloads, reports, images.
|
||||
Responder makes this simple with ``resp.file()``, which reads a file from
|
||||
disk and sets the ``Content-Type`` header automatically using Python's
|
||||
``mimetypes`` module::
|
||||
|
||||
@api.route("/download")
|
||||
def download(req, resp):
|
||||
resp.file("reports/annual.pdf")
|
||||
|
||||
You can also specify the content type explicitly::
|
||||
You can override the content type if the automatic detection isn't right::
|
||||
|
||||
@api.route("/image")
|
||||
def image(req, resp):
|
||||
resp.file("photos/cat.jpg", content_type="image/jpeg")
|
||||
|
||||
For large files, use ``resp.stream_file()`` to avoid loading the entire
|
||||
file into memory. This streams the file in chunks::
|
||||
|
||||
@api.route("/export")
|
||||
def export(req, resp):
|
||||
resp.stream_file("data/export.csv")
|
||||
|
||||
|
||||
Custom Error Handling
|
||||
---------------------
|
||||
|
||||
Register handlers for specific exception types::
|
||||
In production, you don't want your users to see raw Python tracebacks.
|
||||
Responder lets you register custom handlers for specific exception types,
|
||||
so you can return clean, 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 JSON
|
||||
response with a 400 status code instead of a generic 500 error page.
|
||||
|
||||
Background Tasks
|
||||
----------------
|
||||
|
||||
Here, you can spawn off a background thread to run any function, out-of-request::
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
|
||||
@api.background.task
|
||||
def sleep(s=10):
|
||||
time.sleep(s)
|
||||
print("slept!")
|
||||
|
||||
sleep()
|
||||
resp.content = "processing"
|
||||
This is a common pattern in API development — you define your own exception
|
||||
classes for different error conditions, register handlers for each, and
|
||||
your API always returns consistent, machine-readable error responses.
|
||||
|
||||
|
||||
GraphQL
|
||||
-------
|
||||
Responder supports GraphQL, a query language for APIs that enables clients to
|
||||
request exactly the data they need.
|
||||
|
||||
For more information about GraphQL, visit https://graphql.org/.
|
||||
|
||||
Serve a GraphQL API::
|
||||
|
||||
import graphene
|
||||
from responder.ext.graphql import GraphQLView
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
hello = graphene.String(name=graphene.String(default_value="stranger"))
|
||||
|
||||
def resolve_hello(self, info, name):
|
||||
return f"Hello {name}"
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
view = GraphQLView(api=api, schema=schema)
|
||||
|
||||
api.add_route("/graph", view)
|
||||
|
||||
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']``.
|
||||
|
||||
|
||||
OpenAPI Schema Support
|
||||
----------------------
|
||||
|
||||
Responder comes with built-in support for OpenAPI / marshmallow.
|
||||
|
||||
.. note::
|
||||
|
||||
If you're upgrading from a previous version, note that the OpenAPI module
|
||||
has been renamed from ``responder.ext.schema`` to ``responder.ext.openapi``.
|
||||
Update your imports accordingly.
|
||||
|
||||
New in Responder 1.4.0::
|
||||
|
||||
import responder
|
||||
from responder.ext.openapi import 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",
|
||||
}
|
||||
|
||||
api = responder.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,
|
||||
)
|
||||
|
||||
@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"})
|
||||
|
||||
::
|
||||
|
||||
>>> 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: []
|
||||
|
||||
|
||||
Interactive Documentation
|
||||
-------------------------
|
||||
|
||||
Responder can automatically supply API Documentation for you. Using the example above
|
||||
|
||||
The new and recommended way::
|
||||
|
||||
from responder.ext.openapi import OpenAPISchema
|
||||
|
||||
api = responder.API()
|
||||
|
||||
schema = OpenAPISchema(
|
||||
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.route('/')
|
||||
def hello():
|
||||
return 'hello'
|
||||
|
||||
api.mount('/flask', flask)
|
||||
|
||||
That's it!
|
||||
|
||||
Single-Page Web Apps
|
||||
Before-Request Hooks
|
||||
--------------------
|
||||
|
||||
If you have a single-page webapp, you can tell Responder to serve up your ``static/index.html`` at a route, like so::
|
||||
|
||||
api.add_route("/", static=True)
|
||||
|
||||
This will make ``index.html`` the default response to all undefined routes.
|
||||
|
||||
Reading / Writing Cookies
|
||||
-------------------------
|
||||
|
||||
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::
|
||||
Sometimes you need to run the same code before every request —
|
||||
authentication checks, request logging, adding common headers, or setting
|
||||
up per-request state. Before-request hooks let you do this without
|
||||
duplicating code in every route::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def prepare_response(req, resp):
|
||||
resp.headers["X-Pizza"] = "42"
|
||||
def add_headers(req, resp):
|
||||
resp.headers["X-API-Version"] = "3.2"
|
||||
|
||||
Now all requests to your HTTP Service will include an ``X-Pizza`` header.
|
||||
**Short-circuiting** is the really powerful part. If your hook sets
|
||||
``resp.status_code``, the route handler is skipped entirely and the
|
||||
response is sent immediately. This is the pattern for authentication::
|
||||
|
||||
For ``websockets``::
|
||||
@api.route(before_request=True)
|
||||
def auth_check(req, resp):
|
||||
if "Authorization" not in req.headers:
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "unauthorized"}
|
||||
|
||||
@api.route(before_request=True, websocket=True)
|
||||
def prepare_response(ws):
|
||||
await ws.accept()
|
||||
If the ``Authorization`` header is missing, the client gets a 401 response
|
||||
and the actual route handler never runs. This is cleaner than adding
|
||||
auth checks to every individual route.
|
||||
|
||||
|
||||
After-Request Hooks
|
||||
-------------------
|
||||
|
||||
The complement to before-request hooks. After-request hooks run after the
|
||||
route handler completes but before the response is sent. They're useful
|
||||
for logging, adding response headers, or any post-processing::
|
||||
|
||||
@api.after_request()
|
||||
def log_response(req, resp):
|
||||
print(f"{req.method} {req.full_url} -> {resp.status_code}")
|
||||
|
||||
@api.after_request()
|
||||
async def add_timing(req, resp):
|
||||
resp.headers["X-Served-By"] = "responder"
|
||||
|
||||
|
||||
WebSocket Support
|
||||
-----------------
|
||||
|
||||
Responder supports WebSockets::
|
||||
HTTP is a request-response protocol — the client asks, the server answers.
|
||||
But some applications need real-time, bidirectional communication: chat
|
||||
apps, live dashboards, multiplayer games, collaborative editors.
|
||||
|
||||
@api.route('/ws', websocket=True)
|
||||
`WebSockets <https://en.wikipedia.org/wiki/WebSocket>`_ solve this by
|
||||
upgrading an HTTP connection into a persistent, full-duplex channel where
|
||||
both sides can send messages at any time::
|
||||
|
||||
@api.route("/ws", websocket=True)
|
||||
async def websocket(ws):
|
||||
await ws.accept()
|
||||
while True:
|
||||
@@ -415,93 +218,380 @@ Responder supports WebSockets::
|
||||
await ws.send_text(f"Hello {name}!")
|
||||
await ws.close()
|
||||
|
||||
Accepting the connection::
|
||||
You can send and receive in multiple formats:
|
||||
|
||||
await websocket.accept()
|
||||
- ``send_text`` / ``receive_text`` — plain text strings
|
||||
- ``send_json`` / ``receive_json`` — JSON objects (auto-serialized)
|
||||
- ``send_bytes`` / ``receive_bytes`` — raw binary data
|
||||
|
||||
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 (powered by Starlette's TestClient).
|
||||
|
||||
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)
|
||||
WebSocket routes are marked with ``websocket=True`` in the route decorator.
|
||||
They receive a ``ws`` object instead of ``req`` and ``resp``.
|
||||
|
||||
|
||||
Boom.
|
||||
Server-Sent Events (SSE)
|
||||
-------------------------
|
||||
|
||||
SSE is a simpler alternative to WebSockets for *one-way* real-time
|
||||
communication — the server pushes events to the client, but the client
|
||||
can't send messages back. This is perfect for live feeds, progress bars,
|
||||
notification streams, and AI response streaming.
|
||||
|
||||
Unlike WebSockets, SSE works over plain HTTP, is automatically reconnected
|
||||
by the browser, and doesn't require any special client-side libraries::
|
||||
|
||||
@api.route("/events")
|
||||
async def events(req, resp):
|
||||
@resp.sse
|
||||
async def stream():
|
||||
for i in range(10):
|
||||
yield {"data": f"message {i}"}
|
||||
|
||||
On the client side, you consume SSE events with JavaScript's built-in
|
||||
``EventSource`` API::
|
||||
|
||||
const source = new EventSource("/events");
|
||||
source.onmessage = (event) => {
|
||||
console.log(event.data);
|
||||
};
|
||||
|
||||
Each yielded value can be a string (treated as data) or a dict with the
|
||||
standard SSE fields::
|
||||
|
||||
yield {"event": "update", "data": "hello", "id": "1", "retry": "5000"}
|
||||
yield "simple string message"
|
||||
|
||||
|
||||
GraphQL
|
||||
-------
|
||||
|
||||
`GraphQL <https://graphql.org/>`_ is a query language for APIs that lets
|
||||
clients request exactly the data they need — no more, no less. Instead of
|
||||
multiple REST endpoints, you define a schema and let clients query it.
|
||||
|
||||
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
|
||||
|
||||
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))
|
||||
|
||||
Visiting ``/graphql`` in a browser renders the
|
||||
`GraphiQL <https://github.com/graphql/graphiql>`_ interactive IDE, where
|
||||
you can explore your schema, write queries, and see results in real-time.
|
||||
Programmatic clients can POST JSON queries to the same endpoint.
|
||||
|
||||
You can access the Responder request and response objects in your resolvers
|
||||
through ``info.context["request"]`` and ``info.context["response"]``.
|
||||
|
||||
|
||||
OpenAPI Documentation
|
||||
---------------------
|
||||
|
||||
`OpenAPI <https://www.openapis.org/>`_ (formerly Swagger) is the industry
|
||||
standard for describing REST APIs. An OpenAPI specification lets you
|
||||
auto-generate interactive documentation, client libraries, and validation
|
||||
logic.
|
||||
|
||||
Responder generates OpenAPI specs from your code::
|
||||
|
||||
api = responder.API(
|
||||
title="Pet Store",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
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. Use ``request_model`` and
|
||||
``response_model`` to annotate your routes, and Responder generates the
|
||||
schema automatically. When ``request_model`` is set, request bodies are
|
||||
also validated automatically — invalid inputs get a ``422`` response with
|
||||
detailed error messages::
|
||||
|
||||
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}
|
||||
|
||||
When ``response_model`` is set, the response is serialized through the
|
||||
model — extra fields are stripped and types are enforced.
|
||||
|
||||
**YAML docstrings** — for full control, embed OpenAPI YAML in the
|
||||
docstring::
|
||||
|
||||
@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::
|
||||
|
||||
from marshmallow import Schema, fields
|
||||
|
||||
@api.schema("Pet")
|
||||
class PetSchema(Schema):
|
||||
name = fields.Str()
|
||||
|
||||
All three approaches can be mixed in the same API. You can choose from
|
||||
multiple documentation themes: ``swagger_ui`` (default), ``redoc``,
|
||||
``rapidoc``, or ``elements``.
|
||||
|
||||
|
||||
Route Groups
|
||||
------------
|
||||
|
||||
As your application grows, you'll want to organize routes logically.
|
||||
Route groups let you share a URL prefix across related endpoints — a
|
||||
common pattern for API versioning::
|
||||
|
||||
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}
|
||||
|
||||
v2 = api.group("/v2")
|
||||
|
||||
@v2.route("/users")
|
||||
def list_users_v2(req, resp):
|
||||
resp.media = {"users": [], "total": 0}
|
||||
|
||||
This keeps your code organized without affecting the routing logic.
|
||||
|
||||
|
||||
Mounting Other Apps
|
||||
-------------------
|
||||
|
||||
Responder can mount any WSGI or ASGI application at a subroute. This is
|
||||
incredibly useful for gradual migrations — you can run Flask and Responder
|
||||
side by side, moving routes over one at a time::
|
||||
|
||||
from flask import Flask
|
||||
|
||||
flask_app = Flask(__name__)
|
||||
|
||||
@flask_app.route("/")
|
||||
def hello():
|
||||
return "Hello from Flask!"
|
||||
|
||||
api.mount("/flask", flask_app)
|
||||
|
||||
Requests to ``/flask/`` will be handled by Flask. Everything else goes
|
||||
through Responder. Both WSGI and ASGI apps are supported — Responder
|
||||
wraps WSGI apps in an ASGI adapter automatically.
|
||||
|
||||
|
||||
Cookies
|
||||
-------
|
||||
|
||||
`Cookies <https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies>`_ are
|
||||
small pieces of data that the server asks the browser to store and send
|
||||
back with every subsequent request. They're the foundation of sessions,
|
||||
authentication tokens, and user preferences on the web.
|
||||
|
||||
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 production use, you'll want to set security directives. The
|
||||
``httponly`` flag prevents JavaScript from reading the cookie (defending
|
||||
against XSS attacks), and ``secure`` ensures it's only sent over HTTPS::
|
||||
|
||||
resp.set_cookie(
|
||||
"token",
|
||||
value="abc123",
|
||||
max_age=3600, # expires in 1 hour
|
||||
secure=True, # HTTPS only
|
||||
httponly=True, # no JavaScript access
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
Cookie-Based Sessions
|
||||
---------------------
|
||||
|
||||
Sessions let you store per-user data across multiple requests. Responder's
|
||||
built-in sessions are cookie-based — the session data is serialized, signed
|
||||
with your secret key, and stored in a cookie. The signature prevents
|
||||
tampering: if someone modifies the cookie, the signature won't match and
|
||||
the data will be rejected::
|
||||
|
||||
@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")}
|
||||
|
||||
.. warning::
|
||||
|
||||
Always set a secret key in production. The default key is not secret::
|
||||
|
||||
api = responder.API(secret_key="your-secret-key-here")
|
||||
|
||||
|
||||
Static Files
|
||||
------------
|
||||
|
||||
Most web applications serve static assets — CSS stylesheets, JavaScript
|
||||
files, images, fonts. Responder serves these from the ``static/`` directory
|
||||
by default::
|
||||
|
||||
api = responder.API(static_dir="static", static_route="/static")
|
||||
|
||||
Place your assets in the ``static/`` directory and they'll be served
|
||||
automatically at ``/static/style.css``, ``/static/app.js``, etc.
|
||||
|
||||
For single-page applications (React, Vue, Angular), you can serve
|
||||
``index.html`` as the default response for all unmatched routes::
|
||||
|
||||
api.add_route("/", static=True)
|
||||
|
||||
|
||||
CORS
|
||||
----
|
||||
|
||||
Want `CORS <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/>`_ ?
|
||||
`CORS <https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS>`_ (Cross-
|
||||
Origin Resource Sharing) is a security mechanism that controls which
|
||||
websites can make requests to your API. Browsers enforce this — if your
|
||||
API is at ``api.example.com`` and your frontend is at ``app.example.com``,
|
||||
the browser will block requests unless your API explicitly allows it.
|
||||
|
||||
::
|
||||
Enable CORS and configure which origins are allowed::
|
||||
|
||||
api = responder.API(cors=True)
|
||||
api = responder.API(cors=True, cors_params={
|
||||
"allow_origins": ["https://app.example.com"],
|
||||
"allow_methods": ["GET", "POST"],
|
||||
"allow_headers": ["*"],
|
||||
"allow_credentials": True,
|
||||
"max_age": 600,
|
||||
})
|
||||
|
||||
The default policy is restrictive — you must explicitly allow each origin.
|
||||
Using ``["*"]`` for allow_origins permits any website to call your API,
|
||||
which is fine for public APIs but not for private ones.
|
||||
|
||||
|
||||
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:
|
||||
`HSTS <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security>`_
|
||||
(HTTP Strict Transport Security) tells browsers to always use HTTPS when
|
||||
communicating with your server. Once a browser sees the HSTS header, it
|
||||
will refuse to connect over plain HTTP, even if the user types ``http://``
|
||||
in the address bar::
|
||||
|
||||
api = responder.API(enable_hsts=True)
|
||||
|
||||
* ``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.
|
||||
The ``Host`` header in an HTTP request tells the server which domain name
|
||||
the client used. Attackers can forge this header to trick your application
|
||||
into generating URLs to malicious domains (a class of attack called *Host
|
||||
header injection*).
|
||||
|
||||
A 400 response will be raised, if a request does not match any of the provided patterns in the ``allowed_hosts`` attribute.
|
||||
Restrict which hostnames your application accepts::
|
||||
|
||||
::
|
||||
api = responder.API(allowed_hosts=["example.com", "*.example.com"])
|
||||
|
||||
api = responder.API(allowed_hosts=['example.com', 'tenant.example.com'])
|
||||
Requests with unrecognized hosts get a ``400 Bad Request``. Wildcard
|
||||
patterns are supported. By default, all hostnames are allowed.
|
||||
|
||||
* ``allowed_hosts`` - A list of allowed hostnames.
|
||||
|
||||
Note:
|
||||
Request ID
|
||||
----------
|
||||
|
||||
* By default, all hostnames are allowed.
|
||||
* Wildcard domains such as ``*.example.com`` are supported.
|
||||
* To allow any hostname use ``allowed_hosts=["*"]``.
|
||||
In distributed systems, tracing a single request across multiple services
|
||||
is essential for debugging. Request IDs are unique identifiers attached to
|
||||
each request — if something goes wrong, you can search your logs for that
|
||||
ID and find every related event.
|
||||
|
||||
Responder can auto-generate request IDs. If the client sends an
|
||||
``X-Request-ID`` header (common in microservice architectures), it's
|
||||
forwarded. Otherwise, a new UUID is generated::
|
||||
|
||||
api = responder.API(request_id=True)
|
||||
|
||||
The ID appears in the ``X-Request-ID`` response header.
|
||||
|
||||
|
||||
Rate Limiting
|
||||
-------------
|
||||
|
||||
Rate limiting prevents individual clients from overwhelming your API with
|
||||
too many requests. It's essential for public APIs, and good practice even
|
||||
for internal ones.
|
||||
|
||||
Responder includes a 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 a ``Retry-After`` header. Every response includes
|
||||
``X-RateLimit-Limit`` and ``X-RateLimit-Remaining`` headers so clients
|
||||
can pace themselves.
|
||||
|
||||
The rate limiter is per-client, keyed by IP address.
|
||||
|
||||
|
||||
MessagePack
|
||||
-----------
|
||||
|
||||
`MessagePack <https://msgpack.org/>`_ is a binary serialization format
|
||||
that's more compact and faster to parse than JSON. It's useful for
|
||||
high-throughput APIs, IoT devices, and anywhere bandwidth matters.
|
||||
|
||||
Responder supports MessagePack alongside JSON and YAML::
|
||||
|
||||
# Decode a MessagePack request body
|
||||
data = await req.media("msgpack")
|
||||
|
||||
Content negotiation works too — clients can send
|
||||
``Accept: application/x-msgpack`` to receive MessagePack responses
|
||||
instead of JSON.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
Authentication
|
||||
==============
|
||||
|
||||
Every API that handles user data needs authentication — a way to verify
|
||||
who is making a request. This guide covers the most common patterns:
|
||||
API keys, JWT tokens, and how to build reusable auth guards with
|
||||
Responder's before-request hooks.
|
||||
|
||||
|
||||
API Key Authentication
|
||||
----------------------
|
||||
|
||||
The simplest approach. The client sends a secret key in a header, and
|
||||
your server checks it against a known value. This is common for
|
||||
server-to-server communication and simple APIs::
|
||||
|
||||
API_KEYS = {"sk-abc123", "sk-def456"}
|
||||
|
||||
@api.route(before_request=True)
|
||||
def check_api_key(req, resp):
|
||||
key = req.headers.get("X-API-Key")
|
||||
if key not in API_KEYS:
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "Invalid or missing API key"}
|
||||
|
||||
Because the before-request hook sets ``resp.status_code``, the route
|
||||
handler is skipped entirely for unauthorized requests. The client never
|
||||
reaches your endpoint — the guard catches them first.
|
||||
|
||||
The client sends the key like this::
|
||||
|
||||
$ curl -H "X-API-Key: sk-abc123" http://localhost:5042/protected
|
||||
|
||||
|
||||
Bearer Token Authentication
|
||||
----------------------------
|
||||
|
||||
Bearer tokens are the standard for modern APIs. The client sends a token
|
||||
in the ``Authorization`` header, and the server validates it. The most
|
||||
common format is `JWT <https://jwt.io/>`_ (JSON Web Tokens).
|
||||
|
||||
Install PyJWT::
|
||||
|
||||
$ uv pip install pyjwt
|
||||
|
||||
Create a helper to encode and decode tokens::
|
||||
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
SECRET = "your-secret-key"
|
||||
|
||||
def create_token(user_id: int) -> str:
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"exp": datetime.utcnow() + timedelta(hours=24),
|
||||
}
|
||||
return jwt.encode(payload, SECRET, algorithm="HS256")
|
||||
|
||||
def verify_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, SECRET, algorithms=["HS256"])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
Add a login endpoint that issues tokens, and a before-request hook that
|
||||
verifies them::
|
||||
|
||||
@api.route("/login", methods=["POST"])
|
||||
async def login(req, resp):
|
||||
data = await req.media()
|
||||
# In a real app, check credentials against a database
|
||||
if data.get("username") == "admin" and data.get("password") == "secret":
|
||||
token = create_token(user_id=1)
|
||||
resp.media = {"token": token}
|
||||
else:
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "Invalid credentials"}
|
||||
|
||||
@api.route(before_request=True)
|
||||
def auth_guard(req, resp):
|
||||
# Skip auth for the login endpoint itself
|
||||
if req.url.path == "/login":
|
||||
return
|
||||
|
||||
auth = req.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "Missing bearer token"}
|
||||
return
|
||||
|
||||
token = auth[7:] # Strip "Bearer "
|
||||
payload = verify_token(token)
|
||||
if payload is None:
|
||||
resp.status_code = 401
|
||||
resp.media = {"error": "Invalid or expired token"}
|
||||
return
|
||||
|
||||
# Store the authenticated user on the request state
|
||||
req.state.user_id = payload["sub"]
|
||||
|
||||
Now any route can access the authenticated user::
|
||||
|
||||
@api.route("/me")
|
||||
def get_me(req, resp):
|
||||
resp.media = {"user_id": req.state.user_id}
|
||||
|
||||
The client flow:
|
||||
|
||||
1. ``POST /login`` with credentials → receive a token
|
||||
2. Include ``Authorization: Bearer <token>`` on every subsequent request
|
||||
3. The token expires after 24 hours — the client must log in again
|
||||
|
||||
|
||||
Skipping Auth for Public Routes
|
||||
--------------------------------
|
||||
|
||||
The example above skips auth for ``/login`` by checking the path. For
|
||||
more control, you can use a set of public paths::
|
||||
|
||||
PUBLIC_PATHS = {"/login", "/signup", "/health", "/docs", "/schema.yml"}
|
||||
|
||||
@api.route(before_request=True)
|
||||
def auth_guard(req, resp):
|
||||
if req.url.path in PUBLIC_PATHS:
|
||||
return
|
||||
# ... check token
|
||||
|
||||
|
||||
Custom Exception for Auth Errors
|
||||
---------------------------------
|
||||
|
||||
For cleaner code, define a custom exception and register a handler::
|
||||
|
||||
class AuthError(Exception):
|
||||
def __init__(self, message="Unauthorized", status_code=401):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
|
||||
@api.exception_handler(AuthError)
|
||||
async def handle_auth_error(req, resp, exc):
|
||||
resp.status_code = exc.status_code
|
||||
resp.media = {"error": exc.message}
|
||||
|
||||
Now your auth guard can simply raise::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def auth_guard(req, resp):
|
||||
if req.url.path in PUBLIC_PATHS:
|
||||
return
|
||||
if "Authorization" not in req.headers:
|
||||
raise AuthError("Missing authorization header")
|
||||
|
||||
|
||||
Using Sessions for Web Apps
|
||||
----------------------------
|
||||
|
||||
For traditional web applications (with HTML pages and forms), cookie-based
|
||||
sessions are simpler than tokens. The browser handles cookies automatically
|
||||
— no client-side token management needed::
|
||||
|
||||
@api.route("/login", methods=["POST"])
|
||||
async def login(req, resp):
|
||||
data = await req.media("form")
|
||||
if data["username"] == "admin" and data["password"] == "secret":
|
||||
resp.session["user"] = data["username"]
|
||||
api.redirect(resp, location="/dashboard")
|
||||
else:
|
||||
resp.status_code = 401
|
||||
resp.html = "<p>Invalid credentials</p>"
|
||||
|
||||
@api.route("/dashboard")
|
||||
def dashboard(req, resp):
|
||||
user = req.session.get("user")
|
||||
if not user:
|
||||
api.redirect(resp, location="/login")
|
||||
return
|
||||
resp.html = f"<h1>Welcome, {user}!</h1>"
|
||||
|
||||
@api.route("/logout")
|
||||
def logout(req, resp):
|
||||
resp.session.clear()
|
||||
api.redirect(resp, location="/login")
|
||||
|
||||
Remember to set a proper secret key::
|
||||
|
||||
api = responder.API(secret_key="your-production-secret-key")
|
||||
|
||||
The session data is signed (not encrypted) — users can read it but
|
||||
can't tamper with it. Don't store sensitive data like passwords in
|
||||
sessions.
|
||||
@@ -0,0 +1,192 @@
|
||||
Migrating from Flask
|
||||
====================
|
||||
|
||||
If you're coming from Flask, you'll find Responder familiar but different
|
||||
in a few key ways. This guide maps Flask concepts to their Responder
|
||||
equivalents and shows you how to translate common patterns.
|
||||
|
||||
|
||||
The Big Differences
|
||||
-------------------
|
||||
|
||||
**No return values.** In Flask, you return a response. In Responder, you
|
||||
mutate it. This is the single biggest difference:
|
||||
|
||||
Flask::
|
||||
|
||||
@app.route("/")
|
||||
def hello():
|
||||
return "hello, world!"
|
||||
|
||||
Responder::
|
||||
|
||||
@api.route("/")
|
||||
def hello(req, resp):
|
||||
resp.text = "hello, world!"
|
||||
|
||||
**Explicit request and response.** Flask uses a global ``request`` object
|
||||
(via thread-local magic). Responder passes ``req`` and ``resp`` explicitly.
|
||||
No magic, no import needed — they're right there in the function signature.
|
||||
|
||||
**ASGI, not WSGI.** Flask runs on WSGI, which is synchronous. Responder
|
||||
runs on ASGI, which supports async natively. You can still write sync
|
||||
views — Responder runs them in a thread pool automatically.
|
||||
|
||||
|
||||
Quick Reference
|
||||
---------------
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
:widths: 40 60
|
||||
|
||||
* - Flask
|
||||
- Responder
|
||||
* - ``Flask(__name__)``
|
||||
- ``responder.API()``
|
||||
* - ``return "text"``
|
||||
- ``resp.text = "text"``
|
||||
* - ``return jsonify(data)``
|
||||
- ``resp.media = data``
|
||||
* - ``return render_template("t.html", x=1)``
|
||||
- ``resp.html = api.template("t.html", x=1)``
|
||||
* - ``request.args["q"]``
|
||||
- ``req.params["q"]``
|
||||
* - ``request.json``
|
||||
- ``await req.media()``
|
||||
* - ``request.form``
|
||||
- ``await req.media("form")``
|
||||
* - ``request.headers["X"]``
|
||||
- ``req.headers["X"]``
|
||||
* - ``request.method``
|
||||
- ``req.method``
|
||||
* - ``request.cookies["x"]``
|
||||
- ``req.cookies["x"]``
|
||||
* - ``session["x"] = 1``
|
||||
- ``resp.session["x"] = 1``
|
||||
* - ``abort(404)``
|
||||
- ``resp.status_code = 404``
|
||||
* - ``redirect("/new")``
|
||||
- ``api.redirect(resp, location="/new")``
|
||||
* - ``@app.before_request``
|
||||
- ``@api.route(before_request=True)``
|
||||
* - ``@app.errorhandler(404)``
|
||||
- ``@api.exception_handler(ValueError)``
|
||||
* - ``app.run(debug=True)``
|
||||
- ``api.run(debug=True)``
|
||||
|
||||
|
||||
Route Parameters
|
||||
----------------
|
||||
|
||||
Flask uses ``<angle_brackets>``. Responder uses ``{curly_braces}``
|
||||
with the same type convertor idea:
|
||||
|
||||
Flask::
|
||||
|
||||
@app.route("/users/<int:user_id>")
|
||||
def get_user(user_id):
|
||||
return jsonify({"id": user_id})
|
||||
|
||||
Responder::
|
||||
|
||||
@api.route("/users/{user_id:int}")
|
||||
def get_user(req, resp, *, user_id):
|
||||
resp.media = {"id": user_id}
|
||||
|
||||
Note the ``*`` — route parameters are keyword-only arguments in
|
||||
Responder. This makes the interface explicit about which arguments
|
||||
come from the URL.
|
||||
|
||||
|
||||
JSON APIs
|
||||
---------
|
||||
|
||||
Flask::
|
||||
|
||||
@app.route("/api/items", methods=["POST"])
|
||||
def create_item():
|
||||
data = request.json
|
||||
# ... create item
|
||||
return jsonify(item), 201
|
||||
|
||||
Responder::
|
||||
|
||||
@api.route("/api/items", methods=["POST"])
|
||||
async def create_item(req, resp):
|
||||
data = await req.media()
|
||||
# ... create item
|
||||
resp.media = item
|
||||
resp.status_code = 201
|
||||
|
||||
The ``await`` is needed because reading the request body is an async
|
||||
I/O operation. This is more explicit than Flask's approach, and it
|
||||
means the event loop isn't blocked while waiting for the body to arrive.
|
||||
|
||||
|
||||
Templates
|
||||
---------
|
||||
|
||||
Both use Jinja2. The syntax is nearly identical:
|
||||
|
||||
Flask::
|
||||
|
||||
@app.route("/hello/<name>")
|
||||
def hello(name):
|
||||
return render_template("hello.html", name=name)
|
||||
|
||||
Responder::
|
||||
|
||||
@api.route("/hello/{name}")
|
||||
def hello(req, resp, *, name):
|
||||
resp.html = api.template("hello.html", name=name)
|
||||
|
||||
|
||||
Blueprints → Route Groups
|
||||
--------------------------
|
||||
|
||||
Flask uses Blueprints to organize routes. Responder has route groups:
|
||||
|
||||
Flask::
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
@bp.route("/users")
|
||||
def list_users():
|
||||
return jsonify([])
|
||||
|
||||
app.register_blueprint(bp)
|
||||
|
||||
Responder::
|
||||
|
||||
api_v1 = api.group("/api")
|
||||
|
||||
@api_v1.route("/users")
|
||||
def list_users(req, resp):
|
||||
resp.media = []
|
||||
|
||||
|
||||
Gradual Migration
|
||||
-----------------
|
||||
|
||||
You don't have to migrate all at once. Responder can mount your existing
|
||||
Flask app at a subroute, so you can move endpoints over one at a time::
|
||||
|
||||
from flask import Flask
|
||||
|
||||
flask_app = Flask(__name__)
|
||||
|
||||
# Your existing Flask routes stay here
|
||||
@flask_app.route("/legacy")
|
||||
def legacy():
|
||||
return "old endpoint"
|
||||
|
||||
# Mount Flask under /old, new routes go on Responder
|
||||
api.mount("/old", flask_app)
|
||||
|
||||
@api.route("/new")
|
||||
def new_endpoint(req, resp):
|
||||
resp.media = {"modern": True}
|
||||
|
||||
Requests to ``/old/legacy`` go to Flask. Everything else goes to
|
||||
Responder. When you've moved everything over, remove the mount.
|
||||
@@ -0,0 +1,129 @@
|
||||
Writing Middleware
|
||||
=================
|
||||
|
||||
Middleware sits between the server and your route handlers, processing
|
||||
every request and response that flows through your application. It's the
|
||||
right tool for cross-cutting concerns — things that apply to *all*
|
||||
requests, not just specific routes.
|
||||
|
||||
Common middleware use cases:
|
||||
|
||||
- Request logging and timing
|
||||
- Authentication and authorization
|
||||
- Adding security headers
|
||||
- Request ID generation
|
||||
- Rate limiting
|
||||
- Response compression (built-in)
|
||||
|
||||
|
||||
Hooks vs. Middleware
|
||||
--------------------
|
||||
|
||||
Responder gives you two levels of request processing:
|
||||
|
||||
**Hooks** (``before_request`` / ``after_request``) run inside Responder's
|
||||
routing layer. They receive Responder's ``req`` and ``resp`` objects and
|
||||
are the simplest way to add behavior::
|
||||
|
||||
@api.route(before_request=True)
|
||||
def add_header(req, resp):
|
||||
resp.headers["X-Powered-By"] = "Responder"
|
||||
|
||||
@api.after_request()
|
||||
def log_request(req, resp):
|
||||
print(f"{req.method} {req.url.path} -> {resp.status_code}")
|
||||
|
||||
**Middleware** runs at the ASGI level, wrapping the entire application.
|
||||
It's more powerful but more complex — you work with raw ASGI scopes
|
||||
instead of Responder objects. Use middleware when you need to process
|
||||
requests *before* they reach Responder's routing, or when you need to
|
||||
integrate with Starlette middleware.
|
||||
|
||||
|
||||
Using Starlette Middleware
|
||||
--------------------------
|
||||
|
||||
Responder is built on Starlette, so any Starlette middleware works
|
||||
out of the box::
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
class TimingMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request, call_next):
|
||||
import time
|
||||
start = time.time()
|
||||
response = await call_next(request)
|
||||
duration = time.time() - start
|
||||
response.headers["X-Response-Time"] = f"{duration:.3f}s"
|
||||
return response
|
||||
|
||||
api.add_middleware(TimingMiddleware)
|
||||
|
||||
The ``dispatch`` method receives a Starlette ``Request`` and a
|
||||
``call_next`` function. Call ``call_next(request)`` to pass the request
|
||||
to the next middleware (or to your route handler). The return value is
|
||||
a Starlette ``Response`` that you can modify before it's sent.
|
||||
|
||||
|
||||
Built-in Middleware
|
||||
-------------------
|
||||
|
||||
Responder configures several middleware components automatically:
|
||||
|
||||
- **GZipMiddleware** — compresses responses larger than 500 bytes
|
||||
- **TrustedHostMiddleware** — validates the ``Host`` header
|
||||
- **ServerErrorMiddleware** — catches unhandled exceptions
|
||||
- **ExceptionMiddleware** — routes exceptions to your handlers
|
||||
- **SessionMiddleware** — manages signed cookie sessions
|
||||
|
||||
Optional middleware you can enable:
|
||||
|
||||
- **CORSMiddleware** — ``api = responder.API(cors=True)``
|
||||
- **HTTPSRedirectMiddleware** — ``api = responder.API(enable_hsts=True)``
|
||||
|
||||
|
||||
Adding Third-Party Middleware
|
||||
-----------------------------
|
||||
|
||||
Any ASGI middleware can be added with ``api.add_middleware()``::
|
||||
|
||||
from some_package import SomeMiddleware
|
||||
|
||||
api.add_middleware(SomeMiddleware, option1="value", option2=True)
|
||||
|
||||
Keyword arguments are passed to the middleware's constructor.
|
||||
|
||||
|
||||
Middleware Order
|
||||
----------------
|
||||
|
||||
Middleware wraps your application like layers of an onion. The *last*
|
||||
middleware added is the *outermost* layer — it sees the request first
|
||||
and the response last.
|
||||
|
||||
Responder's built-in middleware stack (from outermost to innermost):
|
||||
|
||||
1. SessionMiddleware
|
||||
2. ServerErrorMiddleware
|
||||
3. CORSMiddleware (if enabled)
|
||||
4. TrustedHostMiddleware
|
||||
5. HTTPSRedirectMiddleware (if enabled)
|
||||
6. GZipMiddleware
|
||||
7. ExceptionMiddleware
|
||||
8. Your routes
|
||||
|
||||
When you call ``api.add_middleware()``, your middleware is added *outside*
|
||||
the existing stack. Keep this in mind for ordering dependencies — if
|
||||
middleware A depends on middleware B having run first, add B before A.
|
||||
|
||||
|
||||
When to Use What
|
||||
-----------------
|
||||
|
||||
- **Simple header additions, logging, auth checks** → use hooks
|
||||
- **Response transformation, timing, third-party integrations** → use middleware
|
||||
- **Rate limiting** → use the built-in ``RateLimiter`` (it uses hooks internally)
|
||||
- **Request ID** → use ``api = responder.API(request_id=True)``
|
||||
|
||||
Start with hooks. They're simpler and cover most cases. Graduate to
|
||||
middleware when hooks aren't enough.
|
||||
@@ -0,0 +1,219 @@
|
||||
Building a REST API
|
||||
===================
|
||||
|
||||
This tutorial walks you through building a complete REST API from scratch.
|
||||
By the end, you'll have a working API with CRUD operations, request
|
||||
validation, error handling, and interactive documentation.
|
||||
|
||||
We'll build a simple book catalog — a service that lets you create, read,
|
||||
update, and delete books.
|
||||
|
||||
|
||||
Project Setup
|
||||
-------------
|
||||
|
||||
Create a new file called ``app.py``::
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API(
|
||||
title="Book Catalog",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
docs_route="/docs",
|
||||
)
|
||||
|
||||
We're enabling OpenAPI documentation from the start. Visit ``/docs`` at
|
||||
any point to see interactive Swagger UI for your API.
|
||||
|
||||
|
||||
Define Your Models
|
||||
------------------
|
||||
|
||||
We'll use `Pydantic <https://docs.pydantic.dev/>`_ to define our data
|
||||
models. Pydantic models serve double duty — they validate incoming data
|
||||
*and* generate OpenAPI schemas automatically::
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class BookIn(BaseModel):
|
||||
"""What the client sends when creating a book."""
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
class Book(BaseModel):
|
||||
"""What the API returns."""
|
||||
id: int
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
``BookIn`` is the *input* model — it doesn't have an ``id`` because the
|
||||
server assigns that. ``Book`` is the *output* model — it includes
|
||||
everything. This input/output separation is a common REST API pattern.
|
||||
|
||||
|
||||
In-Memory Storage
|
||||
-----------------
|
||||
|
||||
For this tutorial, we'll store books in a simple dict. In a real
|
||||
application, you'd use a database (see :doc:`tutorial-sqlalchemy`)::
|
||||
|
||||
books_db: dict[int, dict] = {}
|
||||
next_id = 1
|
||||
|
||||
|
||||
List All Books
|
||||
--------------
|
||||
|
||||
The first endpoint — list all books. This is a ``GET`` request to
|
||||
``/books``::
|
||||
|
||||
@api.route("/books", methods=["GET"], response_model=list)
|
||||
def list_books(req, resp):
|
||||
resp.media = list(books_db.values())
|
||||
|
||||
In REST API design, ``GET`` requests should never modify data. They're
|
||||
*safe* and *idempotent* — calling them multiple times has the same effect
|
||||
as calling them once.
|
||||
|
||||
|
||||
Create a Book
|
||||
-------------
|
||||
|
||||
To create a book, the client sends a ``POST`` request with a JSON body.
|
||||
We use ``request_model=BookIn`` to validate the input automatically — if
|
||||
the client sends bad data, they get a ``422`` response with error details::
|
||||
|
||||
@api.route("/books", methods=["POST"], check_existing=False,
|
||||
request_model=BookIn, response_model=Book)
|
||||
async def create_book(req, resp):
|
||||
global next_id
|
||||
data = await req.media()
|
||||
|
||||
book = {"id": next_id, **data}
|
||||
books_db[next_id] = book
|
||||
next_id += 1
|
||||
|
||||
resp.media = book
|
||||
resp.status_code = 201
|
||||
|
||||
Note ``resp.status_code = 201`` — the HTTP ``201 Created`` status code
|
||||
tells the client that a new resource was successfully created. This is
|
||||
more informative than a generic ``200 OK``.
|
||||
|
||||
|
||||
Get a Single Book
|
||||
-----------------
|
||||
|
||||
Retrieve a specific book by its ID. The ``{book_id:int}`` route parameter
|
||||
ensures only integer IDs match — requests like ``/books/abc`` will 404::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["GET"], response_model=Book)
|
||||
def get_book(req, resp, *, book_id):
|
||||
if book_id not in books_db:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": f"Book {book_id} not found"}
|
||||
return
|
||||
|
||||
resp.media = books_db[book_id]
|
||||
|
||||
|
||||
Update a Book
|
||||
-------------
|
||||
|
||||
``PUT`` replaces a resource entirely. The client must send all fields::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["PUT"], check_existing=False,
|
||||
request_model=BookIn, response_model=Book)
|
||||
async def update_book(req, resp, *, book_id):
|
||||
if book_id not in books_db:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": f"Book {book_id} not found"}
|
||||
return
|
||||
|
||||
data = await req.media()
|
||||
book = {"id": book_id, **data}
|
||||
books_db[book_id] = book
|
||||
resp.media = book
|
||||
|
||||
|
||||
Delete a Book
|
||||
-------------
|
||||
|
||||
``DELETE`` removes a resource. The convention is to return ``204 No Content``
|
||||
with an empty body on success::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["DELETE"], check_existing=False)
|
||||
def delete_book(req, resp, *, book_id):
|
||||
if book_id not in books_db:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": f"Book {book_id} not found"}
|
||||
return
|
||||
|
||||
del books_db[book_id]
|
||||
resp.status_code = 204
|
||||
|
||||
|
||||
Error Handling
|
||||
--------------
|
||||
|
||||
Let's add a custom error handler so any ``ValueError`` in our code returns
|
||||
a clean JSON response instead of a 500 error::
|
||||
|
||||
@api.exception_handler(ValueError)
|
||||
async def handle_value_error(req, resp, exc):
|
||||
resp.status_code = 400
|
||||
resp.media = {"error": str(exc)}
|
||||
|
||||
|
||||
Run It
|
||||
------
|
||||
|
||||
Add the standard entry point at the bottom of your file::
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Start the server::
|
||||
|
||||
$ python app.py
|
||||
|
||||
Visit ``http://localhost:5042/docs`` to see your interactive API
|
||||
documentation. You can test every endpoint directly from the browser.
|
||||
|
||||
|
||||
Try It Out
|
||||
----------
|
||||
|
||||
Using ``curl``::
|
||||
|
||||
# Create a book
|
||||
$ curl -X POST http://localhost:5042/books \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "Dune", "author": "Frank Herbert", "year": 1965}'
|
||||
|
||||
# List all books
|
||||
$ curl http://localhost:5042/books
|
||||
|
||||
# Get a specific book
|
||||
$ curl http://localhost:5042/books/1
|
||||
|
||||
# Update a book
|
||||
$ curl -X PUT http://localhost:5042/books/1 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "Dune", "author": "Frank Herbert", "year": 1965, "isbn": "978-0441172719"}'
|
||||
|
||||
# Delete a book
|
||||
$ curl -X DELETE http://localhost:5042/books/1
|
||||
|
||||
|
||||
What's Next
|
||||
-----------
|
||||
|
||||
This tutorial used in-memory storage. For a real application, you'll want
|
||||
a database. See :doc:`tutorial-sqlalchemy` for how to integrate SQLAlchemy
|
||||
with Responder using the lifespan pattern.
|
||||
@@ -0,0 +1,254 @@
|
||||
Using SQLAlchemy
|
||||
================
|
||||
|
||||
Most real web applications need a database. This guide shows how to
|
||||
integrate `SQLAlchemy <https://www.sqlalchemy.org/>`_ with Responder,
|
||||
using async support and the lifespan pattern for connection management.
|
||||
|
||||
SQLAlchemy is the most popular Python database toolkit. It gives you an
|
||||
ORM (Object-Relational Mapper) for working with databases using Python
|
||||
classes instead of raw SQL, plus a powerful query builder for when you
|
||||
need fine-grained control.
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
Install SQLAlchemy with async support and an async database driver.
|
||||
We'll use SQLite for simplicity, but the pattern works with PostgreSQL,
|
||||
MySQL, and any other database SQLAlchemy supports::
|
||||
|
||||
$ uv pip install 'sqlalchemy[asyncio]' aiosqlite
|
||||
|
||||
|
||||
Define Your Models
|
||||
------------------
|
||||
|
||||
SQLAlchemy models map Python classes to database tables. Each attribute
|
||||
becomes a column::
|
||||
|
||||
# models.py
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
class Book(Base):
|
||||
__tablename__ = "books"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
title = Column(String, nullable=False)
|
||||
author = Column(String, nullable=False)
|
||||
year = Column(Integer, nullable=False)
|
||||
isbn = Column(String, nullable=True)
|
||||
|
||||
``DeclarativeBase`` is SQLAlchemy's modern base class (SQLAlchemy 2.0+).
|
||||
Each model class corresponds to a table, and each ``Column`` corresponds
|
||||
to a column in that table.
|
||||
|
||||
|
||||
Database Setup
|
||||
--------------
|
||||
|
||||
Create the async engine and session factory. The *engine* manages
|
||||
the connection pool. The *session* is your unit of work — you use it to
|
||||
query and modify data within a transaction::
|
||||
|
||||
# database.py
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
DATABASE_URL = "sqlite+aiosqlite:///./books.db"
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
The ``echo=True`` flag prints all SQL queries to the console — very
|
||||
helpful during development, but you'll want to disable it in production.
|
||||
|
||||
The ``expire_on_commit=False`` flag keeps model attributes accessible
|
||||
after a commit, which is convenient for returning created objects in
|
||||
API responses.
|
||||
|
||||
|
||||
Lifespan for Startup and Shutdown
|
||||
----------------------------------
|
||||
|
||||
Use Responder's lifespan context manager to create the database tables
|
||||
on startup and dispose of connections on shutdown::
|
||||
|
||||
# app.py
|
||||
from contextlib import asynccontextmanager
|
||||
import responder
|
||||
from database import engine
|
||||
from models import Base
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup: create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
# Shutdown: close all connections
|
||||
await engine.dispose()
|
||||
|
||||
api = responder.API(lifespan=lifespan)
|
||||
|
||||
This is the proper way to manage database connections in an async
|
||||
application. The lifespan context manager ensures that:
|
||||
|
||||
1. Tables are created before the first request
|
||||
2. The connection pool is properly closed when the server shuts down
|
||||
3. If table creation fails, the server won't start
|
||||
|
||||
|
||||
CRUD Endpoints
|
||||
--------------
|
||||
|
||||
Now let's build the API endpoints. Each one opens a database session,
|
||||
does its work, and commits or rolls back::
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from database import async_session
|
||||
from models import Book
|
||||
|
||||
# Pydantic models for request/response validation
|
||||
class BookIn(BaseModel):
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
class BookOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
The ``from_attributes = True`` config tells Pydantic to read data from
|
||||
SQLAlchemy model attributes (not just dicts). This lets you pass a
|
||||
SQLAlchemy ``Book`` object directly to ``BookOut``.
|
||||
|
||||
**List all books**::
|
||||
|
||||
@api.route("/books", methods=["GET"])
|
||||
async def list_books(req, resp):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(Book))
|
||||
books = result.scalars().all()
|
||||
resp.media = [BookOut.model_validate(b).model_dump() for b in books]
|
||||
|
||||
**Create a book**::
|
||||
|
||||
@api.route("/books", methods=["POST"], check_existing=False,
|
||||
request_model=BookIn, response_model=BookOut)
|
||||
async def create_book(req, resp):
|
||||
data = await req.media()
|
||||
|
||||
async with async_session() as session:
|
||||
book = Book(**data)
|
||||
session.add(book)
|
||||
await session.commit()
|
||||
await session.refresh(book)
|
||||
resp.media = BookOut.model_validate(book).model_dump()
|
||||
resp.status_code = 201
|
||||
|
||||
**Get a single book**::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["GET"])
|
||||
async def get_book(req, resp, *, book_id):
|
||||
async with async_session() as session:
|
||||
book = await session.get(Book, book_id)
|
||||
if book is None:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": "Book not found"}
|
||||
return
|
||||
resp.media = BookOut.model_validate(book).model_dump()
|
||||
|
||||
**Update a book**::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["PUT"], check_existing=False,
|
||||
request_model=BookIn)
|
||||
async def update_book(req, resp, *, book_id):
|
||||
data = await req.media()
|
||||
|
||||
async with async_session() as session:
|
||||
book = await session.get(Book, book_id)
|
||||
if book is None:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": "Book not found"}
|
||||
return
|
||||
|
||||
for key, value in data.items():
|
||||
setattr(book, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(book)
|
||||
resp.media = BookOut.model_validate(book).model_dump()
|
||||
|
||||
**Delete a book**::
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["DELETE"], check_existing=False)
|
||||
async def delete_book(req, resp, *, book_id):
|
||||
async with async_session() as session:
|
||||
book = await session.get(Book, book_id)
|
||||
if book is None:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": "Book not found"}
|
||||
return
|
||||
|
||||
await session.delete(book)
|
||||
await session.commit()
|
||||
resp.status_code = 204
|
||||
|
||||
|
||||
Run It
|
||||
------
|
||||
|
||||
::
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
|
||||
Start the server and you'll see SQLAlchemy's SQL echo in the console.
|
||||
The SQLite database file ``books.db`` is created automatically on first
|
||||
startup.
|
||||
|
||||
|
||||
Using PostgreSQL
|
||||
----------------
|
||||
|
||||
To switch to PostgreSQL, just change the connection URL and driver::
|
||||
|
||||
$ uv pip install asyncpg
|
||||
|
||||
::
|
||||
|
||||
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/mydb"
|
||||
|
||||
Everything else stays the same. SQLAlchemy abstracts the database
|
||||
differences so your application code doesn't need to change.
|
||||
|
||||
|
||||
Tips
|
||||
----
|
||||
|
||||
- Use ``async with async_session() as session`` for every request.
|
||||
Don't share sessions across requests — each request should get its
|
||||
own session and transaction.
|
||||
|
||||
- For complex queries, use SQLAlchemy's ``select()`` with ``.where()``,
|
||||
``.order_by()``, ``.limit()``, and ``.offset()`` — it composes
|
||||
naturally.
|
||||
|
||||
- In production, use connection pooling (SQLAlchemy does this by
|
||||
default) and set pool size limits appropriate for your database.
|
||||
|
||||
- Consider `Alembic <https://alembic.sqlalchemy.org/>`_ for database
|
||||
migrations — it tracks schema changes over time so you can evolve
|
||||
your database without losing data.
|
||||
@@ -0,0 +1,171 @@
|
||||
WebSocket Tutorial
|
||||
==================
|
||||
|
||||
HTTP is request-response — the client asks, the server answers, and the
|
||||
connection closes. WebSockets upgrade that into a persistent, bidirectional
|
||||
channel where both sides can send messages at any time. This is what powers
|
||||
chat apps, live dashboards, multiplayer games, and collaborative editors.
|
||||
|
||||
This tutorial builds a simple chat room to show how WebSockets work in
|
||||
Responder.
|
||||
|
||||
|
||||
How WebSockets Work
|
||||
-------------------
|
||||
|
||||
1. The client sends a normal HTTP request with an ``Upgrade: websocket``
|
||||
header.
|
||||
2. The server accepts the upgrade and the connection switches protocols.
|
||||
3. Both sides can now send messages freely — no more request/response.
|
||||
4. Either side can close the connection at any time.
|
||||
|
||||
In Responder, WebSocket routes receive a ``ws`` object instead of
|
||||
``req`` and ``resp``. The ``ws`` object has methods for accepting the
|
||||
connection, sending and receiving data, and closing.
|
||||
|
||||
|
||||
Echo Server
|
||||
-----------
|
||||
|
||||
The simplest WebSocket — echoes everything back::
|
||||
|
||||
@api.route("/ws", websocket=True)
|
||||
async def echo(ws):
|
||||
await ws.accept()
|
||||
while True:
|
||||
data = await ws.receive_text()
|
||||
await ws.send_text(f"Echo: {data}")
|
||||
|
||||
The ``await ws.accept()`` call completes the WebSocket handshake. After
|
||||
that, you're in a loop — receive a message, send a response.
|
||||
|
||||
Test it with a WebSocket client::
|
||||
|
||||
$ pip install websocket-client
|
||||
$ python -c "
|
||||
import websocket
|
||||
ws = websocket.create_connection('ws://localhost:5042/ws')
|
||||
ws.send('hello')
|
||||
print(ws.recv()) # Echo: hello
|
||||
ws.close()
|
||||
"
|
||||
|
||||
|
||||
Chat Room
|
||||
---------
|
||||
|
||||
A chat room needs to broadcast messages to all connected clients. We keep
|
||||
a set of active connections and iterate through them when someone sends
|
||||
a message::
|
||||
|
||||
connected = set()
|
||||
|
||||
@api.route("/chat", websocket=True)
|
||||
async def chat(ws):
|
||||
await ws.accept()
|
||||
connected.add(ws)
|
||||
try:
|
||||
while True:
|
||||
message = await ws.receive_text()
|
||||
# Broadcast to all connected clients
|
||||
for client in connected:
|
||||
await client.send_text(message)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
connected.discard(ws)
|
||||
|
||||
The ``try/finally`` block ensures we remove disconnected clients from
|
||||
the set, even if the connection drops unexpectedly.
|
||||
|
||||
|
||||
Data Formats
|
||||
------------
|
||||
|
||||
WebSockets support three data formats:
|
||||
|
||||
**Text** — plain strings::
|
||||
|
||||
await ws.send_text("hello")
|
||||
message = await ws.receive_text()
|
||||
|
||||
**JSON** — auto-serialized Python objects::
|
||||
|
||||
await ws.send_json({"type": "update", "data": [1, 2, 3]})
|
||||
message = await ws.receive_json()
|
||||
|
||||
**Binary** — raw bytes, useful for images, audio, or custom protocols::
|
||||
|
||||
await ws.send_bytes(b"\x00\x01\x02")
|
||||
data = await ws.receive_bytes()
|
||||
|
||||
|
||||
HTML Client
|
||||
-----------
|
||||
|
||||
Here's a minimal HTML page that connects to the chat room. The browser's
|
||||
built-in ``WebSocket`` API handles everything — no libraries needed:
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="messages"></div>
|
||||
<input id="input" placeholder="Type a message..." />
|
||||
<script>
|
||||
const ws = new WebSocket("ws://localhost:5042/chat");
|
||||
const messages = document.getElementById("messages");
|
||||
const input = document.getElementById("input");
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = event.data;
|
||||
messages.appendChild(p);
|
||||
};
|
||||
|
||||
input.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
ws.send(input.value);
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Save this as ``static/index.html`` and serve it with Responder's
|
||||
built-in static file support.
|
||||
|
||||
|
||||
Before-Request Hooks for WebSockets
|
||||
------------------------------------
|
||||
|
||||
You can run code before a WebSocket connection is established, just like
|
||||
HTTP before-request hooks. This is useful for authentication::
|
||||
|
||||
@api.before_request(websocket=True)
|
||||
async def ws_auth(ws):
|
||||
# Check for a token in the query string
|
||||
# (WebSocket headers are limited in browsers)
|
||||
await ws.accept()
|
||||
|
||||
WebSocket before-request hooks receive the ``ws`` object and must call
|
||||
``await ws.accept()`` if they want the connection to proceed.
|
||||
|
||||
|
||||
Testing WebSockets
|
||||
------------------
|
||||
|
||||
Use Starlette's ``TestClient`` for WebSocket tests::
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
def test_echo():
|
||||
client = TestClient(api)
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
ws.send_text("hello")
|
||||
assert ws.receive_text() == "Echo: hello"
|
||||
|
||||
The ``websocket_connect`` context manager handles the connection
|
||||
lifecycle — it connects on enter and disconnects on exit.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Complete REST API example with Pydantic validation.
|
||||
# https://responder.kennethreitz.org/tutorial-rest.html
|
||||
import responder
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BookIn(BaseModel):
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
|
||||
class BookOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
author: str
|
||||
year: int
|
||||
isbn: str | None = None
|
||||
|
||||
|
||||
api = responder.API(
|
||||
title="Book Catalog",
|
||||
version="1.0",
|
||||
openapi="3.0.2",
|
||||
docs_route="/docs",
|
||||
)
|
||||
|
||||
books_db: dict[int, dict] = {}
|
||||
next_id = 1
|
||||
|
||||
|
||||
@api.route("/books", methods=["GET"])
|
||||
def list_books(req, resp):
|
||||
resp.media = list(books_db.values())
|
||||
|
||||
|
||||
@api.route("/books", methods=["POST"], check_existing=False,
|
||||
request_model=BookIn, response_model=BookOut)
|
||||
async def create_book(req, resp):
|
||||
global next_id
|
||||
data = await req.media()
|
||||
book = {"id": next_id, **data}
|
||||
books_db[next_id] = book
|
||||
next_id += 1
|
||||
resp.media = book
|
||||
resp.status_code = 201
|
||||
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["GET"])
|
||||
def get_book(req, resp, *, book_id):
|
||||
if book_id not in books_db:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": f"Book {book_id} not found"}
|
||||
return
|
||||
resp.media = books_db[book_id]
|
||||
|
||||
|
||||
@api.route("/books/{book_id:int}", methods=["DELETE"], check_existing=False)
|
||||
def delete_book(req, resp, *, book_id):
|
||||
if book_id not in books_db:
|
||||
resp.status_code = 404
|
||||
resp.media = {"error": f"Book {book_id} not found"}
|
||||
return
|
||||
del books_db[book_id]
|
||||
resp.status_code = 204
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
@@ -0,0 +1,42 @@
|
||||
# Server-Sent Events streaming example.
|
||||
# https://responder.kennethreitz.org/tour.html#server-sent-events-sse
|
||||
import asyncio
|
||||
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
def index(req, resp):
|
||||
resp.html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>SSE Stream</h1>
|
||||
<div id="events"></div>
|
||||
<script>
|
||||
const source = new EventSource("/stream");
|
||||
const events = document.getElementById("events");
|
||||
source.onmessage = (e) => {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = e.data;
|
||||
events.appendChild(p);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@api.route("/stream")
|
||||
async def stream(req, resp):
|
||||
@resp.sse
|
||||
async def events():
|
||||
for i in range(20):
|
||||
yield {"data": f"Event #{i}"}
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
@@ -0,0 +1,57 @@
|
||||
# WebSocket chat room example.
|
||||
# https://responder.kennethreitz.org/tutorial-websockets.html
|
||||
import responder
|
||||
|
||||
api = responder.API()
|
||||
|
||||
connected = set()
|
||||
|
||||
|
||||
@api.route("/")
|
||||
def index(req, resp):
|
||||
resp.html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Chat Room</h1>
|
||||
<div id="messages" style="height:300px;overflow-y:scroll;border:1px solid #ccc;padding:10px;"></div>
|
||||
<input id="input" placeholder="Type a message..." style="width:300px;" />
|
||||
<script>
|
||||
const ws = new WebSocket(`ws://${location.host}/chat`);
|
||||
const messages = document.getElementById("messages");
|
||||
const input = document.getElementById("input");
|
||||
ws.onmessage = (e) => {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = e.data;
|
||||
messages.appendChild(p);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
};
|
||||
input.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter" && input.value) {
|
||||
ws.send(input.value);
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@api.route("/chat", websocket=True)
|
||||
async def chat(ws):
|
||||
await ws.accept()
|
||||
connected.add(ws)
|
||||
try:
|
||||
while True:
|
||||
message = await ws.receive_text()
|
||||
for client in connected:
|
||||
await client.send_text(message)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
connected.discard(ws)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.run()
|
||||
+3
-2
@@ -38,7 +38,9 @@ dependencies = [
|
||||
"graphene>=3",
|
||||
"graphql-core>=3.1",
|
||||
"marshmallow",
|
||||
"msgpack",
|
||||
"pueblo[sfa-full]>=0.0.11",
|
||||
"pydantic>=2",
|
||||
"python-multipart",
|
||||
"starlette[full]>=0.40",
|
||||
"uvicorn[standard]",
|
||||
@@ -52,12 +54,11 @@ develop = [
|
||||
]
|
||||
docs = [
|
||||
"alabaster<1.1",
|
||||
"myst-parser[linkify]",
|
||||
"myst-parser",
|
||||
"sphinx>=5,<9",
|
||||
"sphinx-autobuild",
|
||||
"sphinx-copybutton",
|
||||
"sphinx-design-elements",
|
||||
"sphinxext.opengraph",
|
||||
]
|
||||
release = ["build", "twine"]
|
||||
test = [
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "3.1.0"
|
||||
__version__ = "3.3.0"
|
||||
|
||||
+94
-1
@@ -59,6 +59,7 @@ class API:
|
||||
allowed_hosts=None,
|
||||
openapi_theme=DEFAULT_OPENAPI_THEME,
|
||||
lifespan=None,
|
||||
request_id=False,
|
||||
):
|
||||
self.background = BackgroundQueue()
|
||||
|
||||
@@ -131,6 +132,17 @@ class API:
|
||||
|
||||
self.templates = Templates(directory=templates_dir)
|
||||
|
||||
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."""
|
||||
@@ -150,6 +162,23 @@ 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)
|
||||
|
||||
@@ -327,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::
|
||||
@@ -336,9 +365,40 @@ 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
|
||||
|
||||
@@ -445,5 +505,38 @@ class API:
|
||||
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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Simple in-memory rate limiter for Responder."""
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Token bucket rate limiter.
|
||||
|
||||
Usage::
|
||||
|
||||
from responder.ext.ratelimit import RateLimiter
|
||||
|
||||
limiter = RateLimiter(requests=100, period=60) # 100 req/min
|
||||
|
||||
@api.route(before_request=True)
|
||||
def rate_limit(req, resp):
|
||||
limiter.check(req, resp)
|
||||
|
||||
Or use the shorthand::
|
||||
|
||||
limiter = RateLimiter(requests=100, period=60)
|
||||
limiter.install(api)
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, requests=100, period=60):
|
||||
self.max_requests = requests
|
||||
self.period = period
|
||||
self._buckets: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
def _client_key(self, req):
|
||||
client = req.client
|
||||
if client:
|
||||
return client[0]
|
||||
return req.headers.get("X-Forwarded-For", "unknown")
|
||||
|
||||
def _cleanup(self, key):
|
||||
now = time.time()
|
||||
cutoff = now - self.period
|
||||
self._buckets[key] = [
|
||||
t for t in self._buckets[key] if t > cutoff
|
||||
]
|
||||
|
||||
def check(self, req, resp):
|
||||
"""Check rate limit. Sets 429 status if exceeded."""
|
||||
key = self._client_key(req)
|
||||
self._cleanup(key)
|
||||
|
||||
if len(self._buckets[key]) >= self.max_requests:
|
||||
resp.status_code = 429
|
||||
resp.media = {"error": "rate limit exceeded"}
|
||||
resp.headers["Retry-After"] = str(self.period)
|
||||
return False
|
||||
|
||||
self._buckets[key].append(time.time())
|
||||
remaining = self.max_requests - len(self._buckets[key])
|
||||
resp.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
||||
resp.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
return True
|
||||
|
||||
def install(self, api):
|
||||
"""Install as a before_request hook on the API."""
|
||||
|
||||
@api.route(before_request=True)
|
||||
def _rate_limit(req, resp):
|
||||
self.check(req, resp)
|
||||
@@ -139,10 +139,25 @@ async def format_files(r, encode=False):
|
||||
return dump
|
||||
|
||||
|
||||
async def format_msgpack(r, encode=False):
|
||||
try:
|
||||
import msgpack
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"msgpack is required for MessagePack support: pip install msgpack"
|
||||
) from exc
|
||||
|
||||
if encode:
|
||||
r.headers.update({"Content-Type": "application/x-msgpack"})
|
||||
return msgpack.packb(r.media)
|
||||
return msgpack.unpackb(await r.content)
|
||||
|
||||
|
||||
def get_formats():
|
||||
return {
|
||||
"json": format_json,
|
||||
"yaml": format_yaml,
|
||||
"form": format_form,
|
||||
"files": format_files,
|
||||
"msgpack": format_msgpack,
|
||||
}
|
||||
|
||||
+78
-1
@@ -357,6 +357,80 @@ class Response:
|
||||
|
||||
return func
|
||||
|
||||
def sse(self, func, *args, **kwargs):
|
||||
"""Set up Server-Sent Events streaming.
|
||||
|
||||
Usage::
|
||||
|
||||
@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 dict can have: data, event, id, retry.
|
||||
Yielding a string is treated as data.
|
||||
"""
|
||||
assert inspect.isasyncgenfunction(func)
|
||||
|
||||
async def sse_generator():
|
||||
async for event in func(*args, **kwargs):
|
||||
if isinstance(event, str):
|
||||
yield f"data: {event}\n\n".encode()
|
||||
elif isinstance(event, dict):
|
||||
parts = []
|
||||
if "event" in event:
|
||||
parts.append(f"event: {event['event']}")
|
||||
if "id" in event:
|
||||
parts.append(f"id: {event['id']}")
|
||||
if "retry" in event:
|
||||
parts.append(f"retry: {event['retry']}")
|
||||
data = event.get("data", "")
|
||||
for line in str(data).split("\n"):
|
||||
parts.append(f"data: {line}")
|
||||
parts.append("")
|
||||
parts.append("")
|
||||
yield "\n".join(parts).encode()
|
||||
else:
|
||||
yield f"data: {event}\n\n".encode()
|
||||
|
||||
self._stream = sse_generator
|
||||
self.mimetype = "text/event-stream"
|
||||
self.headers["Cache-Control"] = "no-cache"
|
||||
self.headers["Connection"] = "keep-alive"
|
||||
|
||||
return func
|
||||
|
||||
def stream_file(self, path, *, content_type=None, chunk_size=8192):
|
||||
"""Stream a file without loading it entirely into memory.
|
||||
|
||||
:param path: Path to the file.
|
||||
:param content_type: Optional MIME type override.
|
||||
:param chunk_size: Size of chunks to read (default 8192 bytes).
|
||||
"""
|
||||
from pathlib import Path as PathType
|
||||
|
||||
path = PathType(path)
|
||||
|
||||
if content_type:
|
||||
self.mimetype = content_type
|
||||
else:
|
||||
import mimetypes
|
||||
|
||||
guessed = mimetypes.guess_type(str(path))[0]
|
||||
self.mimetype = guessed or "application/octet-stream"
|
||||
|
||||
async def file_generator():
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
self._stream = file_generator
|
||||
|
||||
def file(self, path, *, content_type=None):
|
||||
"""Serve a file from disk as the response.
|
||||
|
||||
@@ -385,7 +459,10 @@ class Response:
|
||||
@property
|
||||
async def body(self):
|
||||
if self._stream is not None:
|
||||
return (self._stream(), {})
|
||||
headers = {}
|
||||
if self.mimetype is not None:
|
||||
headers["Content-Type"] = self.mimetype
|
||||
return (self._stream(), headers)
|
||||
|
||||
if self.content is not None:
|
||||
headers = {}
|
||||
|
||||
@@ -123,6 +123,23 @@ class Route(BaseRoute):
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
# Auto-validate request body with Pydantic model
|
||||
req_model = getattr(self.endpoint, "_request_model", None)
|
||||
if req_model is not None and request.method in ("post", "put", "patch"):
|
||||
try:
|
||||
body = await request.media()
|
||||
req_model(**body)
|
||||
except Exception as exc:
|
||||
response.status_code = 422
|
||||
errors = []
|
||||
if hasattr(exc, "errors"):
|
||||
errors = exc.errors()
|
||||
else:
|
||||
errors = [{"msg": str(exc)}]
|
||||
response.media = {"errors": errors}
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
views = []
|
||||
|
||||
if inspect.isclass(self.endpoint):
|
||||
@@ -150,6 +167,23 @@ class Route(BaseRoute):
|
||||
else:
|
||||
await run_in_threadpool(view, request, response, **path_params)
|
||||
|
||||
# Auto-serialize response with Pydantic model
|
||||
resp_model = getattr(self.endpoint, "_response_model", None)
|
||||
if resp_model is not None and response.media is not None:
|
||||
try:
|
||||
validated = resp_model(**response.media)
|
||||
response.media = validated.model_dump()
|
||||
except Exception:
|
||||
pass # Don't break the response if serialization fails
|
||||
|
||||
# Run after-request hooks
|
||||
after_requests = scope.get("after_requests", [])
|
||||
for after_request in after_requests:
|
||||
if asyncio.iscoroutinefunction(after_request):
|
||||
await after_request(request, response)
|
||||
else:
|
||||
await run_in_threadpool(after_request, request, response)
|
||||
|
||||
if response.status_code is None:
|
||||
response.status_code = status_codes.HTTP_200 # type: ignore[attr-defined]
|
||||
|
||||
@@ -231,6 +265,7 @@ class Router:
|
||||
self.before_requests = (
|
||||
{"http": [], "ws": []} if before_requests is None else before_requests
|
||||
)
|
||||
self.after_requests: list = []
|
||||
self.events = defaultdict(list)
|
||||
self._lifespan_handler = lifespan
|
||||
|
||||
@@ -297,6 +332,9 @@ class Router:
|
||||
else:
|
||||
self.before_requests.setdefault("http", []).append(endpoint)
|
||||
|
||||
def after_request(self, endpoint):
|
||||
self.after_requests.append(endpoint)
|
||||
|
||||
def url_for(self, endpoint, **params):
|
||||
for route in self.routes:
|
||||
if endpoint in (route.endpoint, route.endpoint.__name__):
|
||||
@@ -371,6 +409,7 @@ class Router:
|
||||
route = self._resolve_route(scope)
|
||||
|
||||
scope["before_requests"] = self.before_requests
|
||||
scope["after_requests"] = self.after_requests
|
||||
|
||||
if route is not None:
|
||||
await route(scope, receive, send)
|
||||
|
||||
@@ -586,6 +586,61 @@ def test_openapi_static_url():
|
||||
assert url == "/static/swagger-ui.css"
|
||||
|
||||
|
||||
def test_pydantic_schema():
|
||||
"""Pydantic models registered via @api.schema."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
api = responder.API(
|
||||
title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"],
|
||||
)
|
||||
|
||||
@api.schema("Pet")
|
||||
class Pet(BaseModel):
|
||||
name: str
|
||||
age: int = 0
|
||||
|
||||
r = api.requests.get("http://;/schema.yml")
|
||||
assert r.status_code == 200
|
||||
assert "Pet" in r.text
|
||||
assert "name" in r.text
|
||||
assert "type: string" in r.text
|
||||
|
||||
|
||||
def test_pydantic_request_response_models():
|
||||
"""request_model and response_model generate OpenAPI schemas."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
api = responder.API(
|
||||
title="Test", version="1.0", openapi="3.0.2", allowed_hosts=[";"],
|
||||
)
|
||||
|
||||
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(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"id": 1, **data}
|
||||
|
||||
# Check schema generation
|
||||
r = api.requests.get("http://;/schema.yml")
|
||||
assert "ItemIn" in r.text
|
||||
assert "ItemOut" in r.text
|
||||
assert "$ref" in r.text
|
||||
assert "requestBody" in r.text
|
||||
|
||||
# Check the endpoint still works
|
||||
r = api.requests.post("http://;/items", json={"name": "widget", "price": 9.99})
|
||||
assert r.json() == {"id": 1, "name": "widget", "price": 9.99}
|
||||
|
||||
|
||||
def test_templates_context(tmp_path):
|
||||
"""Lines 23, 27: Templates.context getter and setter."""
|
||||
template_dir = tmp_path / "templates"
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Tests for new features: validation, SSE, after_request, route groups, etc."""
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
from starlette.testclient import TestClient as StarletteTestClient
|
||||
|
||||
import responder
|
||||
from responder.ext.ratelimit import RateLimiter
|
||||
|
||||
|
||||
# --- Pydantic auto-validation ---
|
||||
|
||||
|
||||
class ItemIn(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
|
||||
class ItemOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
price: float
|
||||
|
||||
|
||||
def test_pydantic_request_validation():
|
||||
"""Auto-validate request body against request_model."""
|
||||
api = responder.API(allowed_hosts=[";"])
|
||||
|
||||
@api.route("/items", methods=["POST"], request_model=ItemIn)
|
||||
async def create(req, resp):
|
||||
data = await req.media()
|
||||
resp.media = {"id": 1, **data}
|
||||
|
||||
# Valid request
|
||||
r = api.requests.post("http://;/items", json={"name": "widget", "price": 9.99})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["name"] == "widget"
|
||||
|
||||
# Invalid request — missing required field
|
||||
r = api.requests.post("http://;/items", json={"name": "widget"})
|
||||
assert r.status_code == 422
|
||||
assert "errors" in r.json()
|
||||
|
||||
# Invalid request — wrong type
|
||||
r = api.requests.post("http://;/items", json={"name": "widget", "price": "not_a_number"})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_pydantic_response_serialization():
|
||||
"""Auto-serialize response through response_model."""
|
||||
api = responder.API(allowed_hosts=[";"])
|
||||
|
||||
@api.route("/items", methods=["POST"],
|
||||
request_model=ItemIn, response_model=ItemOut)
|
||||
async def create(req, resp):
|
||||
data = await req.media()
|
||||
# Include an extra field that should be stripped by the model
|
||||
resp.media = {"id": 1, "secret": "hidden", **data}
|
||||
|
||||
r = api.requests.post("http://;/items", json={"name": "widget", "price": 9.99})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data == {"id": 1, "name": "widget", "price": 9.99}
|
||||
assert "secret" not in data
|
||||
|
||||
|
||||
def test_pydantic_validation_skipped_for_get():
|
||||
"""GET requests don't trigger request body validation."""
|
||||
api = responder.API(allowed_hosts=[";"])
|
||||
|
||||
@api.route("/items", methods=["GET"], request_model=ItemIn)
|
||||
def list_items(req, resp):
|
||||
resp.media = []
|
||||
|
||||
r = api.requests.get("http://;/items")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# --- SSE streaming ---
|
||||
|
||||
|
||||
def test_sse_streaming(api):
|
||||
"""Server-Sent Events with resp.sse."""
|
||||
|
||||
@api.route("/events")
|
||||
async def events(req, resp):
|
||||
@resp.sse
|
||||
async def stream():
|
||||
yield {"data": "hello"}
|
||||
yield {"event": "update", "data": "world"}
|
||||
yield "simple"
|
||||
|
||||
r = api.requests.get(api.url_for(events))
|
||||
assert r.status_code == 200
|
||||
assert "text/event-stream" in r.headers.get("content-type", "")
|
||||
assert "data: hello" in r.text
|
||||
assert "event: update" in r.text
|
||||
assert "data: world" in r.text
|
||||
assert "data: simple" in r.text
|
||||
|
||||
|
||||
def test_sse_with_id_and_retry(api):
|
||||
"""SSE events with id and retry fields."""
|
||||
|
||||
@api.route("/events")
|
||||
async def events(req, resp):
|
||||
@resp.sse
|
||||
async def stream():
|
||||
yield {"data": "msg", "id": "1", "retry": "5000"}
|
||||
|
||||
r = api.requests.get(api.url_for(events))
|
||||
assert "id: 1" in r.text
|
||||
assert "retry: 5000" in r.text
|
||||
|
||||
|
||||
# --- stream_file ---
|
||||
|
||||
|
||||
def test_stream_file(api, tmp_path):
|
||||
"""Stream a file without loading into memory."""
|
||||
big_file = tmp_path / "data.bin"
|
||||
big_file.write_bytes(b"x" * 10000)
|
||||
|
||||
@api.route("/download")
|
||||
def download(req, resp):
|
||||
resp.stream_file(big_file)
|
||||
|
||||
r = api.requests.get(api.url_for(download))
|
||||
assert len(r.content) == 10000
|
||||
assert r.content == b"x" * 10000
|
||||
|
||||
|
||||
def test_stream_file_content_type(api, tmp_path):
|
||||
"""stream_file detects content type."""
|
||||
css = tmp_path / "style.css"
|
||||
css.write_text("body { color: red; }")
|
||||
|
||||
@api.route("/css")
|
||||
def serve_css(req, resp):
|
||||
resp.stream_file(css)
|
||||
|
||||
r = api.requests.get(api.url_for(serve_css))
|
||||
assert "text/css" in r.headers.get("content-type", "")
|
||||
|
||||
|
||||
# --- after_request hooks ---
|
||||
|
||||
|
||||
def test_after_request(api):
|
||||
"""after_request hook runs after route handler."""
|
||||
|
||||
@api.after_request()
|
||||
def add_header(req, resp):
|
||||
resp.headers["X-After"] = "yes"
|
||||
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.text = "hello"
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert r.text == "hello"
|
||||
assert r.headers["X-After"] == "yes"
|
||||
|
||||
|
||||
def test_after_request_async(api):
|
||||
"""Async after_request hook."""
|
||||
|
||||
@api.after_request()
|
||||
async def add_header(req, resp):
|
||||
resp.headers["X-Async-After"] = "yes"
|
||||
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.text = "hello"
|
||||
|
||||
r = api.requests.get(api.url_for(view))
|
||||
assert r.headers["X-Async-After"] == "yes"
|
||||
|
||||
|
||||
# --- Route groups ---
|
||||
|
||||
|
||||
def test_route_group(api):
|
||||
"""Route group with shared prefix."""
|
||||
v1 = api.group("/v1")
|
||||
|
||||
@v1.route("/users")
|
||||
def list_users(req, resp):
|
||||
resp.media = [{"name": "alice"}]
|
||||
|
||||
@v1.route("/users/{user_id:int}")
|
||||
def get_user(req, resp, *, user_id):
|
||||
resp.media = {"id": user_id}
|
||||
|
||||
r = api.requests.get("http://;/v1/users")
|
||||
assert r.json() == [{"name": "alice"}]
|
||||
|
||||
r = api.requests.get("http://;/v1/users/42")
|
||||
assert r.json() == {"id": 42}
|
||||
|
||||
|
||||
def test_multiple_route_groups(api):
|
||||
"""Multiple route groups coexist."""
|
||||
v1 = api.group("/v1")
|
||||
v2 = api.group("/v2")
|
||||
|
||||
@v1.route("/status")
|
||||
def v1_status(req, resp):
|
||||
resp.media = {"version": 1}
|
||||
|
||||
@v2.route("/status")
|
||||
def v2_status(req, resp):
|
||||
resp.media = {"version": 2}
|
||||
|
||||
assert api.requests.get("http://;/v1/status").json() == {"version": 1}
|
||||
assert api.requests.get("http://;/v2/status").json() == {"version": 2}
|
||||
|
||||
|
||||
# --- Request ID ---
|
||||
|
||||
|
||||
def test_request_id():
|
||||
"""Auto-generated request ID header."""
|
||||
api = responder.API(request_id=True, allowed_hosts=[";"])
|
||||
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.text = "ok"
|
||||
|
||||
r = api.requests.get("http://;/")
|
||||
assert "X-Request-ID" in r.headers
|
||||
assert len(r.headers["X-Request-ID"]) > 0
|
||||
|
||||
|
||||
def test_request_id_forwarded():
|
||||
"""Request ID is forwarded from client header."""
|
||||
api = responder.API(request_id=True, allowed_hosts=[";"])
|
||||
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.text = "ok"
|
||||
|
||||
r = api.requests.get("http://;/", headers={"X-Request-ID": "my-trace-123"})
|
||||
assert r.headers["X-Request-ID"] == "my-trace-123"
|
||||
|
||||
|
||||
# --- Rate Limiting ---
|
||||
|
||||
|
||||
def test_rate_limiter():
|
||||
"""Rate limiter returns 429 when exceeded."""
|
||||
api = responder.API(allowed_hosts=[";"])
|
||||
limiter = RateLimiter(requests=3, period=60)
|
||||
limiter.install(api)
|
||||
|
||||
@api.route("/")
|
||||
def view(req, resp):
|
||||
resp.text = "ok"
|
||||
|
||||
for i in range(3):
|
||||
r = api.requests.get("http://;/")
|
||||
assert r.status_code == 200
|
||||
assert "X-RateLimit-Remaining" in r.headers
|
||||
|
||||
# 4th request should be rate limited
|
||||
r = api.requests.get("http://;/")
|
||||
assert r.status_code == 429
|
||||
assert "Retry-After" in r.headers
|
||||
|
||||
|
||||
# --- MessagePack ---
|
||||
|
||||
|
||||
def test_msgpack_format(api):
|
||||
"""MessagePack encoding and decoding."""
|
||||
import msgpack
|
||||
|
||||
@api.route("/")
|
||||
async def view(req, resp):
|
||||
data = await req.media("msgpack")
|
||||
resp.media = data
|
||||
|
||||
payload = {"hello": "world", "number": 42}
|
||||
r = api.requests.post(
|
||||
api.url_for(view),
|
||||
content=msgpack.packb(payload),
|
||||
headers={"Content-Type": "application/x-msgpack"},
|
||||
)
|
||||
assert r.json() == payload
|
||||
Reference in New Issue
Block a user