Compare commits

...

15 Commits

Author SHA1 Message Date
kennethreitz 3c2b1acc19 Bump version to 3.3.0 2026-03-22 13:58:18 -04:00
kennethreitz a3b49ab9fd Add auth, WebSocket, middleware, config guides. Examples and changelog.
New documentation pages:
- Authentication: API keys, JWT tokens, session auth, custom exceptions
- WebSocket Tutorial: echo server, chat room, HTML client, data formats
- Writing Middleware: hooks vs middleware, Starlette integration, ordering
- Configuration: env vars, .env files, secret keys, debug mode, production setup

Also:
- Complete working example at end of quickstart with cross-references
- Three new example files: rest_api.py, websocket_chat.py, sse_stream.py
- Changelog updated for v3.2.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:56:35 -04:00
kennethreitz bf17b02653 Add tutorials: REST API, SQLAlchemy, Flask migration. Rewrite CLI and API ref.
Three new tutorial pages:
- Building a REST API: full CRUD with Pydantic validation, from scratch
- Using SQLAlchemy: async engine, lifespan setup, CRUD with ORM
- Migrating from Flask: concept mapping, quick reference table,
  gradual migration via app mounting

Also rewritten:
- CLI docs: cleaner, more concise
- API reference: added prose descriptions for each section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:34:01 -04:00
kennethreitz 9383cd0f16 Rework homepage prose for better flow
Lead with recognition, earn each paragraph, land on the welcome.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:09:46 -04:00
kennethreitz 226bd63ed3 Full docs pass: educational prose throughout all pages
Every section now teaches web development concepts alongside the code:
- HTTP methods, status codes, content negotiation explained
- What ASGI is and why it matters
- How cookies, sessions, CORS, and HSTS work
- When to use WebSockets vs SSE
- Why request validation matters
- How background tasks differ from task queues
- What rate limiting protects against
- What Host header injection is
- Separation of concerns in templating

People should learn about web development while reading these docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:07:56 -04:00
kennethreitz b3c55f68d9 Expand testing docs with prose, examples, and tips
New sections: request validation, headers/cookies, custom error
handlers, before/after hooks, and practical tips. Every section
now explains the why, not just the how.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 13:00:29 -04:00
kennethreitz a2a2ae21ff Good intentions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:58:05 -04:00
kennethreitz 84074860aa Add note about Responder's spirit as a passion project
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:55:36 -04:00
kennethreitz 21baa03640 CI: Add CNAME for custom domain in GitHub Pages deploy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:51:14 -04:00
kennethreitz 0c552e25cb CI: Deploy docs to GitHub Pages on push to main
Uses peaceiris/actions-gh-pages to push built docs to gh-pages branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:48:05 -04:00
kennethreitz 24958bff51 v3.2.0: Request ID, rate limiting, MessagePack, docs update
New features:
- Request ID: api = responder.API(request_id=True)
- Rate limiting: RateLimiter(requests=100, period=60).install(api)
- MessagePack format: await req.media("msgpack")
- All new features documented in tour

176 tests, 95% coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:45:50 -04:00
kennethreitz 364f6b67f7 Add auto-validation, SSE, stream_file, after_request, route groups
Five new features:

1. **Pydantic auto-validation** — if request_model is set, request
   bodies are validated automatically and 422 returned on failure.
   If response_model is set, resp.media is serialized through the
   model (extra fields stripped, types enforced).

2. **Server-Sent Events** — resp.sse for real-time streaming:

       @resp.sse
       async def stream():
           yield {"event": "update", "data": "hello"}

3. **resp.stream_file()** — stream large files without loading
   into memory, with automatic content-type detection.

4. **after_request hooks** — run code after every request:

       @api.after_request()
       def add_request_id(req, resp):
           resp.headers["X-Request-ID"] = str(uuid.uuid4())

5. **Route groups** — organize routes with shared prefixes:

       v1 = api.group("/v1")

       @v1.route("/users")
       def list_users(req, resp): ...

Also fix streaming responses not sending Content-Type headers.

172 tests, 95% coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:42:48 -04:00
kennethreitz 2cab7b5af7 Document Pydantic OpenAPI support in feature tour
Three approaches: Pydantic models (recommended), YAML docstrings,
and marshmallow schemas — all work together.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:36:02 -04:00
kennethreitz 1bfd85b003 Add Pydantic support for OpenAPI schema generation
Define your API schemas with Pydantic models instead of (or alongside)
YAML docstrings and marshmallow:

    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}

Also works with @api.schema("Name") decorator for registering
standalone schema components.

Pydantic models, marshmallow schemas, and YAML docstrings can all
be used together in the same API.

Also: rewrite docs with more prose, restore sidebar logo and links,
add FastAPI acknowledgment, update homepage copy.

161 tests, 95% coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:35:07 -04:00
kennethreitz 33ebc77f10 Rewrite docs from scratch, remove 14MB of bundled fonts
Complete documentation rewrite:
- Clean, code-first index page (no badges, no testimonials)
- Rewritten quickstart: request/response reference, templates, background tasks
- Rewritten feature tour: method filtering, lifespan, file serving,
  error handling, hooks, WebSockets, GraphQL, OpenAPI, CORS, sessions
- Simplified testing and deployment guides
- Stripped conf.py to essential extensions only

Removed cruft:
- 14MB of paid font files (Mercury, Operator Mono)
- Google Analytics (deprecated Universal Analytics)
- UserVoice widget
- Konami code easter egg
- Algolia DocSearch (not configured)
- Twitter widgets
- Unused Sphinx extensions (mathjax, ifconfig, coverage, doctest, opengraph)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 12:17:22 -04:00
95 changed files with 3622 additions and 6164 deletions
+11
View File
@@ -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
+38
View File
@@ -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
-7
View File
@@ -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;
}
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. -->
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
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
File diff suppressed because one or more lines are too long
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
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/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
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
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
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
-161
View File
@@ -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;
}
}
+3 -224
View File
@@ -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>
+5 -80
View File
@@ -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>
-94
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+172
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+191
View File
@@ -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.
+192
View File
@@ -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.
+129
View File
@@ -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.
+219
View File
@@ -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.
+254
View File
@@ -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.
+171
View File
@@ -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.
+70
View File
@@ -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()
+42
View File
@@ -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()
+57
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "3.1.0"
__version__ = "3.3.0"
+94 -1
View File
@@ -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)
+96 -3
View File
@@ -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):
+67
View File
@@ -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)
+15
View File
@@ -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
View File
@@ -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 = {}
+39
View File
@@ -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)
+55
View File
@@ -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"
+289
View File
@@ -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