├── docs_src
├── py.typed
├── __init__.py
├── advanced
│ ├── __init__.py
│ ├── binders
│ │ ├── __init__.py
│ │ └── msgpack
│ │ │ ├── __init__.py
│ │ │ ├── functions.py
│ │ │ ├── openapi.py
│ │ │ └── extractor.py
│ ├── dependencies
│ │ ├── __init__.py
│ │ ├── tutorial_007.py
│ │ ├── tutorial_001.py
│ │ ├── tutorial_002.py
│ │ ├── tutorial_003.py
│ │ ├── tutorial_004.py
│ │ ├── tutorial_005.py
│ │ └── tutorial_006.py
│ ├── responses
│ │ ├── __init__.py
│ │ ├── tutorial_003.py
│ │ ├── tutorial_004.py
│ │ ├── tutorial_005.py
│ │ ├── tutorial_001.py
│ │ └── tutorial_002.py
│ ├── root_path.py
│ ├── body_union.py
│ └── websockets.py
└── tutorial
│ ├── __init__.py
│ ├── body
│ ├── __init__.py
│ ├── tutorial_003.py
│ ├── tutorial_006.py
│ ├── tutorial_001.py
│ ├── tutorial_004.py
│ ├── tutorial_002.py
│ └── tutorial_005.py
│ ├── files
│ ├── __init__.py
│ ├── tutorial_002.py
│ ├── tutorial_001.py
│ ├── tutorial_003.py
│ └── tutorial_004.py
│ ├── forms
│ ├── __init__.py
│ ├── tutorial_001.py
│ ├── tutorial_002.py
│ └── tutorial_003.py
│ ├── routing
│ ├── __init__.py
│ ├── tutorial_001.py
│ └── tutorial_002.py
│ ├── security
│ └── README.md
│ ├── cookie_params
│ ├── __init__.py
│ └── tutorial_001.py
│ ├── dependencies
│ ├── __init__.py
│ ├── tutorial_007_response_003.json
│ ├── tutorial_007_response_001.json
│ ├── tutorial_007_response_002.json
│ ├── tutorial_005.py
│ ├── tutorial_001.py
│ ├── tutorial_002.py
│ ├── tutorial_003.py
│ ├── tutorial_004.py
│ ├── tutorial_006.py
│ └── tutorial_007.py
│ ├── header_params
│ ├── __init__.py
│ ├── tutorial_001.py
│ └── tutorial_002.py
│ ├── lifespans
│ ├── __init__.py
│ ├── tutorial_001.py
│ └── tutorial_002.py
│ ├── middleware
│ ├── __init__.py
│ └── tutorial_001.py
│ ├── path_params
│ ├── __init__.py
│ ├── tutorial_002_response_1.json
│ ├── tutorial_003_response_1.json
│ ├── tutorial_001_response_1.json
│ ├── tutorial_004_response_1.json
│ ├── tutorial_002_response_2.json
│ ├── tutorial_003_response_2.json
│ ├── tutorial_001.py
│ ├── tutorial_002.py
│ ├── tutorial_004.py
│ └── tutorial_003.py
│ ├── query_params
│ ├── __init__.py
│ ├── tutorial_001_response_1.json
│ ├── tutorial_005_response_1.json
│ ├── tutorial_006_response_1.json
│ ├── tutorial_001_response_2.json
│ ├── tutorial_003_response_2.json
│ ├── tutorial_003_response_1.json
│ ├── tutorial_002_response_1.json
│ ├── tutorial_002.py
│ ├── tutorial_001.py
│ ├── tutorial_005.py
│ ├── tutorial_004.py
│ ├── tutorial_003.py
│ └── tutorial_006.py
│ ├── param_constraints_and_metadata
│ ├── __init__.py
│ ├── tutorial_001.py
│ └── tutorial_002.py
│ ├── minimal_app.py
│ ├── json_body.py
│ └── json_body_with_examples.py
├── tests
├── __init__.py
├── test_docs
│ ├── __init__.py
│ ├── advanced
│ │ ├── __init__.py
│ │ ├── binders
│ │ │ ├── __init__.py
│ │ │ └── test_msgpack.py
│ │ ├── dependencies
│ │ │ ├── __init__.py
│ │ │ ├── test_tutorial_003.py
│ │ │ ├── test_tutorial_001.py
│ │ │ ├── test_tutorial_005.py
│ │ │ ├── test_tutorial_006.py
│ │ │ ├── test_tutorial_002.py
│ │ │ └── test_tutorial_004.py
│ │ ├── additional_responses
│ │ │ └── test_tutorial_004.py
│ │ ├── test_root_path.py
│ │ └── test_websockets.py
│ └── tutorial
│ │ ├── __init__.py
│ │ ├── body
│ │ ├── __init__.py
│ │ └── test_tutorial_006.py
│ │ ├── files
│ │ ├── __init__.py
│ │ └── test_tutorial_003.py
│ │ ├── forms
│ │ └── __init__.py
│ │ ├── test_json_body.py
│ │ ├── lifespans
│ │ ├── __init__.py
│ │ ├── test_tutorial_001.py
│ │ └── test_tutorial_002.py
│ │ ├── middleware
│ │ ├── __init__.py
│ │ └── test_tutorial_001.py
│ │ ├── path_params
│ │ └── __init__.py
│ │ ├── routing
│ │ ├── __init__.py
│ │ └── test_tutorial001.py
│ │ ├── test_minimal_app.py
│ │ ├── cookie_params
│ │ └── __init__.py
│ │ ├── dependencies
│ │ ├── __init__.py
│ │ ├── test_tutorial_005.py
│ │ ├── test_tutorial_002.py
│ │ ├── test_tutorial_004.py
│ │ ├── test_tutorial_001.py
│ │ ├── test_tutorial_006.py
│ │ ├── test_tutorial_003.py
│ │ └── test_tutorial_007.py
│ │ ├── header_params
│ │ └── __init__.py
│ │ ├── query_params
│ │ └── __init__.py
│ │ ├── test_json_body_with_examples.py
│ │ ├── test_path_parameter_serialization.py
│ │ └── param_constraints_and_metadata
│ │ └── __init__.py
├── test_openapi
│ ├── __init__.py
│ ├── model_name_conflict_resolution
│ │ ├── __init__.py
│ │ ├── user1.py
│ │ └── user2.py
│ ├── test_swagger_html.py
│ ├── expected_swagger_html.html
│ └── test_docstrings.py
├── test_responses
│ └── __init__.py
├── test_dependencies
│ ├── __init__.py
│ ├── test_injectable_classes.py
│ ├── test_contextvars.py
│ └── test_overrides.py
├── test_request_bodies
│ └── __init__.py
├── test_request_params
│ └── __init__.py
├── test_config.py
├── test_lifespans.py
├── test_exception_handlers.py
└── test_routing
│ └── test_operation.py
├── xpresso
├── py.typed
├── _utils
│ ├── __init__.py
│ ├── asgi.py
│ ├── schemas.py
│ ├── endpoint_dependent.py
│ ├── scope_resolver.py
│ ├── typing.py
│ ├── pydantic_utils.py
│ ├── overrides.py
│ └── routing.py
├── binders
│ ├── __init__.py
│ ├── _binders
│ │ ├── __init__.py
│ │ ├── grouped.py
│ │ ├── utils.py
│ │ ├── media_type_validator.py
│ │ ├── pydantic_validators.py
│ │ └── query_params.py
│ ├── dependents.py
│ └── api.py
├── openapi
│ ├── __init__.py
│ ├── _constants.py
│ ├── _utils.py
│ └── _html.py
├── routing
│ ├── __init__.py
│ ├── host.py
│ └── mount.py
├── testclient.py
├── staticfiles.py
├── background.py
├── middleware
│ ├── __init__.py
│ ├── cors.py
│ ├── gzip.py
│ ├── trustedhost.py
│ ├── httpsredirect.py
│ └── exceptions.py
├── dependencies
│ └── __init__.py
├── requests.py
├── websockets.py
├── typing.py
├── datastructures.py
├── config.py
├── exceptions.py
├── exception_handlers.py
├── __init__.py
└── responses.py
├── benchmarks
├── __init__.py
├── line_profile.sh
├── run_wrk.sh
├── run_app.sh
├── README.md
├── starlette_app.py
├── constants.py
├── utils.py
├── fastapi_app.py
└── xpresso_app.py
├── docs
├── README.md
├── contributing.md
├── docs
│ ├── NOTE.txt
│ └── readme_example_swagger.png
├── tutorial
│ ├── body_001.png
│ ├── body_002.png
│ ├── routing_002.png
│ ├── path_params_001.png
│ ├── param_constraints_and_metadata_001.png
│ ├── param_constraints_and_metadata_002.png
│ ├── cookie_params.md
│ ├── dependencies
│ │ ├── http-params.md
│ │ ├── nested.md
│ │ ├── lifecycle.md
│ │ └── scopes.md
│ ├── middleware
│ │ └── diagram_001.mmd
│ ├── lifespan.md
│ ├── header_params.md
│ ├── forms.md
│ ├── minimal_app.md
│ ├── files.md
│ └── routing.md
├── readme_example_swagger.png
├── assets
│ └── images
│ │ ├── xpresso-bean.png
│ │ ├── xpresso-title.png
│ │ ├── xpresso-bean.pxd
│ │ ├── metadata.info
│ │ ├── QuickLook
│ │ │ ├── Icon.tiff
│ │ │ └── Thumbnail.tiff
│ │ └── data
│ │ │ ├── selection
│ │ │ ├── shapeSelection
│ │ │ │ ├── path
│ │ │ │ └── meta
│ │ │ └── meta
│ │ │ └── originalImportedContentDocumentInfo
│ │ └── xpresso-title.pxd
│ │ ├── metadata.info
│ │ ├── QuickLook
│ │ ├── Icon.tiff
│ │ └── Thumbnail.tiff
│ │ └── data
│ │ ├── 72678C72-8B48-4CB1-8C80-312AD99C8C59
│ │ └── FBF26CFB-8E0A-4D4B-8091-5BC779991BEF
├── css
│ └── custom.css
├── overrides
│ └── main.html
├── advanced
│ ├── websockets.md
│ ├── body-union.md
│ ├── dependencies
│ │ ├── caching.md
│ │ ├── responses.md
│ │ ├── composition-root.md
│ │ └── overrides.md
│ ├── proxies-root-path.md
│ └── responses.md
└── types.md
├── .flake8
├── .github
├── FUNDING.yml
└── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── codecov.yml
├── .gitignore
├── LICENSE.txt
├── .pre-commit-config.yaml
├── Makefile
├── CONTRIBUTING.md
└── mkdocs.yml
/docs_src/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/xpresso/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/benchmarks/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/benchmarks/line_profile.sh:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/xpresso/_utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/xpresso/binders/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/xpresso/openapi/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/xpresso/routing/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/advanced/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_openapi/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_responses/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | --8<-- "README.md"
2 |
--------------------------------------------------------------------------------
/docs_src/advanced/binders/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/body/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/files/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/forms/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/routing/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/security/README.md:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_dependencies/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_request_bodies/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_request_params/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/xpresso/binders/_binders/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/advanced/dependencies/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/advanced/responses/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/cookie_params/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/header_params/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/lifespans/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/middleware/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/body/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/files/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/forms/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/test_json_body.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/advanced/binders/msgpack/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/binders/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/lifespans/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/middleware/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/path_params/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/routing/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/test_minimal_app.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/contributing.md:
--------------------------------------------------------------------------------
1 | --8<-- "CONTRIBUTING.md"
2 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/dependencies/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/cookie_params/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/dependencies/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/header_params/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/query_params/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/test_json_body_with_examples.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs_src/tutorial/param_constraints_and_metadata/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/test_path_parameter_serialization.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_openapi/model_name_conflict_resolution/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/param_constraints_and_metadata/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/xpresso/openapi/_constants.py:
--------------------------------------------------------------------------------
1 | REF_PREFIX = "#/components/schemas/"
2 |
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/tutorial_007_response_003.json:
--------------------------------------------------------------------------------
1 | "foobar"
2 |
--------------------------------------------------------------------------------
/benchmarks/run_wrk.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | wrk http://localhost:8080$1 -d5s -t4 -c64
3 |
--------------------------------------------------------------------------------
/xpresso/routing/host.py:
--------------------------------------------------------------------------------
1 | from starlette.routing import Host as Host # noqa: F401
2 |
--------------------------------------------------------------------------------
/xpresso/routing/mount.py:
--------------------------------------------------------------------------------
1 | from starlette.routing import Mount as Mount # noqa: F401
2 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/tutorial_002_response_1.json:
--------------------------------------------------------------------------------
1 | {
2 | "item_id": 1234
3 | }
4 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/tutorial_003_response_1.json:
--------------------------------------------------------------------------------
1 | {
2 | "item_id": 1
3 | }
4 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/tutorial_001_response_1.json:
--------------------------------------------------------------------------------
1 | {
2 | "item_id": "1234"
3 | }
4 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/tutorial_004_response_1.json:
--------------------------------------------------------------------------------
1 | [
2 | 1,
3 | 2,
4 | 3
5 | ]
6 |
--------------------------------------------------------------------------------
/xpresso/testclient.py:
--------------------------------------------------------------------------------
1 | from starlette.testclient import TestClient as TestClient # noqa: F401
2 |
--------------------------------------------------------------------------------
/xpresso/staticfiles.py:
--------------------------------------------------------------------------------
1 | from starlette.staticfiles import StaticFiles as StaticFiles # noqa: F401
2 |
--------------------------------------------------------------------------------
/docs/docs/NOTE.txt:
--------------------------------------------------------------------------------
1 | This folder only exists to allow the embedded `/README.md` to properly link to media.
2 |
--------------------------------------------------------------------------------
/docs/tutorial/body_001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/tutorial/body_001.png
--------------------------------------------------------------------------------
/docs/tutorial/body_002.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/tutorial/body_002.png
--------------------------------------------------------------------------------
/xpresso/background.py:
--------------------------------------------------------------------------------
1 | from starlette.background import BackgroundTasks as BackgroundTasks # noqa: F401
2 |
--------------------------------------------------------------------------------
/xpresso/middleware/__init__.py:
--------------------------------------------------------------------------------
1 | from starlette.middleware import Middleware as Middleware # noqa: F401
2 |
--------------------------------------------------------------------------------
/docs/tutorial/routing_002.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/tutorial/routing_002.png
--------------------------------------------------------------------------------
/docs/readme_example_swagger.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/readme_example_swagger.png
--------------------------------------------------------------------------------
/docs/tutorial/path_params_001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/tutorial/path_params_001.png
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/tutorial_007_response_001.json:
--------------------------------------------------------------------------------
1 | {
2 | "detail": "Missing roles: ['user']"
3 | }
4 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_001_response_1.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "item_name": "Bar"
4 | }
5 | ]
6 |
--------------------------------------------------------------------------------
/xpresso/middleware/cors.py:
--------------------------------------------------------------------------------
1 | from starlette.middleware.cors import CORSMiddleware as CORSMiddleware # noqa: F401
2 |
--------------------------------------------------------------------------------
/xpresso/middleware/gzip.py:
--------------------------------------------------------------------------------
1 | from starlette.middleware.gzip import GZipMiddleware as GZipMiddleware # noqa: F401
2 |
--------------------------------------------------------------------------------
/benchmarks/run_app.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | gunicorn -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8080 "benchmarks.$1_app:app"
3 |
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-bean.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-bean.png
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/tutorial_007_response_002.json:
--------------------------------------------------------------------------------
1 | {
2 | "detail": "Missing roles: ['items-user']"
3 | }
4 |
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-title.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-title.png
--------------------------------------------------------------------------------
/docs/docs/readme_example_swagger.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/docs/readme_example_swagger.png
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_005_response_1.json:
--------------------------------------------------------------------------------
1 | {
2 | "limit": 1,
3 | "prefix": "Ba",
4 | "skip": 0
5 | }
6 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_006_response_1.json:
--------------------------------------------------------------------------------
1 | {
2 | "limit": 1,
3 | "prefix": "Ba",
4 | "skip": 0
5 | }
6 |
--------------------------------------------------------------------------------
/tests/test_openapi/model_name_conflict_resolution/user1.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 |
4 | class User(BaseModel):
5 | foo: int
6 |
--------------------------------------------------------------------------------
/tests/test_openapi/model_name_conflict_resolution/user2.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 |
4 | class User(BaseModel):
5 | foo: str
6 |
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-bean.pxd/metadata.info:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-bean.pxd/metadata.info
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-title.pxd/metadata.info:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-title.pxd/metadata.info
--------------------------------------------------------------------------------
/docs/tutorial/param_constraints_and_metadata_001.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/tutorial/param_constraints_and_metadata_001.png
--------------------------------------------------------------------------------
/docs/tutorial/param_constraints_and_metadata_002.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/tutorial/param_constraints_and_metadata_002.png
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_001_response_2.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "item_name": "Foo"
4 | },
5 | {
6 | "item_name": "Bar"
7 | }
8 | ]
9 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_003_response_2.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "item_name": "Foo"
4 | },
5 | {
6 | "item_name": "Bar"
7 | }
8 | ]
9 |
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-bean.pxd/QuickLook/Icon.tiff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-bean.pxd/QuickLook/Icon.tiff
--------------------------------------------------------------------------------
/docs/css/custom.css:
--------------------------------------------------------------------------------
1 | [data-md-color-scheme="default"] {
2 | --md-default-bg-color: rgb(255, 250, 245);
3 | --md-code-bg-color: rgb(242, 239, 235);
4 | }
5 |
--------------------------------------------------------------------------------
/xpresso/middleware/trustedhost.py:
--------------------------------------------------------------------------------
1 | from starlette.middleware.trustedhost import ( # noqa: F401
2 | TrustedHostMiddleware as TrustedHostMiddleware,
3 | )
4 |
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-title.pxd/QuickLook/Icon.tiff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-title.pxd/QuickLook/Icon.tiff
--------------------------------------------------------------------------------
/xpresso/middleware/httpsredirect.py:
--------------------------------------------------------------------------------
1 | from starlette.middleware.httpsredirect import ( # noqa: F401
2 | HTTPSRedirectMiddleware as HTTPSRedirectMiddleware,
3 | )
4 |
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-bean.pxd/QuickLook/Thumbnail.tiff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-bean.pxd/QuickLook/Thumbnail.tiff
--------------------------------------------------------------------------------
/xpresso/dependencies/__init__.py:
--------------------------------------------------------------------------------
1 | from xpresso.dependencies._dependencies import Depends, Injectable, Singleton
2 |
3 | __all__ = ("Depends", "Injectable", "Singleton")
4 |
--------------------------------------------------------------------------------
/xpresso/requests.py:
--------------------------------------------------------------------------------
1 | from starlette.requests import HTTPConnection as HTTPConnection # noqa: F401
2 | from starlette.requests import Request as Request # noqa: F401
3 |
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-title.pxd/QuickLook/Thumbnail.tiff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-title.pxd/QuickLook/Thumbnail.tiff
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-bean.pxd/data/selection/shapeSelection/path:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-bean.pxd/data/selection/shapeSelection/path
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-bean.pxd/data/originalImportedContentDocumentInfo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-bean.pxd/data/originalImportedContentDocumentInfo
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-title.pxd/data/72678C72-8B48-4CB1-8C80-312AD99C8C59:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-title.pxd/data/72678C72-8B48-4CB1-8C80-312AD99C8C59
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-title.pxd/data/FBF26CFB-8E0A-4D4B-8091-5BC779991BEF:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adriangb/xpresso/HEAD/docs/assets/images/xpresso-title.pxd/data/FBF26CFB-8E0A-4D4B-8091-5BC779991BEF
--------------------------------------------------------------------------------
/docs_src/tutorial/minimal_app.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, Path
2 |
3 |
4 | async def endpoint():
5 | return {"message": "Hello World"}
6 |
7 |
8 | app = App(routes=[Path(path="/", get=endpoint)])
9 |
--------------------------------------------------------------------------------
/xpresso/websockets.py:
--------------------------------------------------------------------------------
1 | from starlette.websockets import WebSocket as WebSocket # noqa: F401
2 | from starlette.websockets import ( # noqa: F401
3 | WebSocketDisconnect as WebSocketDisconnect,
4 | )
5 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_003_response_1.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "item_name": "Foo"
4 | },
5 | {
6 | "item_name": "Bar"
7 | },
8 | {
9 | "item_name": "Baz"
10 | }
11 | ]
12 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | # See https://ichard26-testblackdocs.readthedocs.io/en/refactor_docs/compatible_configs.html#flake8
3 | extend-ignore = E203, E231, E501, W503, E302
4 | # for black compatibiilty
5 | max-line-length = 88
6 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: adriangb
4 |
5 | # Please donate to the following projects which Xpresso relies on:
6 | # - https://github.com/samuelcolvin/pydantic
7 | # - https://github.com/encode
8 |
--------------------------------------------------------------------------------
/docs/overrides/main.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block outdated %}
4 | You're not viewing the latest version.
5 |
6 | Click here to go to latest.
7 |
8 | {% endblock %}
9 |
--------------------------------------------------------------------------------
/docs_src/tutorial/files/tutorial_002.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, FromRawBody, Path
2 |
3 |
4 | async def count_bytes_in_file(data: FromRawBody[bytes]) -> int:
5 | return len(data)
6 |
7 |
8 | app = App(routes=[Path(path="/count-bytes", put=count_bytes_in_file)])
9 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_002_response_1.json:
--------------------------------------------------------------------------------
1 | {
2 | "detail": [
3 | {
4 | "loc": [
5 | "query",
6 | "input"
7 | ],
8 | "msg": "Missing required query parameter",
9 | "type": "value_error"
10 | }
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/tutorial_002_response_2.json:
--------------------------------------------------------------------------------
1 | {
2 | "detail": [
3 | {
4 | "loc": [
5 | "path",
6 | "item_id"
7 | ],
8 | "msg": "value is not a valid integer",
9 | "type": "type_error.integer"
10 | }
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/docs_src/tutorial/files/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, FromRawBody, Path, UploadFile
2 |
3 |
4 | async def count_bytes_in_file(file: FromRawBody[UploadFile]) -> int:
5 | return len(await file.read())
6 |
7 |
8 | app = App(routes=[Path(path="/count-bytes", put=count_bytes_in_file)])
9 |
--------------------------------------------------------------------------------
/docs/tutorial/cookie_params.md:
--------------------------------------------------------------------------------
1 | # Cookie Parameters
2 |
3 | Cookies parameters are declared the same way as `Query` and `Path` parameters:
4 |
5 | ```python
6 | --8<-- "docs_src/tutorial/cookie_params/tutorial_001.py"
7 | ```
8 |
9 | ## Repeated Cookies
10 |
11 | ## Serialization and Parsing
12 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/binders/test_msgpack.py:
--------------------------------------------------------------------------------
1 | from docs_src.advanced.binders.msgpack import tests as msgpack_binder_tests
2 |
3 |
4 | def test_echo_item() -> None:
5 | msgpack_binder_tests.test_echo_item()
6 |
7 |
8 | def test_openapi_schema() -> None:
9 | msgpack_binder_tests.test_openapi_schema()
10 |
--------------------------------------------------------------------------------
/xpresso/typing.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from typing import Any, NamedTuple
3 |
4 | if sys.version_info < (3, 9):
5 | from typing_extensions import Annotated as Annotated # noqa: F401
6 | else:
7 | from typing import Annotated as Annotated # noqa: F401
8 |
9 |
10 | class Some(NamedTuple):
11 | value: Any
12 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | comment:
2 | layout: "diff, files"
3 | require_changes: true
4 | codecov:
5 | require_ci_to_pass: true
6 | notify:
7 | wait_for_ci: true
8 | coverage:
9 | status:
10 | project:
11 | default:
12 | target: auto
13 | threshold: 0.5% # the leniency in hitting the target
14 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/dependencies/test_tutorial_003.py:
--------------------------------------------------------------------------------
1 | from docs_src.advanced.dependencies.tutorial_003 import app
2 | from xpresso.testclient import TestClient
3 |
4 |
5 | def test_shared() -> None:
6 | client = TestClient(app)
7 | resp = client.get("/shared")
8 | assert resp.status_code == 200, resp.content
9 |
--------------------------------------------------------------------------------
/docs_src/advanced/root_path.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, Path, Request
2 |
3 |
4 | def read_main(request: Request) -> str:
5 | return f"Hello from {request.url_for('main')}"
6 |
7 |
8 | app = App(
9 | routes=[
10 | Path("/app", get=read_main, name="main"),
11 | ],
12 | root_path="/v1/api",
13 | )
14 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/dependencies/test_tutorial_001.py:
--------------------------------------------------------------------------------
1 | from docs_src.advanced.dependencies.tutorial_001 import app
2 | from xpresso.testclient import TestClient
3 |
4 |
5 | def test_get_slow_endpoint() -> None:
6 | client = TestClient(app)
7 | resp = client.get("/slow")
8 | assert resp.status_code == 200, resp.content
9 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/tutorial_003_response_2.json:
--------------------------------------------------------------------------------
1 | {
2 | "detail": [
3 | {
4 | "ctx": {
5 | "limit_value": 0
6 | },
7 | "loc": [
8 | "path",
9 | "item_id"
10 | ],
11 | "msg": "ensure this value is greater than 0",
12 | "type": "value_error.number.not_gt"
13 | }
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | !/.gitignore
4 | !/.github/
5 | !/.flake8
6 | !/.pre-commit-config.yaml
7 | !/xpresso/
8 | !/benchmarks/
9 | !/docs/
10 | !/docs_src/
11 | !/tests/
12 | !/Makefile
13 | !/pyproject.toml
14 | !/README.md
15 | !/mkdocs.yml
16 | !/codecov.yml
17 | !/LICENSE.txt
18 | !/CONTRIBUTING.md
19 | !/assets/
20 |
21 | **/__pycache__
22 | **/.DS_Store
23 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_002.py:
--------------------------------------------------------------------------------
1 | from typing import Dict
2 |
3 | from xpresso import App, FromQuery, Path
4 |
5 |
6 | async def double(input: FromQuery[int]) -> Dict[str, int]:
7 | return {"result": input * 2}
8 |
9 |
10 | app = App(
11 | routes=[
12 | Path(
13 | path="/math/double",
14 | get=double,
15 | ),
16 | ]
17 | )
18 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/body/test_tutorial_006.py:
--------------------------------------------------------------------------------
1 | from docs_src.tutorial.body.tutorial_006 import app
2 | from xpresso.testclient import TestClient
3 |
4 | client = TestClient(app)
5 |
6 |
7 | def test_body_tutorial_006():
8 | response = client.post("/webhook", json={"foo": "bar"})
9 | assert response.status_code == 200, response.content
10 | assert response.json() is True
11 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/lifespans/test_tutorial_001.py:
--------------------------------------------------------------------------------
1 | from docs_src.tutorial.lifespans.tutorial_001 import app
2 | from xpresso.testclient import TestClient
3 |
4 |
5 | def test_lifespan() -> None:
6 |
7 | with TestClient(app) as client:
8 | resp = client.get("/health")
9 | assert resp.status_code == 200, resp.content
10 |
11 | assert resp.json() == {"running": True}
12 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from typing import Dict
2 |
3 | from xpresso import App, FromPath, Path
4 |
5 |
6 | async def read_item(item_id: FromPath[str]) -> Dict[str, str]:
7 | return {"item_id": item_id}
8 |
9 |
10 | app = App(
11 | routes=[
12 | Path(
13 | path="/items/{item_id}",
14 | get=read_item,
15 | ),
16 | ]
17 | )
18 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/tutorial_002.py:
--------------------------------------------------------------------------------
1 | from typing import Dict
2 |
3 | from xpresso import App, FromPath, Path
4 |
5 |
6 | async def read_item(item_id: FromPath[int]) -> Dict[str, int]:
7 | return {"item_id": item_id}
8 |
9 |
10 | app = App(
11 | routes=[
12 | Path(
13 | path="/items/{item_id}",
14 | get=read_item,
15 | ),
16 | ]
17 | )
18 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/lifespans/test_tutorial_002.py:
--------------------------------------------------------------------------------
1 | from docs_src.tutorial.lifespans.tutorial_002 import app
2 | from xpresso.testclient import TestClient
3 |
4 |
5 | def test_lifespan() -> None:
6 |
7 | with TestClient(app) as client:
8 | resp = client.get("/logs")
9 | assert resp.status_code == 200, resp.content
10 | assert set(resp.json()) == {"App lifespan", "Router lifespan"}
11 |
--------------------------------------------------------------------------------
/docs_src/tutorial/files/tutorial_003.py:
--------------------------------------------------------------------------------
1 | from typing import AsyncIterator
2 |
3 | from xpresso import App, FromRawBody, Path
4 |
5 |
6 | async def count_bytes_in_file(
7 | data: FromRawBody[AsyncIterator[bytes]],
8 | ) -> int:
9 | size = 0
10 | async for chunk in data:
11 | size += len(chunk)
12 | return size
13 |
14 |
15 | app = App(routes=[Path(path="/count-bytes", put=count_bytes_in_file)])
16 |
--------------------------------------------------------------------------------
/docs_src/tutorial/body/tutorial_003.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, List
2 |
3 | from xpresso import App, FromJson, Path
4 |
5 |
6 | async def count_items(
7 | item_counts: FromJson[List[int]],
8 | ) -> Dict[str, int]:
9 | return {"total": sum(item_counts)}
10 |
11 |
12 | app = App(
13 | routes=[
14 | Path(
15 | "/items/count",
16 | put=count_items,
17 | )
18 | ]
19 | )
20 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/dependencies/test_tutorial_005.py:
--------------------------------------------------------------------------------
1 | from docs_src.tutorial.dependencies.tutorial_005 import app
2 | from xpresso.testclient import TestClient
3 |
4 |
5 | def test_echo_user():
6 | client = TestClient(app)
7 | response = client.get("/echo/user", params={"username": "foobarbaz"})
8 | assert response.status_code == 200, response.content
9 | assert response.json() == {"username": "foobarbaz"}
10 |
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-bean.pxd/data/selection/shapeSelection/meta:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | backingScale
6 | 1
7 | pathFilename
8 | path
9 | version
10 | 1
11 |
12 |
13 |
--------------------------------------------------------------------------------
/docs/tutorial/dependencies/http-params.md:
--------------------------------------------------------------------------------
1 | # HTTP Parameters in Dependencies
2 |
3 | Your dependencies can depend on HTTP parameters.
4 | For example, you can get a query parameter from within a dependency.
5 | This can be useful to create reusable groups of parameters:
6 |
7 | ```python hl_lines="6-7 10"
8 | --8<-- "docs_src/tutorial/dependencies/tutorial_005.py"
9 | ```
10 |
11 | This applies to parameters as well as request bodies.
12 |
--------------------------------------------------------------------------------
/docs_src/tutorial/files/tutorial_004.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, Path, RawBody, UploadFile
2 | from xpresso.typing import Annotated
3 |
4 |
5 | async def count_image_bytes(
6 | file: Annotated[
7 | UploadFile,
8 | RawBody(media_type="image/*", enforce_media_type=True),
9 | ]
10 | ) -> int:
11 | return len(await file.read())
12 |
13 |
14 | app = App(routes=[Path(path="/count-bytes", put=count_image_bytes)])
15 |
--------------------------------------------------------------------------------
/docs_src/advanced/dependencies/tutorial_007.py:
--------------------------------------------------------------------------------
1 | import asyncpg # type: ignore[import]
2 | import uvicorn # type: ignore[import]
3 |
4 | from xpresso import App
5 |
6 |
7 | async def main() -> None:
8 | app = App()
9 | async with asyncpg.create_pool(...) as pool: # type: ignore
10 | app.dependency_overrides[asyncpg.Pool] = lambda: pool
11 | server = uvicorn.Server(uvicorn.Config(app))
12 | await server.serve()
13 |
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/tutorial_005.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 | from xpresso import App, FromQuery, Path
4 |
5 |
6 | class CurrentUser(BaseModel):
7 | username: FromQuery[str]
8 |
9 |
10 | async def echo_user(user: CurrentUser) -> CurrentUser:
11 | return user
12 |
13 |
14 | app = App(
15 | routes=[
16 | Path(
17 | "/echo/user",
18 | get=echo_user,
19 | )
20 | ]
21 | )
22 |
--------------------------------------------------------------------------------
/docs/advanced/websockets.md:
--------------------------------------------------------------------------------
1 | # WebSockets
2 |
3 | Xpresso supports [WebSockets] via [Starlette's WebSocket support].
4 | The only functionality added on top of Starlette's is the ability to inject HTTP parameters like headers:
5 |
6 | ```python
7 | --8<-- "docs_src/advanced/websockets.py"
8 | ```
9 |
10 | [WebSockets]: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API
11 | [Starlette's WebSocket support]: https://www.starlette.io/websockets/
12 |
--------------------------------------------------------------------------------
/docs_src/tutorial/routing/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, Operation, Path, Router
2 | from xpresso.routing.mount import Mount
3 |
4 |
5 | async def items() -> None:
6 | ...
7 |
8 |
9 | path = Path("/items", get=Operation(items))
10 |
11 | inner_mount = Mount(path="/mount-again", routes=[path])
12 |
13 | router = Router(routes=[inner_mount])
14 |
15 | outer_mount = Mount(path="/mount", app=router)
16 |
17 | app = App(routes=[outer_mount])
18 |
--------------------------------------------------------------------------------
/docs_src/advanced/responses/tutorial_003.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, FromPath, Operation, Path, Response
2 |
3 |
4 | async def read_item(item_id: FromPath[str]) -> bytes:
5 | return f"".encode()
6 |
7 |
8 | get_item = Operation(
9 | read_item,
10 | response_media_type="image/png",
11 | response_encoder=None,
12 | response_factory=Response,
13 | )
14 |
15 | app = App(routes=[Path("/items/{item_id}", get=get_item)])
16 |
--------------------------------------------------------------------------------
/docs_src/advanced/responses/tutorial_004.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 | from xpresso import App, Operation, Path, status
4 |
5 |
6 | class Item(BaseModel):
7 | id: str
8 | value: str
9 |
10 |
11 | async def create_item(item: Item) -> None:
12 | ...
13 |
14 |
15 | post_item = Operation(
16 | create_item,
17 | response_status_code=status.HTTP_204_NO_CONTENT,
18 | )
19 |
20 | app = App(routes=[Path("/items/", post=post_item)])
21 |
--------------------------------------------------------------------------------
/docs_src/tutorial/cookie_params/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Optional
2 |
3 | from xpresso import App, FromCookie, Path
4 |
5 |
6 | async def read_items(
7 | advertiser_id: FromCookie[Optional[int]] = None,
8 | ) -> Dict[str, Optional[int]]:
9 | return {"advertiser_id": advertiser_id}
10 |
11 |
12 | app = App(
13 | routes=[
14 | Path(
15 | "/items/",
16 | get=read_items,
17 | )
18 | ]
19 | )
20 |
--------------------------------------------------------------------------------
/docs_src/tutorial/header_params/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Optional
2 |
3 | from xpresso import App, FromHeader, Path
4 |
5 |
6 | async def read_items(
7 | accept_language: FromHeader[Optional[str]] = None,
8 | ) -> Dict[str, Optional[str]]:
9 | return {"Accept-Language": accept_language}
10 |
11 |
12 | app = App(
13 | routes=[
14 | Path(
15 | "/items/",
16 | get=read_items,
17 | )
18 | ]
19 | )
20 |
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/tutorial_001.py:
--------------------------------------------------------------------------------
1 | import httpx
2 |
3 | from xpresso import App, Path
4 |
5 |
6 | async def echo_url(client: httpx.AsyncClient) -> str:
7 | resp = await client.get("https://httpbin.org/get")
8 | resp.raise_for_status() # or some other error handling
9 | return resp.json()["url"]
10 |
11 |
12 | app = App(
13 | routes=[
14 | Path(
15 | "/echo/url",
16 | get=echo_url,
17 | )
18 | ]
19 | )
20 |
--------------------------------------------------------------------------------
/docs/advanced/body-union.md:
--------------------------------------------------------------------------------
1 | # Body Unions
2 |
3 | You can accept a `Union` of bodies, which will be resolved by trying to deserialize each body and returning the first one that does not error.
4 | If your bodies accept only specific content types (this is the default for json bodies but is opt-in for files) this will be used to discriminate the type.
5 | If no bodies verify successfully, an error will be returned to the client.
6 |
7 | ```python
8 | --8<-- "docs_src/advanced/body_union.py"
9 | ```
10 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/tutorial_004.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from xpresso import App, Path, PathParam
4 | from xpresso.typing import Annotated
5 |
6 |
7 | async def read_items(
8 | items: Annotated[
9 | List[int], PathParam(explode=True, style="matrix")
10 | ]
11 | ) -> List[int]:
12 | return items
13 |
14 |
15 | app = App(
16 | routes=[
17 | Path(
18 | path="/items/{items}",
19 | get=read_items,
20 | ),
21 | ]
22 | )
23 |
--------------------------------------------------------------------------------
/docs_src/advanced/binders/msgpack/functions.py:
--------------------------------------------------------------------------------
1 | from typing import Any, TypeVar
2 |
3 | from xpresso.binders.dependents import BinderMarker
4 | from xpresso.typing import Annotated
5 |
6 | from .extractor import ExtractorMarker
7 | from .openapi import OpenAPIMarker
8 |
9 | T = TypeVar("T")
10 |
11 |
12 | def MsgPack() -> Any:
13 | return BinderMarker(
14 | extractor_marker=ExtractorMarker(),
15 | openapi_marker=OpenAPIMarker(),
16 | )
17 |
18 |
19 | FromMsgPack = Annotated[T, MsgPack()]
20 |
--------------------------------------------------------------------------------
/docs_src/tutorial/forms/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, FromFormData, FromFormField, Path
6 |
7 |
8 | class SearchForm(BaseModel):
9 | name: str # implicit FromFormField[str]
10 | tags: FromFormField[List[str]]
11 |
12 |
13 | async def log_search(form: FromFormData[SearchForm]) -> str:
14 | return f"{form.name} searched for {', '.join(form.tags)}"
15 |
16 |
17 | app = App(routes=[Path(path="/form", post=log_search)])
18 |
--------------------------------------------------------------------------------
/docs_src/tutorial/path_params/tutorial_003.py:
--------------------------------------------------------------------------------
1 | from typing import Dict
2 |
3 | from pydantic import Field
4 |
5 | from xpresso import App, Path, PathParam
6 | from xpresso.typing import Annotated
7 |
8 |
9 | async def read_item(
10 | item_id: Annotated[int, PathParam(), Field(gt=0)]
11 | ) -> Dict[str, int]:
12 | return {"item_id": item_id}
13 |
14 |
15 | app = App(
16 | routes=[
17 | Path(
18 | path="/items/{item_id}",
19 | get=read_item,
20 | ),
21 | ]
22 | )
23 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, FromQuery, Path
2 |
3 | fake_items_db = [
4 | {"item_name": "Foo"},
5 | {"item_name": "Bar"},
6 | {"item_name": "Baz"},
7 | ]
8 |
9 |
10 | async def read_items(
11 | skip: FromQuery[int] = 0, limit: FromQuery[int] = 2
12 | ):
13 | return fake_items_db[skip : skip + limit]
14 |
15 |
16 | app = App(
17 | routes=[
18 | Path(
19 | path="/items/",
20 | get=read_items,
21 | ),
22 | ]
23 | )
24 |
--------------------------------------------------------------------------------
/xpresso/binders/_binders/grouped.py:
--------------------------------------------------------------------------------
1 | from typing import Iterable, Sequence, Tuple, TypeVar
2 |
3 | T = TypeVar("T")
4 |
5 |
6 | def grouped(items: Sequence[T], n: int = 2) -> Iterable[Tuple[T, ...]]:
7 | """s -> [(s0, s1, s2,...sn-1), (sn, sn+1 , sn+2,...s2n-1), ...]
8 | list(grouped([1, 2], 2)) == [(1, 2)]
9 | list(grouped([1, 2, 3, 4], 2)) == [(1, 2), (3, 4)]
10 | """
11 | if len(items) % n != 0:
12 | raise ValueError("items must be equally divisible by n")
13 | return zip(*[iter(items)] * n)
14 |
--------------------------------------------------------------------------------
/tests/test_openapi/test_swagger_html.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, Path
2 | from xpresso.testclient import TestClient
3 |
4 | expected_html = open("tests/test_openapi/expected_swagger_html.html").read()
5 |
6 |
7 | def test_swagger_html_generation() -> None:
8 | async def endpoint() -> None:
9 | ...
10 |
11 | app = App([Path("/", get=endpoint)])
12 | client = TestClient(app)
13 |
14 | resp = client.get("/docs")
15 |
16 | assert resp.status_code == 200, resp.content
17 | assert resp.text == expected_html
18 |
--------------------------------------------------------------------------------
/docs_src/tutorial/header_params/tutorial_002.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Optional
2 |
3 | from xpresso import App, HeaderParam, Path
4 | from xpresso.typing import Annotated
5 |
6 |
7 | async def read_items(
8 | some_header: Annotated[
9 | str, HeaderParam(convert_underscores=False)
10 | ]
11 | ) -> Dict[str, Optional[str]]:
12 | return {"some_header": some_header}
13 |
14 |
15 | app = App(
16 | routes=[
17 | Path(
18 | "/items/",
19 | get=read_items,
20 | )
21 | ]
22 | )
23 |
--------------------------------------------------------------------------------
/xpresso/binders/_binders/utils.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | import typing
3 |
4 | T = typing.TypeVar("T")
5 |
6 | Consumer = typing.Callable[[T], typing.Any]
7 | ConsumerContextManager = typing.Callable[[T], typing.AsyncContextManager[typing.Any]]
8 |
9 |
10 | def wrap_consumer_as_cm(consumer: Consumer[T]) -> ConsumerContextManager[T]:
11 | @contextlib.asynccontextmanager
12 | async def consume(request: T) -> typing.AsyncIterator[typing.Any]:
13 | res = await consumer(request)
14 | yield res
15 |
16 | return consume
17 |
--------------------------------------------------------------------------------
/docs_src/tutorial/body/tutorial_006.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import Any, Dict
3 |
4 | from xpresso import App, Json, Path, RawBody
5 | from xpresso.typing import Annotated
6 |
7 |
8 | async def handle_event(
9 | event: Annotated[Dict[str, Any], Json(consume=False)],
10 | raw_body: Annotated[bytes, RawBody(consume=False)],
11 | ) -> bool:
12 | return json.loads(raw_body) == event
13 |
14 |
15 | app = App(
16 | routes=[
17 | Path(
18 | "/webhook",
19 | post=handle_event,
20 | )
21 | ]
22 | )
23 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_005.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, FromQuery, Path
6 |
7 |
8 | class Filter(BaseModel):
9 | prefix: str
10 | limit: int
11 | skip: int = 0
12 |
13 |
14 | async def read_items(
15 | filter: FromQuery[Optional[Filter]],
16 | ) -> Optional[Filter]:
17 | return filter
18 |
19 |
20 | app = App(
21 | routes=[
22 | Path(
23 | path="/items/",
24 | get=read_items,
25 | ),
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/docs_src/tutorial/body/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Optional
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, FromJson, Path
6 |
7 |
8 | class Item(BaseModel):
9 | name: str
10 | price: float
11 | tax: Optional[float] = None
12 |
13 |
14 | async def create_receipt(item: FromJson[Item]) -> Dict[str, float]:
15 | return {item.name: item.price + (item.tax or 0)}
16 |
17 |
18 | app = App(
19 | routes=[
20 | Path(
21 | "/items/",
22 | post=create_receipt,
23 | )
24 | ]
25 | )
26 |
--------------------------------------------------------------------------------
/docs_src/tutorial/forms/tutorial_002.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, FormField, FromFormData, Path
6 | from xpresso.typing import Annotated
7 |
8 |
9 | class SearchForm(BaseModel):
10 | name: str # implicit FromFormField[str]
11 | tags: Annotated[List[str], FormField(explode=False)]
12 |
13 |
14 | async def log_search(form: FromFormData[SearchForm]) -> str:
15 | return f"{form.name} searched for {', '.join(form.tags)}"
16 |
17 |
18 | app = App(routes=[Path(path="/form", post=log_search)])
19 |
--------------------------------------------------------------------------------
/docs_src/tutorial/forms/tutorial_003.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, FromFormFile, FromMultipart, Path, UploadFile
6 |
7 |
8 | class UploadForm(BaseModel):
9 | name: str # implicit FromFormField[str]
10 | files: FromFormFile[List[UploadFile]]
11 |
12 |
13 | async def log_search(form: FromMultipart[UploadForm]) -> str:
14 | data = [(await f.read()).decode() for f in form.files]
15 | return f"{form.name} uploaded {', '.join(data)}"
16 |
17 |
18 | app = App(routes=[Path(path="/form", post=log_search)])
19 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/dependencies/test_tutorial_005.py:
--------------------------------------------------------------------------------
1 | from uuid import UUID, uuid4
2 |
3 | from docs_src.advanced.dependencies.tutorial_005 import app
4 | from xpresso.testclient import TestClient
5 |
6 |
7 | def test_context_id() -> None:
8 | client = TestClient(app)
9 |
10 | resp = client.get("/items/foo")
11 | assert resp.status_code == 200, resp.content
12 | UUID(resp.headers["X-Request-Context"]) # just a valid UUID
13 |
14 | ctx = str(uuid4())
15 | resp = client.get("/items/foo", headers={"X-Request-Context": ctx})
16 | assert resp.headers["X-Request-Context"] == ctx
17 |
--------------------------------------------------------------------------------
/docs_src/tutorial/body/tutorial_004.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, List, Optional
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, FromJson, Path
6 |
7 |
8 | class Item(BaseModel):
9 | name: str
10 | price: float
11 | tax: Optional[float] = None
12 |
13 |
14 | async def create_receipt(
15 | items: FromJson[List[Item]],
16 | ) -> Dict[str, float]:
17 | return {item.name: item.price + (item.tax or 0) for item in items}
18 |
19 |
20 | app = App(
21 | routes=[
22 | Path(
23 | "/items/",
24 | post=create_receipt,
25 | )
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_004.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from xpresso import App, FromQuery, Path
4 |
5 | fake_items_db = [
6 | {"item_name": "Foo"},
7 | {"item_name": "Bar"},
8 | {"item_name": "Baz"},
9 | ]
10 |
11 |
12 | async def read_item(prefix: FromQuery[List[str]]):
13 | return [
14 | item
15 | for item in fake_items_db
16 | if all(item["item_name"].startswith(p) for p in prefix or [])
17 | ]
18 |
19 |
20 | app = App(
21 | routes=[
22 | Path(
23 | path="/items/",
24 | get=read_item,
25 | ),
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/docs/tutorial/middleware/diagram_001.mmd:
--------------------------------------------------------------------------------
1 | sequenceDiagram
2 | participant Client
3 | participant Server
4 | participant Middleware
5 | participant Router
6 | participant Dependencies
7 | participant Endpoint
8 | Client->>Server: Incoming Request
9 | Server->>Middleware: Process Request
10 | Middleware->>Router: Route Request
11 | Router->>Dependencies: Process Request
12 | Dependencies->>Endpoint: Run Endpoint
13 | Endpoint->>Dependencies: Process Response
14 | Dependencies->>Middleware: Process Response
15 | Middleware->>Server: Process Response
16 | Server->>Client: Return Response
17 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_003.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from xpresso import App, FromQuery, Path
4 |
5 | fake_items_db = [
6 | {"item_name": "Foo"},
7 | {"item_name": "Bar"},
8 | {"item_name": "Baz"},
9 | ]
10 |
11 |
12 | async def read_item(
13 | skip: FromQuery[int] = 0, limit: FromQuery[Optional[int]] = 2
14 | ):
15 | if limit is None:
16 | limit = len(fake_items_db)
17 | return fake_items_db[skip : skip + limit]
18 |
19 |
20 | app = App(
21 | routes=[
22 | Path(
23 | path="/items/",
24 | get=read_item,
25 | ),
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/docs_src/advanced/dependencies/tutorial_001.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from xpresso import App, Depends, Operation, Path
4 |
5 |
6 | def slow_dependency() -> None:
7 | time.sleep(1e-3)
8 |
9 |
10 | def slow_endpoint() -> None:
11 | time.sleep(1e-3)
12 |
13 |
14 | app = App(
15 | routes=[
16 | Path(
17 | "/slow",
18 | get=Operation(
19 | endpoint=slow_endpoint,
20 | sync_to_thread=True,
21 | dependencies=[
22 | Depends(slow_dependency, sync_to_thread=True)
23 | ],
24 | ),
25 | )
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/docs_src/tutorial/json_body.py:
--------------------------------------------------------------------------------
1 | from collections import defaultdict
2 | from typing import DefaultDict, List
3 |
4 | from xpresso import App, FromJson, Path
5 |
6 | fake_db: DefaultDict[str, List[str]] = defaultdict(list)
7 |
8 |
9 | async def add_tags(
10 | id: str,
11 | tags: FromJson[List[str]],
12 | ) -> None:
13 | fake_db[id].extend(tags)
14 |
15 |
16 | async def get_tags(id: str) -> List[str]:
17 | return fake_db[id]
18 |
19 |
20 | app = App(
21 | routes=[
22 | Path(
23 | path="/items/{id}/tags",
24 | post=add_tags,
25 | get=get_tags,
26 | ),
27 | ],
28 | )
29 |
--------------------------------------------------------------------------------
/xpresso/datastructures.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable, Iterable, Type
2 |
3 | from starlette.datastructures import UploadFile as StarletteUploadFile
4 |
5 |
6 | class UploadFile(StarletteUploadFile):
7 | @classmethod
8 | def __get_validators__(cls: Type["UploadFile"]) -> Iterable[Callable[..., Any]]:
9 | # this is required so that UploadFile can be a Pydantic field
10 | return iter(())
11 |
12 | async def read(self, size: int = -1) -> bytes:
13 | # this is implemented just to fix the return type annotation
14 | # which is always bytes
15 | return await super().read(size) # type: ignore
16 |
--------------------------------------------------------------------------------
/docs_src/advanced/body_union.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Optional, Union
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, FromBodyUnion, FromFormData, FromJson, Path
6 |
7 |
8 | class Item(BaseModel):
9 | name: str
10 | price: float
11 | tax: Optional[float] = None
12 |
13 |
14 | async def create_receipt(
15 | item: FromBodyUnion[Union[FromFormData[Item], FromJson[Item]]]
16 | ) -> Dict[str, float]:
17 | return {item.name: item.price + (item.tax or 0)}
18 |
19 |
20 | app = App(
21 | routes=[
22 | Path(
23 | "/items/",
24 | post=create_receipt,
25 | )
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/docs_src/tutorial/query_params/tutorial_006.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, Path, QueryParam
6 | from xpresso.typing import Annotated
7 |
8 |
9 | class Filter(BaseModel):
10 | prefix: str
11 | limit: int
12 | skip: int = 0
13 |
14 |
15 | async def read_items(
16 | filter: Annotated[
17 | Optional[Filter], QueryParam(style="deepObject")
18 | ]
19 | ) -> Optional[Filter]:
20 | return filter
21 |
22 |
23 | app = App(
24 | routes=[
25 | Path(
26 | path="/items/",
27 | get=read_items,
28 | ),
29 | ]
30 | )
31 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/dependencies/test_tutorial_006.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from httpx import AsyncClient
3 |
4 | from docs_src.advanced.dependencies.tutorial_006 import (
5 | create_app,
6 | test_add_word_endpoint,
7 | )
8 |
9 |
10 | def test_example_test() -> None:
11 | test_add_word_endpoint()
12 |
13 |
14 | @pytest.mark.anyio
15 | async def test_against_sqlite() -> None:
16 | app = create_app()
17 | async with AsyncClient(app=app, base_url="http://example.com") as client:
18 | resp = await client.post("/words/", json="foo") # type: ignore
19 | assert resp.status_code == 200
20 | assert resp.json() == "foo"
21 |
--------------------------------------------------------------------------------
/docs_src/tutorial/lifespans/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from contextlib import asynccontextmanager
2 | from typing import AsyncIterator
3 |
4 | from pydantic import BaseModel
5 |
6 | from xpresso import App, Path
7 |
8 |
9 | class AppState(BaseModel):
10 | started: bool = False
11 |
12 |
13 | @asynccontextmanager
14 | async def lifespan(state: AppState) -> AsyncIterator[None]:
15 | state.started = True
16 | yield
17 |
18 |
19 | class AppHealth(BaseModel):
20 | running: bool
21 |
22 |
23 | async def healthcheck(state: AppState) -> AppHealth:
24 | return AppHealth(running=state.started)
25 |
26 |
27 | app = App(
28 | lifespan=lifespan, routes=[Path("/health", get=healthcheck)]
29 | )
30 |
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/tutorial_002.py:
--------------------------------------------------------------------------------
1 | import httpx
2 |
3 | from xpresso import App, Depends, Path
4 | from xpresso.typing import Annotated
5 |
6 |
7 | def get_client() -> httpx.AsyncClient:
8 | return httpx.AsyncClient(base_url="https://httpbin.org")
9 |
10 |
11 | HttpbinClient = Annotated[httpx.AsyncClient, Depends(get_client)]
12 |
13 |
14 | async def echo_url(client: HttpbinClient) -> str:
15 | resp = await client.get("/get")
16 | resp.raise_for_status() # or some other error handling
17 | return resp.json()["url"]
18 |
19 |
20 | app = App(
21 | routes=[
22 | Path(
23 | "/echo/url",
24 | get=echo_url,
25 | )
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/docs_src/tutorial/json_body_with_examples.py:
--------------------------------------------------------------------------------
1 | from collections import defaultdict
2 | from typing import DefaultDict, List
3 |
4 | from xpresso import App, Json, Path
5 | from xpresso.typing import Annotated
6 |
7 | fake_db: DefaultDict[str, List[str]] = defaultdict(list)
8 |
9 |
10 | async def add_tags(
11 | id: str,
12 | tags: Annotated[
13 | List[str],
14 | Json(examples={"Two tags": ["tag1", "tag2"]}),
15 | ],
16 | ) -> None:
17 | fake_db[id].extend(tags)
18 |
19 |
20 | async def get_tags(id: str) -> List[str]:
21 | return fake_db[id]
22 |
23 |
24 | app = App(
25 | routes=[
26 | Path(
27 | path="/items/{id}/tags",
28 | post=add_tags,
29 | get=get_tags,
30 | ),
31 | ],
32 | )
33 |
--------------------------------------------------------------------------------
/xpresso/_utils/asgi.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from di import ScopeState
4 | from starlette.responses import Response
5 |
6 |
7 | class XpressoHTTPExtension:
8 | __slots__ = ("di_container_state", "response", "response_sent")
9 |
10 | di_container_state: ScopeState
11 | response: Optional[Response]
12 | response_sent: bool
13 |
14 | def __init__(self, di_state: ScopeState) -> None:
15 | self.di_container_state = di_state
16 | self.response = None
17 | self.response_sent = False
18 |
19 |
20 | class XpressoWebSocketExtension:
21 | __slots__ = ("di_container_state",)
22 |
23 | di_container_state: ScopeState
24 |
25 | def __init__(self, di_state: ScopeState) -> None:
26 | self.di_container_state = di_state
27 |
--------------------------------------------------------------------------------
/docs_src/advanced/responses/tutorial_005.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel
2 |
3 | from xpresso import App, FromPath, HTTPException, Path
4 | from xpresso.responses import ResponseSpec
5 |
6 |
7 | class Item(BaseModel):
8 | id: str
9 | value: str
10 |
11 |
12 | async def read_item(item_id: FromPath[str]) -> Item:
13 | if item_id == "foo":
14 | return Item(id="foo", value="there goes my hero")
15 | raise HTTPException(status_code=404)
16 |
17 |
18 | app = App(
19 | routes=[
20 | Path(
21 | "/items/{item_id}",
22 | get=read_item,
23 | responses={
24 | 404: ResponseSpec(
25 | description="Item not found",
26 | )
27 | },
28 | )
29 | ]
30 | )
31 |
--------------------------------------------------------------------------------
/docs_src/tutorial/body/tutorial_002.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Optional
2 |
3 | from pydantic import BaseModel, Field
4 |
5 | from xpresso import App, FromJson, Path
6 | from xpresso.typing import Annotated
7 |
8 |
9 | class Item(BaseModel):
10 | name: str
11 | price: Annotated[
12 | float,
13 | Field(
14 | gt=0,
15 | description="Item price without tax. Must be greater than zero.",
16 | ),
17 | ]
18 | tax: Optional[float] = None
19 |
20 |
21 | async def create_receipt(item: FromJson[Item]) -> Dict[str, float]:
22 | return {item.name: item.price + (item.tax or 0)}
23 |
24 |
25 | app = App(
26 | routes=[
27 | Path(
28 | "/items/",
29 | post=create_receipt,
30 | )
31 | ]
32 | )
33 |
--------------------------------------------------------------------------------
/docs_src/advanced/dependencies/tutorial_002.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | import anyio
4 |
5 | from xpresso import App, Depends, Operation, Path
6 |
7 |
8 | def slow_dependency_1() -> None:
9 | time.sleep(1)
10 |
11 |
12 | async def slow_dependency_2() -> None:
13 | await anyio.sleep(1)
14 |
15 |
16 | async def endpoint() -> None:
17 | ...
18 |
19 |
20 | app = App(
21 | routes=[
22 | Path(
23 | "/slow",
24 | get=Operation(
25 | endpoint=endpoint,
26 | dependencies=[
27 | Depends(slow_dependency_1, sync_to_thread=True),
28 | Depends(slow_dependency_2),
29 | ],
30 | execute_dependencies_concurrently=True,
31 | ),
32 | )
33 | ]
34 | )
35 |
--------------------------------------------------------------------------------
/docs_src/tutorial/body/tutorial_005.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Optional
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, Json, Path
6 | from xpresso.typing import Annotated
7 |
8 |
9 | class Item(BaseModel):
10 | name: str
11 | price: float
12 | tax: Optional[float] = None
13 |
14 |
15 | item_examples = {
16 | "With tax": Item(name="foo", price=1, tax=1),
17 | "Duty Free": Item(name="foo", price=2, tax=0),
18 | }
19 |
20 |
21 | async def create_receipt(
22 | item: Annotated[Item, Json(examples=item_examples)]
23 | ) -> Dict[str, float]:
24 | return {item.name: item.price + (item.tax or 0)}
25 |
26 |
27 | app = App(
28 | routes=[
29 | Path(
30 | "/items/",
31 | post=create_receipt,
32 | )
33 | ]
34 | )
35 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from unittest.mock import patch
3 |
4 | from xpresso import App, Path
5 | from xpresso.config import BaseConfig
6 | from xpresso.testclient import TestClient
7 |
8 |
9 | def test_coonfig() -> None:
10 | class AppConfig(BaseConfig):
11 | foobarbaz: int
12 |
13 | async def endpoint(cfg: AppConfig) -> int:
14 | return id(cfg)
15 |
16 | app = App(routes=[Path("/", get=endpoint)])
17 |
18 | with patch.dict(os.environ, {"FOOBARBAZ": "123"}):
19 | with TestClient(app) as client:
20 | resp1 = client.get("/")
21 | assert resp1.status_code == 200, resp1.content
22 | resp2 = client.get("/")
23 | assert resp2.status_code == 200, resp2.content
24 | assert resp1.json() == resp2.json()
25 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/dependencies/test_tutorial_002.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | import httpx
4 | import pytest
5 |
6 | from docs_src.advanced.dependencies.tutorial_002 import app
7 |
8 |
9 | @pytest.mark.anyio
10 | @pytest.mark.parametrize("anyio_backend", ["asyncio"])
11 | async def test_get_slow_endpoint() -> None:
12 | # unforuntely it is pretty hard to actually time this in tests without making
13 | # really slow tests just for the sake of running timing on them
14 | async with httpx.AsyncClient(app=app, base_url="http://example.com") as client:
15 | start = time.time()
16 | resp = await client.get("/slow")
17 | stop = time.time()
18 | assert resp.status_code == 200, resp.content # type: ignore
19 | elapsed = stop - start
20 | assert elapsed < 2
21 |
--------------------------------------------------------------------------------
/xpresso/config.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from di.dependent import Injectable
4 | from pydantic import BaseSettings
5 |
6 | from xpresso.dependencies._dependencies import Scope
7 |
8 |
9 | class BaseConfig(Injectable, BaseSettings):
10 | def __init_subclass__(
11 | cls, scope: Scope = "app", use_cache: bool = True, **kwargs: Any
12 | ) -> None:
13 | # Pydantic BaseSettings models cannot be wired because of how the grab values
14 | # from env vars
15 | # But most of the time you just want to load the entire thing from the environment,
16 | # so that's what we do here
17 | def call() -> Any:
18 | return cls()
19 |
20 | return super().__init_subclass__(
21 | call=call, scope=scope, use_cache=use_cache, **kwargs
22 | )
23 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/dependencies/test_tutorial_004.py:
--------------------------------------------------------------------------------
1 | from docs_src.advanced.dependencies.tutorial_004 import StatusCodeLogFile, app
2 | from xpresso.testclient import TestClient
3 |
4 |
5 | def test_read_items_logging() -> None:
6 | log = StatusCodeLogFile()
7 | with app.dependency_overrides as overrides:
8 | overrides[StatusCodeLogFile] = lambda: log
9 | client = TestClient(app)
10 |
11 | resp = client.get("/items/foo")
12 | assert resp.status_code == 200, resp.content
13 | assert log == [200]
14 |
15 | resp = client.get("/items/baz")
16 | assert resp.status_code == 404, resp.content
17 | assert log == [200, 404]
18 |
19 | resp = client.get("/items/bar")
20 | assert resp.status_code == 200, resp.content
21 | assert log == [200, 404, 200]
22 |
--------------------------------------------------------------------------------
/docs_src/advanced/dependencies/tutorial_003.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, Depends, Path
2 | from xpresso.typing import Annotated
3 |
4 |
5 | class SharedDependency:
6 | pass
7 |
8 |
9 | def dependency_1(shared: SharedDependency) -> SharedDependency:
10 | return shared
11 |
12 |
13 | def dependency_2(shared: SharedDependency) -> SharedDependency:
14 | return shared
15 |
16 |
17 | async def endpoint(
18 | shared1: Annotated[SharedDependency, Depends(dependency_1)],
19 | shared2: Annotated[SharedDependency, Depends(dependency_1)],
20 | shared3: Annotated[SharedDependency, Depends(use_cache=False)],
21 | ) -> None:
22 | assert shared1 is shared2
23 | assert shared1 is not shared3
24 |
25 |
26 | app = App(
27 | routes=[
28 | Path(
29 | "/shared",
30 | get=endpoint,
31 | )
32 | ]
33 | )
34 |
--------------------------------------------------------------------------------
/benchmarks/README.md:
--------------------------------------------------------------------------------
1 | # Benchmarks
2 |
3 | ## Usage
4 |
5 | Open two terminals and start an app in one of them:
6 |
7 | ```shell
8 | # or . ./benchmarks/run_app.sh fastapi
9 | . ./benchmarks/run_app.sh xpresso
10 | ```
11 |
12 | In the other terminal, start the benchmarks:
13 |
14 | ```shell
15 | . ./benchmarks/run_wrk.sh /fast_deps
16 | ```
17 |
18 | The options are:
19 |
20 | - `/simple`: an endpoint that does nothing, this measures framework overhead mainly.
21 | - `/slow_deps`: a largish dependency graph where each dependency calls `asyncio.sleep()`.
22 | - `/fast_deps`: a largish dependency graph where each dependency is an async dependency that just calls `asyncio.sleep(0)`.
23 | - `/routing/two/two-three/two-three-three`: test routing performance on an endpoint that does nothing but is nested within a large routing table.
24 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/dependencies/test_tutorial_002.py:
--------------------------------------------------------------------------------
1 | import httpx
2 |
3 | from docs_src.tutorial.dependencies.tutorial_002 import app
4 | from xpresso.testclient import TestClient
5 |
6 |
7 | def test_client_injection():
8 | async def handler(request: httpx.Request) -> httpx.Response:
9 | assert request.url == "https://httpbin.org/get"
10 | return httpx.Response(200, json={"url": "https://httpbin.org/get"})
11 |
12 | with app.dependency_overrides as overrides:
13 | overrides[httpx.AsyncClient] = lambda: httpx.AsyncClient(
14 | transport=httpx.MockTransport(handler), base_url="https://httpbin.org"
15 | )
16 | client = TestClient(app)
17 | response = client.get("/echo/url")
18 | assert response.status_code == 200, response.content
19 | assert response.json() == "https://httpbin.org/get"
20 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/dependencies/test_tutorial_004.py:
--------------------------------------------------------------------------------
1 | import httpx
2 |
3 | from docs_src.tutorial.dependencies.tutorial_004 import app
4 | from xpresso.testclient import TestClient
5 |
6 |
7 | def test_client_injection():
8 | async def handler(request: httpx.Request) -> httpx.Response:
9 | assert request.url == "https://httpbin.org/get"
10 | return httpx.Response(200, json={"url": "https://httpbin.org/get"})
11 |
12 | with app.dependency_overrides as overrides:
13 | overrides[httpx.AsyncClient] = lambda: httpx.AsyncClient(
14 | transport=httpx.MockTransport(handler), base_url="https://httpbin.org"
15 | )
16 | client = TestClient(app)
17 | response = client.get("/echo/url")
18 | assert response.status_code == 200, response.content
19 | assert response.json() == "https://httpbin.org/get"
20 |
--------------------------------------------------------------------------------
/docs_src/advanced/responses/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, FromPath, Operation, Path
6 | from xpresso.responses import JSONResponse, ResponseSpec
7 |
8 |
9 | class Item(BaseModel):
10 | id: str
11 | value: str
12 |
13 |
14 | class Message(BaseModel):
15 | message: str
16 |
17 |
18 | async def read_item(item_id: FromPath[str]) -> Any:
19 | if item_id == "foo":
20 | return {"id": "foo", "value": "there goes my hero"}
21 | return JSONResponse(
22 | status_code=404, content={"message": "Item not found"}
23 | )
24 |
25 |
26 | get_item = Operation(
27 | read_item,
28 | response_model=Item,
29 | responses={
30 | 404: ResponseSpec(content={"application/json": Message}),
31 | },
32 | )
33 |
34 | app = App(routes=[Path("/items/{item_id}", get=get_item)])
35 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/dependencies/test_tutorial_001.py:
--------------------------------------------------------------------------------
1 | import httpx
2 |
3 | from docs_src.tutorial.dependencies.tutorial_001 import app
4 | from xpresso.testclient import TestClient
5 |
6 |
7 | def test_client_injection():
8 | async def handler(request: httpx.Request) -> httpx.Response:
9 | assert request.url == "https://httpbin.org/get"
10 | return httpx.Response(200, json={"url": "https://httpbin.org/get"})
11 |
12 | transport = httpx.MockTransport(handler)
13 | http_client = httpx.AsyncClient(transport=transport)
14 |
15 | with app.dependency_overrides:
16 | app.dependency_overrides[httpx.AsyncClient] = lambda: http_client
17 |
18 | client = TestClient(app)
19 | response = client.get("/echo/url")
20 | assert response.status_code == 200, response.content
21 | assert response.json() == "https://httpbin.org/get"
22 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/dependencies/test_tutorial_006.py:
--------------------------------------------------------------------------------
1 | import httpx
2 |
3 | from docs_src.tutorial.dependencies.tutorial_006 import app
4 | from xpresso.testclient import TestClient
5 |
6 |
7 | def test_client_injection():
8 | async def handler(request: httpx.Request) -> httpx.Response:
9 | assert request.url == "https://httpbin.org/get"
10 | return httpx.Response(200, json={"url": "https://httpbin.org/get"})
11 |
12 | with app.dependency_overrides as overrides:
13 | overrides[httpx.AsyncClient] = lambda: httpx.AsyncClient(
14 | transport=httpx.MockTransport(handler), base_url="https://httpbin.org"
15 | )
16 | with TestClient(app) as client:
17 | response = client.get("/echo/url")
18 | assert response.status_code == 200, response.content
19 | assert response.json() == "https://httpbin.org/get"
20 |
--------------------------------------------------------------------------------
/xpresso/_utils/schemas.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict
2 |
3 | from pydantic.fields import ModelField
4 | from pydantic.schema import field_schema
5 |
6 | from xpresso._utils.pydantic_utils import filter_pydantic_models_from_mapping
7 | from xpresso.binders.api import ModelNameMap
8 | from xpresso.openapi import models as openapi_models
9 | from xpresso.openapi._constants import REF_PREFIX
10 |
11 |
12 | def openapi_schema_from_pydantic_field(
13 | field: ModelField,
14 | model_name_map: ModelNameMap,
15 | schemas: Dict[str, Any],
16 | ) -> openapi_models.Schema:
17 | schema, refs, _ = field_schema(
18 | field,
19 | by_alias=True,
20 | ref_prefix=REF_PREFIX,
21 | model_name_map=filter_pydantic_models_from_mapping(model_name_map),
22 | )
23 | schemas.update(refs)
24 | return openapi_models.Schema(**schema, nullable=field.allow_none or None)
25 |
--------------------------------------------------------------------------------
/docs_src/tutorial/param_constraints_and_metadata/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from pydantic import Field
2 |
3 | from xpresso import App, Path, QueryParam
4 | from xpresso.typing import Annotated
5 |
6 | fake_items_db = [
7 | {"item_name": "Foo"},
8 | {"item_name": "Bar"},
9 | {"item_name": "Baz"},
10 | ]
11 |
12 |
13 | async def read_item(
14 | skip: Annotated[
15 | int,
16 | QueryParam(
17 | description="Count of items to skip starting from the 0th item"
18 | ),
19 | Field(gt=0),
20 | ],
21 | limit: Annotated[
22 | int,
23 | QueryParam(),
24 | Field(gt=0, description="Maximum number of items to return"),
25 | ],
26 | ):
27 | return fake_items_db[skip : skip + limit]
28 |
29 |
30 | app = App(
31 | routes=[
32 | Path(
33 | path="/items/",
34 | get=read_item,
35 | ),
36 | ]
37 | )
38 |
--------------------------------------------------------------------------------
/docs/assets/images/xpresso-bean.pxd/data/selection/meta:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | backingScale
6 | 1
7 | mode
8 | 0
9 | shapeSelectionFilename
10 | shapeSelection
11 | size
12 |
13 | NC10UHpTVFAQAAAAQI9AAAAAAABAj0AAAAAAAA==
14 |
15 | softness
16 | 0.0
17 | timestamp
18 | 664829621.60078001
19 | transform
20 |
21 | 1
22 | 0.0
23 | 0.0
24 | 1
25 | 0.0
26 | 0.0
27 | 0.0
28 | 0.0
29 |
30 | version
31 | 2
32 |
33 |
34 |
--------------------------------------------------------------------------------
/xpresso/openapi/_utils.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Mapping, Union
2 |
3 | from xpresso.encoders import JsonableEncoder
4 | from xpresso.openapi import models as openapi_models
5 | from xpresso.responses import ResponseSpec
6 |
7 | ENCODER = JsonableEncoder()
8 |
9 |
10 | def merge_response_specs(r1: ResponseSpec, r2: ResponseSpec) -> ResponseSpec:
11 | return ResponseSpec(
12 | description=r2.description or r1.description,
13 | headers={**(r2.headers or {}), **(r1.headers or {})} or None,
14 | content={**(r2.content or {}), **(r1.content or {})} or None,
15 | )
16 |
17 |
18 | def parse_examples(
19 | examples: Mapping[str, Union[openapi_models.Example, Any]]
20 | ) -> openapi_models.Examples:
21 | return {
22 | k: v
23 | if isinstance(v, openapi_models.Example)
24 | else openapi_models.Example(value=ENCODER(v))
25 | for k, v in examples.items()
26 | }
27 |
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/tutorial_003.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | from pydantic import BaseSettings
3 |
4 | from xpresso import App, Depends, Path
5 | from xpresso.typing import Annotated
6 |
7 |
8 | class HttpBinConfig(BaseSettings):
9 | url: str = "https://httpbin.org"
10 |
11 | class Config(BaseSettings.Config):
12 | env_prefix = "HTTPBIN_"
13 |
14 |
15 | def get_client(config: HttpBinConfig) -> httpx.AsyncClient:
16 | return httpx.AsyncClient(base_url=config.url)
17 |
18 |
19 | HttpbinClient = Annotated[httpx.AsyncClient, Depends(get_client)]
20 |
21 |
22 | async def echo_url(client: HttpbinClient) -> str:
23 | resp = await client.get("/get")
24 | resp.raise_for_status() # or some other error handling
25 | return resp.json()["url"]
26 |
27 |
28 | app = App(
29 | routes=[
30 | Path(
31 | "/echo/url",
32 | get=echo_url,
33 | )
34 | ]
35 | )
36 |
--------------------------------------------------------------------------------
/docs_src/advanced/responses/tutorial_002.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Optional
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, FromPath, Operation, Path
6 | from xpresso.parameters import FromQuery
7 | from xpresso.responses import Response, ResponseSpec
8 |
9 |
10 | class Item(BaseModel):
11 | id: str
12 | value: str
13 |
14 |
15 | async def read_item(
16 | item_id: FromPath[str], img: FromQuery[Optional[bool]]
17 | ) -> Any:
18 | if img:
19 | return Response(b"", media_type="image/png")
20 | else:
21 | return {"id": "foo", "value": "there goes my hero"}
22 |
23 |
24 | get_item = Operation(
25 | read_item,
26 | responses={
27 | 200: ResponseSpec(
28 | description="OK",
29 | content={"application/json": Item, "image/png": bytes},
30 | )
31 | },
32 | )
33 |
34 | app = App(routes=[Path("/items/{item_id}", get=get_item)])
35 |
--------------------------------------------------------------------------------
/xpresso/_utils/endpoint_dependent.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing
4 |
5 | from di.api.providers import CallableProvider, CoroutineProvider
6 | from di.concurrency import as_async
7 | from di.dependent import Dependent
8 |
9 | from xpresso.dependencies._dependencies import Depends, DependsMarker
10 |
11 | Endpoint = typing.Union[CallableProvider[typing.Any], CoroutineProvider[typing.Any]]
12 |
13 |
14 | class EndpointDependent(Dependent[typing.Any]):
15 | def __init__(
16 | self,
17 | endpoint: Endpoint,
18 | sync_to_thread: bool = False,
19 | ) -> None:
20 | if sync_to_thread:
21 | endpoint = as_async(endpoint)
22 | super().__init__(
23 | call=endpoint,
24 | scope="endpoint",
25 | use_cache=False,
26 | wire=True,
27 | )
28 |
29 | def get_default_marker(self) -> DependsMarker[None]:
30 | return Depends()
31 |
--------------------------------------------------------------------------------
/xpresso/_utils/scope_resolver.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Sequence
2 |
3 | from di.api.dependencies import DependentBase
4 | from di.api.scopes import Scope
5 |
6 |
7 | def endpoint_scope_resolver(
8 | dep: DependentBase[Any],
9 | sub_dependent_scopes: Sequence[Scope],
10 | _: Sequence[Scope],
11 | ) -> Scope:
12 | """Resolve scopes by defaulting to "connection"
13 | unless a sub-dependency has an "endpoint" scope
14 | in which case we drop down to that scope
15 | """
16 | if dep.scope is not None:
17 | return dep.scope
18 | if "endpoint" in sub_dependent_scopes:
19 | return "endpoint"
20 | return "connection"
21 |
22 |
23 | def lifespan_scope_resolver(
24 | dep: DependentBase[Any],
25 | sub_dependent_scopes: Sequence[Scope],
26 | _: Sequence[Scope],
27 | ) -> Scope:
28 | """Always default to the "app" scope"""
29 | if dep.scope is None:
30 | return "app"
31 | return dep.scope
32 |
--------------------------------------------------------------------------------
/tests/test_openapi/expected_swagger_html.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | API - Swagger UI
7 |
8 |
9 |
10 |
11 |
12 |
13 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/xpresso/_utils/typing.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | if sys.version_info < (3, 9):
4 | from typing_extensions import Annotated as Annotated # noqa: F401
5 | from typing_extensions import get_args as get_args # noqa: F401
6 | from typing_extensions import get_origin as get_origin # noqa: F401
7 | from typing_extensions import get_type_hints as get_type_hints # noqa: F401
8 | else:
9 | from typing import Annotated as Annotated # noqa: F401
10 | from typing import get_args as get_args # noqa: F401
11 | from typing import get_origin as get_origin # noqa: F401
12 | from typing import get_type_hints as get_type_hints # noqa: F401
13 |
14 | if sys.version_info < (3, 8):
15 | from typing_extensions import Literal as Literal # noqa: F401
16 | from typing_extensions import Protocol as Protocol # noqa: F401
17 | else:
18 |
19 | from typing import Literal as Literal # noqa: F401
20 | from typing import Protocol as Protocol # noqa: F401
21 |
--------------------------------------------------------------------------------
/docs_src/advanced/websockets.py:
--------------------------------------------------------------------------------
1 | from xpresso import (
2 | App,
3 | Depends,
4 | FromHeader,
5 | WebSocket,
6 | WebSocketRoute,
7 | )
8 | from xpresso.exceptions import WebSocketValidationError
9 |
10 |
11 | async def enforce_header_pattern(
12 | x_header: FromHeader[int], ws: WebSocket
13 | ) -> None:
14 | if not x_header > 0:
15 | await ws.close()
16 | # This currently produces a 500 error in the server logs
17 | # See https://github.com/encode/starlette/pull/527 for more info
18 | raise WebSocketValidationError([])
19 |
20 |
21 | async def websocket_endpoint(
22 | ws: WebSocket, x_header: FromHeader[int]
23 | ) -> None:
24 | await ws.accept()
25 | await ws.send_text(str(x_header))
26 | await ws.close()
27 |
28 |
29 | app = App(
30 | routes=[
31 | WebSocketRoute(
32 | path="/ws",
33 | endpoint=websocket_endpoint,
34 | dependencies=[Depends(enforce_header_pattern)],
35 | ),
36 | ]
37 | )
38 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/tests/test_lifespans.py:
--------------------------------------------------------------------------------
1 | from contextlib import asynccontextmanager
2 | from typing import AsyncIterator, List
3 |
4 | from xpresso import App, Router
5 | from xpresso.routing.mount import Mount
6 | from xpresso.testclient import TestClient
7 |
8 |
9 | def test_lifespan_mounted_app() -> None:
10 | class Counter(List[int]):
11 | pass
12 |
13 | @asynccontextmanager
14 | async def lifespan(counter: Counter) -> AsyncIterator[None]:
15 | counter.append(1)
16 | yield
17 |
18 | counter = Counter()
19 |
20 | inner_app = App(lifespan=lifespan)
21 | inner_app.dependency_overrides[Counter] = lambda: counter
22 |
23 | app = App(
24 | routes=[
25 | Mount("/mounted-app", app=inner_app),
26 | Mount("/mounted-router", app=Router([], lifespan=lifespan)),
27 | ],
28 | lifespan=lifespan,
29 | )
30 |
31 | app.dependency_overrides[Counter] = lambda: counter
32 |
33 | with TestClient(app):
34 | pass
35 |
36 | assert counter == [1, 1, 1]
37 |
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/tutorial_004.py:
--------------------------------------------------------------------------------
1 | from typing import AsyncGenerator
2 |
3 | import httpx
4 | from pydantic import BaseSettings
5 |
6 | from xpresso import App, Depends, Path
7 | from xpresso.typing import Annotated
8 |
9 |
10 | class HttpBinConfig(BaseSettings):
11 | url: str = "https://httpbin.org"
12 |
13 | class Config(BaseSettings.Config):
14 | env_prefix = "HTTPBIN_"
15 |
16 |
17 | async def get_client(
18 | config: HttpBinConfig,
19 | ) -> AsyncGenerator[httpx.AsyncClient, None]:
20 | async with httpx.AsyncClient(base_url=config.url) as client:
21 | yield client
22 |
23 |
24 | HttpbinClient = Annotated[httpx.AsyncClient, Depends(get_client)]
25 |
26 |
27 | async def echo_url(client: HttpbinClient) -> str:
28 | resp = await client.get("/get")
29 | resp.raise_for_status() # or some other error handling
30 | return resp.json()["url"]
31 |
32 |
33 | app = App(
34 | routes=[
35 | Path(
36 | "/echo/url",
37 | get=echo_url,
38 | )
39 | ]
40 | )
41 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/routing/test_tutorial001.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict
2 |
3 | from docs_src.tutorial.routing.tutorial_001 import app
4 | from xpresso.testclient import TestClient
5 |
6 |
7 | def test_routing() -> None:
8 | client = TestClient(app)
9 |
10 | resp = client.get("/mount/mount-again/items")
11 | assert resp.status_code == 200, resp.content
12 |
13 |
14 | def test_openapi() -> None:
15 | expected_openapi: Dict[str, Any] = {
16 | "openapi": "3.0.3",
17 | "info": {"title": "API", "version": "0.1.0"},
18 | "paths": {
19 | "/mount/mount-again/items": {
20 | "get": {"responses": {"200": {
21 | "description": "OK",
22 | "content": {"application/json": {}},
23 | }}}
24 | }
25 | },
26 | }
27 |
28 | client = TestClient(app)
29 |
30 | resp = client.get("/openapi.json")
31 | assert resp.status_code == 200, resp.content
32 | assert resp.json() == expected_openapi
33 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright 2021 Adrian Garcia Badaracco
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/docs_src/advanced/binders/msgpack/openapi.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import typing
3 |
4 | from xpresso.binders.api import ModelNameMap, SupportsOpenAPI
5 | from xpresso.openapi import models
6 |
7 |
8 | class OpenAPI:
9 | def get_models(self) -> typing.List[type]:
10 | return []
11 |
12 | def modify_operation_schema(
13 | self,
14 | model_name_map: ModelNameMap,
15 | operation: models.Operation,
16 | components: models.Components,
17 | ) -> None:
18 | operation.requestBody = models.RequestBody(
19 | content={
20 | "application/x-msgpack": models.MediaType(
21 | schema=models.Schema( # type: ignore[arg-type]
22 | type="string",
23 | format="binary",
24 | )
25 | )
26 | },
27 | required=True,
28 | )
29 |
30 |
31 | class OpenAPIMarker:
32 | def register_parameter(
33 | self, param: inspect.Parameter
34 | ) -> SupportsOpenAPI:
35 | return OpenAPI()
36 |
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/tutorial_006.py:
--------------------------------------------------------------------------------
1 | from typing import AsyncGenerator
2 |
3 | import httpx
4 | from pydantic import BaseSettings
5 |
6 | from xpresso import App, Depends, Path
7 | from xpresso.typing import Annotated
8 |
9 |
10 | class HttpBinConfig(BaseSettings):
11 | url: str = "https://httpbin.org"
12 |
13 | class Config(BaseSettings.Config):
14 | env_prefix = "HTTPBIN_"
15 |
16 |
17 | async def get_client(
18 | config: HttpBinConfig,
19 | ) -> AsyncGenerator[httpx.AsyncClient, None]:
20 | async with httpx.AsyncClient(base_url=config.url) as client:
21 | yield client
22 |
23 |
24 | HttpbinClient = Annotated[
25 | httpx.AsyncClient, Depends(get_client, scope="app")
26 | ]
27 |
28 |
29 | async def echo_url(client: HttpbinClient) -> str:
30 | resp = await client.get("/get")
31 | resp.raise_for_status() # or some other error handling
32 | return resp.json()["url"]
33 |
34 |
35 | app = App(
36 | routes=[
37 | Path(
38 | "/echo/url",
39 | get=echo_url,
40 | )
41 | ]
42 | )
43 |
--------------------------------------------------------------------------------
/docs_src/tutorial/param_constraints_and_metadata/tutorial_002.py:
--------------------------------------------------------------------------------
1 | import re
2 | from typing import List
3 |
4 | from xpresso import App, Path, QueryParam
5 | from xpresso.openapi.models import Example
6 | from xpresso.typing import Annotated
7 |
8 | fake_items_db = [
9 | {"item_name": "Foo"},
10 | {"item_name": "Bar"},
11 | {"item_name": "Baz"},
12 | ]
13 |
14 |
15 | prefix_examples = {
16 | "Starts with Foo": Example(value="Foo.*"),
17 | "Starts with Foo or Bar": Example(value="Foo.*|Bar.*"),
18 | }
19 |
20 | QueryFilter = Annotated[
21 | str,
22 | QueryParam(
23 | description="Regular expression to filter items by name",
24 | examples=prefix_examples,
25 | ),
26 | ]
27 |
28 |
29 | async def read_item(filter: QueryFilter) -> List[str]:
30 | return [
31 | item["item_name"]
32 | for item in fake_items_db
33 | if re.match(filter, item["item_name"])
34 | ]
35 |
36 |
37 | app = App(
38 | routes=[
39 | Path(
40 | path="/items/",
41 | get=read_item,
42 | ),
43 | ]
44 | )
45 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/additional_responses/test_tutorial_004.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict
2 |
3 | from docs_src.advanced.responses.tutorial_004 import app
4 | from xpresso.testclient import TestClient
5 |
6 | client = TestClient(app)
7 |
8 | openapi_schema: Dict[str, Any] = {
9 | "openapi": "3.0.3",
10 | "info": {"title": "API", "version": "0.1.0"},
11 | "paths": {
12 | "/items/": {
13 | "post": {
14 | "responses": {
15 | "204": {
16 | "description": "No Content",
17 | "content": {"application/json": {}},
18 | }
19 | }
20 | }
21 | }
22 | },
23 | }
24 |
25 |
26 | def test_openapi_schema():
27 | response = client.get("/openapi.json")
28 | assert response.status_code == 200, response.text
29 | assert response.json() == openapi_schema
30 |
31 |
32 | def test_create_image():
33 | response = client.post("/items/", json={"id": "foo", "value": "bar"})
34 | assert response.status_code == 204, response.text
35 |
--------------------------------------------------------------------------------
/docs_src/tutorial/lifespans/tutorial_002.py:
--------------------------------------------------------------------------------
1 | from contextlib import asynccontextmanager
2 | from typing import AsyncIterator, List
3 |
4 | from xpresso import App, Path, Router
5 | from xpresso.routing.mount import Mount
6 |
7 |
8 | class Logger(List[str]):
9 | pass
10 |
11 |
12 | @asynccontextmanager
13 | async def app_lifespan(logger: Logger) -> AsyncIterator[None]:
14 | logger.append("App lifespan")
15 | yield
16 |
17 |
18 | @asynccontextmanager
19 | async def router_lifespan(logger: Logger) -> AsyncIterator[None]:
20 | logger.append("Router lifespan")
21 | yield
22 |
23 |
24 | async def get_logs(logger: Logger) -> List[str]:
25 | return logger
26 |
27 |
28 | app = App(
29 | routes=[
30 | Mount(
31 | "",
32 | app=Router(
33 | routes=[
34 | Path(
35 | "/logs",
36 | get=get_logs,
37 | )
38 | ],
39 | lifespan=router_lifespan,
40 | ),
41 | )
42 | ],
43 | lifespan=app_lifespan,
44 | )
45 |
--------------------------------------------------------------------------------
/tests/test_openapi/test_docstrings.py:
--------------------------------------------------------------------------------
1 | from xpresso import App, Operation, Path
2 | from xpresso.testclient import TestClient
3 |
4 |
5 | def test_docstrings() -> None:
6 | """Check that doctring indentation is correctly parsed out"""
7 |
8 | async def endpoint() -> None:
9 | """Lorem ipsum:
10 |
11 | 1. Dolor
12 | 2. Amet
13 | """
14 |
15 | app = App([Path("/", get=endpoint)])
16 | client = TestClient(app)
17 |
18 | resp = client.get("/openapi.json")
19 |
20 | assert resp.status_code == 200, resp.content
21 | assert (
22 | resp.json()["paths"]["/"]["get"]["description"]
23 | == "Lorem ipsum:\n\n1. Dolor\n2. Amet"
24 | )
25 |
26 |
27 | def test_description_overrides_docstring() -> None:
28 | async def endpoint() -> None:
29 | """123"""
30 |
31 | app = App([Path("/", get=Operation(endpoint, description="456"))])
32 | client = TestClient(app)
33 |
34 | resp = client.get("/openapi.json")
35 |
36 | assert resp.status_code == 200, resp.content
37 | assert resp.json()["paths"]["/"]["get"]["description"] == "456"
38 |
--------------------------------------------------------------------------------
/benchmarks/starlette_app.py:
--------------------------------------------------------------------------------
1 | from typing import Awaitable, List, Mapping, Union
2 | from starlette.applications import Starlette as App
3 | from starlette.responses import Response
4 | from starlette.routing import Route, Router, Mount, BaseRoute
5 | from starlette.types import Scope, Receive, Send
6 |
7 | from benchmarks.constants import ROUTING_PATHS
8 |
9 | class Simple:
10 | def __call__(self, scope: Scope, receive: Receive, send: Send) -> Awaitable[None]:
11 | return Response()(scope, receive, send)
12 |
13 |
14 | Paths = Mapping[str, Union["Paths", None]] # type: ignore[misc]
15 |
16 |
17 | def recurisively_generate_routes(paths: Paths) -> Router:
18 | routes: List[BaseRoute] = []
19 | for path in paths:
20 | subpaths = paths[path]
21 | if subpaths is None:
22 | routes.append(Route(f"/{path}", Simple()))
23 | else:
24 | routes.append(Mount(f"/{path}", app=recurisively_generate_routes(subpaths)))
25 | return Router(routes=routes)
26 |
27 |
28 | app = App(routes=[Route("/simple", Simple()), Mount("/routing", app=recurisively_generate_routes(ROUTING_PATHS))])
29 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/test_root_path.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict
2 |
3 | from docs_src.advanced.root_path import app
4 | from xpresso.testclient import TestClient
5 |
6 | client = TestClient(app, base_url="https://example.com")
7 |
8 | openapi_schema: Dict[str, Any] = {
9 | "openapi": "3.0.3",
10 | "info": {"title": "API", "version": "0.1.0"},
11 | "paths": {
12 | "/app": {
13 | "get": {
14 | "responses": {
15 | "200": {
16 | "description": "OK",
17 | "content": {"application/json": {"schema": {"type": "string"}}},
18 | }
19 | }
20 | }
21 | }
22 | },
23 | "servers": [{"url": "/v1/api"}],
24 | }
25 |
26 |
27 | def test_openapi():
28 | response = client.get("/openapi.json")
29 | assert response.status_code == 200
30 | assert response.json() == openapi_schema
31 |
32 |
33 | def test_main():
34 | response = client.get("/app")
35 | assert response.status_code == 200
36 | assert response.json() == "Hello from https://example.com/v1/api/app"
37 |
--------------------------------------------------------------------------------
/xpresso/exceptions.py:
--------------------------------------------------------------------------------
1 | import typing
2 | from typing import List, Type
3 |
4 | from pydantic import BaseModel
5 | from pydantic import ValidationError as PydanticValidationError
6 | from pydantic import create_model
7 | from pydantic.error_wrappers import ErrorWrapper
8 | from starlette.exceptions import HTTPException as HTTPException # noqa: F401
9 | from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY
10 |
11 | _RequestErrorModel: Type[BaseModel] = create_model("Request") # type: ignore
12 | _WebSocketErrorModel: Type[BaseModel] = create_model("WebSocket") # type: ignore
13 |
14 |
15 | class RequestValidationError(PydanticValidationError):
16 | raw_errors: List[ErrorWrapper]
17 |
18 | def __init__(
19 | self,
20 | errors: typing.Sequence[ErrorWrapper],
21 | status_code: int = HTTP_422_UNPROCESSABLE_ENTITY,
22 | ) -> None:
23 | super().__init__(errors, _RequestErrorModel)
24 | self.status_code = status_code
25 |
26 |
27 | class WebSocketValidationError(PydanticValidationError):
28 | def __init__(
29 | self,
30 | errors: typing.Sequence[ErrorWrapper],
31 | ) -> None:
32 | super().__init__(errors, _WebSocketErrorModel)
33 |
--------------------------------------------------------------------------------
/docs_src/advanced/dependencies/tutorial_004.py:
--------------------------------------------------------------------------------
1 | from typing import Generator, List
2 |
3 | from xpresso import (
4 | App,
5 | Depends,
6 | FromPath,
7 | HTTPException,
8 | Path,
9 | Request,
10 | )
11 | from xpresso.responses import get_response
12 |
13 |
14 | class StatusCodeLogFile(List[int]):
15 | pass
16 |
17 |
18 | def log_response_status_code(
19 | request: Request, log: StatusCodeLogFile
20 | ) -> Generator[None, None, None]:
21 | try:
22 | yield
23 | except HTTPException as exc:
24 | log.append(exc.status_code)
25 | raise
26 | else:
27 | response = get_response(request)
28 | log.append(response.status_code)
29 |
30 |
31 | fake_items_db = {"foo": "Foo", "bar": "Bar"}
32 |
33 |
34 | async def read_items(item_name: FromPath[str]) -> str:
35 | if item_name in fake_items_db:
36 | return fake_items_db[item_name]
37 | raise HTTPException(status_code=404)
38 |
39 |
40 | app = App(
41 | routes=[
42 | Path(
43 | path="/items/{item_name}",
44 | get=read_items,
45 | dependencies=[
46 | Depends(log_response_status_code, scope="connection")
47 | ],
48 | ),
49 | ]
50 | )
51 |
--------------------------------------------------------------------------------
/xpresso/exception_handlers.py:
--------------------------------------------------------------------------------
1 | from typing import Awaitable, Callable, Type, TypeVar, Union
2 |
3 | from starlette.exceptions import HTTPException
4 | from starlette.requests import Request
5 | from starlette.responses import JSONResponse, Response
6 |
7 | from xpresso.encoders import JsonableEncoder
8 | from xpresso.exceptions import RequestValidationError
9 |
10 | ExcType = TypeVar("ExcType", bound=Exception, contravariant=True)
11 |
12 |
13 | class ExcHandler:
14 | def __init__(
15 | self,
16 | exc: Union[Type[ExcType], int],
17 | handler: Callable[[Request, ExcType], Union[Awaitable[Response], Response]],
18 | ) -> None:
19 | self.exc = exc
20 | self.handler = handler
21 |
22 |
23 | async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
24 | return JSONResponse(
25 | {"detail": exc.detail},
26 | status_code=exc.status_code,
27 | headers=getattr(exc, "headers", None) or {},
28 | )
29 |
30 |
31 | _ENCODER = JsonableEncoder()
32 |
33 |
34 | async def validation_exception_handler(
35 | request: Request, exc: RequestValidationError
36 | ) -> JSONResponse:
37 | return JSONResponse(
38 | _ENCODER({"detail": exc.errors()}),
39 | status_code=exc.status_code,
40 | )
41 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/dependencies/test_tutorial_003.py:
--------------------------------------------------------------------------------
1 | import httpx
2 |
3 | from docs_src.tutorial.dependencies.tutorial_003 import HttpBinConfig, app
4 | from xpresso.testclient import TestClient
5 |
6 |
7 | def test_client_config_injection():
8 |
9 | test_url = "https://example.com"
10 |
11 | async def handler(request: httpx.Request) -> httpx.Response:
12 | assert request.url == test_url + "/get"
13 | return httpx.Response(200, json={"url": test_url + "/get"})
14 |
15 | # This dependency becomes the provider for the client
16 | # It will get auto-wired with the config, so we can use it to assert that the config
17 | # Was successfully injected
18 | def get_client(config: HttpBinConfig) -> httpx.AsyncClient:
19 | assert config.url == test_url
20 | return httpx.AsyncClient(
21 | transport=httpx.MockTransport(handler), base_url=config.url
22 | )
23 |
24 | with app.dependency_overrides as overrides:
25 | overrides[HttpBinConfig] = lambda: HttpBinConfig(url=test_url)
26 | overrides[httpx.AsyncClient] = get_client
27 | client = TestClient(app)
28 | response = client.get("/echo/url")
29 | assert response.status_code == 200, response.content
30 | assert response.json() == test_url + "/get"
31 |
--------------------------------------------------------------------------------
/docs_src/advanced/dependencies/tutorial_005.py:
--------------------------------------------------------------------------------
1 | from typing import Generator
2 | from uuid import UUID, uuid4
3 |
4 | from xpresso import (
5 | App,
6 | Depends,
7 | FromPath,
8 | HTTPException,
9 | Path,
10 | Request,
11 | )
12 | from xpresso.responses import get_response
13 |
14 | CONTEXT_HEADER = "X-Request-Context"
15 |
16 |
17 | def trace(request: Request) -> Generator[None, None, None]:
18 | req_ctx = request.headers.get(CONTEXT_HEADER, None)
19 | if req_ctx is not None:
20 | ctx = UUID(req_ctx)
21 | else:
22 | ctx = uuid4()
23 | try:
24 | yield
25 | except HTTPException as exc:
26 | exc.headers[CONTEXT_HEADER] = str(ctx)
27 | raise
28 | else:
29 | response = get_response(request)
30 | response.headers[CONTEXT_HEADER] = str(ctx)
31 |
32 |
33 | fake_items_db = {"foo": "Foo", "bar": "Bar"}
34 |
35 |
36 | async def read_items(item_name: FromPath[str]) -> str:
37 | if item_name in fake_items_db:
38 | return fake_items_db[item_name]
39 | raise HTTPException(status_code=404)
40 |
41 |
42 | app = App(
43 | routes=[
44 | Path(
45 | path="/items/{item_name}",
46 | get=read_items,
47 | dependencies=[Depends(trace, scope="endpoint")],
48 | ),
49 | ]
50 | )
51 |
--------------------------------------------------------------------------------
/docs_src/tutorial/dependencies/tutorial_007.py:
--------------------------------------------------------------------------------
1 | from typing import Callable, FrozenSet, Optional
2 |
3 | from xpresso import (
4 | App,
5 | Depends,
6 | FromPath,
7 | FromQuery,
8 | HTTPException,
9 | Operation,
10 | Path,
11 | )
12 |
13 |
14 | def require_roles(*roles: str) -> Callable[..., None]:
15 | role_set = frozenset(roles)
16 |
17 | def enforce_roles(
18 | roles: FromQuery[Optional[FrozenSet[str]]] = None,
19 | ) -> None:
20 | missing_roles = role_set.difference(roles or frozenset())
21 | if missing_roles:
22 | raise HTTPException(
23 | 403, f"Missing roles: {list(missing_roles)}"
24 | )
25 |
26 | return enforce_roles
27 |
28 |
29 | async def delete_item() -> None:
30 | ...
31 |
32 |
33 | async def get_item(item_id: FromPath[str]) -> str:
34 | return item_id
35 |
36 |
37 | app = App(
38 | routes=[
39 | Path(
40 | "/items/{item_id}",
41 | get=get_item, # no extra roles required
42 | delete=Operation(
43 | endpoint=delete_item,
44 | dependencies=[Depends(require_roles("items-admin"))],
45 | ),
46 | dependencies=[Depends(require_roles("items-user"))],
47 | )
48 | ],
49 | dependencies=[Depends(require_roles("user"))],
50 | )
51 |
--------------------------------------------------------------------------------
/docs_src/advanced/dependencies/tutorial_006.py:
--------------------------------------------------------------------------------
1 | import sqlite3
2 | from dataclasses import dataclass
3 | from typing import List
4 |
5 | from xpresso import App, FromJson, Path
6 |
7 |
8 | class SupportsWordsRepo:
9 | def add_word(self, word: str) -> None:
10 | raise NotImplementedError
11 |
12 |
13 | @dataclass
14 | class SQLiteWordsRepo(SupportsWordsRepo):
15 | conn: sqlite3.Connection
16 |
17 | def add_word(self, word: str) -> None:
18 | with self.conn:
19 | self.conn.execute("SELECT ?", (word,))
20 |
21 |
22 | def add_word(repo: SupportsWordsRepo, word: FromJson[str]) -> str:
23 | repo.add_word(word)
24 | return word
25 |
26 |
27 | routes = [Path("/words/", post=add_word)]
28 |
29 |
30 | def create_app() -> App:
31 | conn = sqlite3.connect(":memory:")
32 | repo = SQLiteWordsRepo(conn)
33 | app = App(routes)
34 | app.dependency_overrides[SupportsWordsRepo] = lambda: repo
35 | return app
36 |
37 |
38 | def test_add_word_endpoint() -> None:
39 | # this demonstrates how easy it is to swap
40 | # out an implementation with this pattern
41 | words: List[str] = []
42 |
43 | class TestWordsRepo(SupportsWordsRepo):
44 | def add_word(self, word: str) -> None:
45 | words.append(word)
46 |
47 | add_word(TestWordsRepo(), "hello")
48 |
49 | assert words == ["hello"]
50 |
--------------------------------------------------------------------------------
/benchmarks/constants.py:
--------------------------------------------------------------------------------
1 | # Establishing an asyncpg -> PostgreSQL connection takes ~75ms
2 | # Running query takes about 1ms
3 | # Hitting okta.com w/ httpx takes ~100ms
4 | # So we'll take a range of 1ms to 100ms as delays for async dependencies
5 | # And then make a medium sized DAG (3 levels)
6 |
7 | NO_DELAY = (0, 0)
8 | DELAY = (1e-3, 1e-1)
9 |
10 | DAG_SHAPE = (3, 2, 2)
11 |
12 | ROUTING_PATHS = {
13 | "one": {
14 | "one-one": {
15 | "one-one-one": None,
16 | "one-one-two": None,
17 | "one-one-three": None,
18 | },
19 | "one-two": {
20 | "one-two-one": None,
21 | "one-two-two": None,
22 | "one-two-three": None,
23 | },
24 | "one-three": {
25 | "one-three-one": None,
26 | "one-three-two": None,
27 | "one-three-three": None,
28 | },
29 | },
30 | "two": {
31 | "two-one": {
32 | "two-one-one": None,
33 | "two-one-two": None,
34 | "two-one-three": None,
35 | },
36 | "two-two": {
37 | "two-two-one": None,
38 | "two-two-two": None,
39 | "two-two-three": None,
40 | },
41 | "two-three": {
42 | "two-three-one": None,
43 | "two-three-two": None,
44 | "two-three-three": None,
45 | },
46 | },
47 | }
48 |
--------------------------------------------------------------------------------
/docs/tutorial/lifespan.md:
--------------------------------------------------------------------------------
1 | # Lifespans
2 |
3 | Xpresso supports lifespan context managers from [Starlette].
4 | This is the only way to handle startup/shutdown; there are no startup/shutdown events in Xpresso.
5 |
6 | The main difference vs. Starlette is that the lifespan context manager is allowed to depend on `"app"` scoped dependencies (see [Dependency Scopes]), including the `App` itself:
7 |
8 | ```python
9 | --8<-- "docs_src/tutorial/lifespans/tutorial_001.py"
10 | ```
11 |
12 | !!! tip Tip
13 | You don't need `app.state` or `request.state` in Xpresso.
14 | Instead, you can create you own strongly typed mutable or immutable state object and inject it into your lifespan and/or endpoints like in the example above.
15 |
16 | !!! tip Tip
17 | Lifespan dependencies are automatically assigned the `"app"` scope, you don't need to explicitly set it.
18 |
19 | ## Router lifespans
20 |
21 | Routers can also have lifespans, and these lifespans will be executed when the top level `App`'s lifespan executes:
22 |
23 | ```python hl_lines="18-21 32-40"
24 | --8<-- "docs_src/tutorial/lifespans/tutorial_002.py"
25 | ```
26 |
27 | !!! note Note
28 | Only Xpresso Routers and mounted Apps support multiple lifespans.
29 | Lifespans for arbitrary mounted ASGI apps (using `Mount`) will _not_ work.
30 |
31 | [Starlette]: https://www.starlette.io/events/
32 | [Dependency Scopes]: ../tutorial/dependencies/scopes.md
33 |
--------------------------------------------------------------------------------
/tests/test_dependencies/test_injectable_classes.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 |
3 | from xpresso import App, Path
4 | from xpresso.dependencies import Injectable, Singleton
5 | from xpresso.testclient import TestClient
6 |
7 |
8 | def test_singleton() -> None:
9 | class Foo:
10 | pass
11 |
12 | @dataclass
13 | class MyService(Singleton):
14 | foo: Foo
15 |
16 | async def endpoint(service: MyService) -> int:
17 | return id(service)
18 |
19 | app = App(routes=[Path("/", get=endpoint)])
20 |
21 | with TestClient(app) as client:
22 | resp1 = client.get("/")
23 | assert resp1.status_code == 200, resp1.content
24 | resp2 = client.get("/")
25 | assert resp2.status_code == 200, resp2.content
26 | assert resp1.json() == resp2.json()
27 |
28 |
29 | def test_injectable() -> None:
30 | class Foo:
31 | pass
32 |
33 | @dataclass
34 | class MyService(Injectable):
35 | foo: Foo
36 |
37 | async def endpoint(service: MyService) -> int:
38 | return id(service)
39 |
40 | app = App(routes=[Path("/", get=endpoint)])
41 |
42 | with TestClient(app) as client:
43 | resp1 = client.get("/")
44 | assert resp1.status_code == 200, resp1.content
45 | resp2 = client.get("/")
46 | assert resp2.status_code == 200, resp2.content
47 | assert resp1.json() != resp2.json()
48 |
--------------------------------------------------------------------------------
/docs/tutorial/header_params.md:
--------------------------------------------------------------------------------
1 | # Header Parameters
2 |
3 | Header parameters are declared the same way as `Query` and `Path` parameters:
4 |
5 | ```python
6 | --8<-- "docs_src/tutorial/header_params/tutorial_001.py"
7 | ```
8 |
9 | ## Underscore conversion
10 |
11 | Headers names are usually composed of several words separated by hyphens (`"-"`).
12 | But Python veriable names cannot contain hyphens.
13 | Since Xpresso automatically derives the header names from the parameter names, this creates a problem.
14 | To get around this, Xpresso automatically converts parameter name underscores (`"_"`) to hyphens (`"-"`).
15 | This is controlled using the `convert_underscores` parameter to `HeaderParam(...)`:
16 |
17 | ```python
18 | --8<-- "docs_src/tutorial/header_params/tutorial_002.py"
19 | ```
20 |
21 | !!! tip "Tip"
22 | The import `from Xpresso.typing import Annotated` is just a convenience import.
23 | All it does is import `Annotated` from `typing` if your Python version is >= 3.9 and [typing_extensions] otherwise.
24 | But if you are already using Python >= 3.9, you can just replace that with `from typing import Annotated`.
25 |
26 | !!! warning "Warning"
27 | It is pretty uncommon to use headers with underscores.
28 | You should probably think twice about setting `convert_underscores=False` and test that it doesn't break your clients, proxies, etc.
29 |
30 | ## Repeated Headers
31 |
32 | ## Serialization and Parsing
33 |
--------------------------------------------------------------------------------
/docs/advanced/dependencies/caching.md:
--------------------------------------------------------------------------------
1 | # Dependency Caching
2 |
3 | Xpresso has a dependency caching system.
4 | This allows re-using of already computed dependencies within a request response cycle.
5 | This is also what enables Xpresso to persist `"app"` scoped dependencies across requests (see [Scopes]).
6 | By default, all dependencies are cached within their execution scope, but this can be disabled on a per-dependency basis with the `use_cache` argument to `Depends`.
7 |
8 | First we are going to declare a placeholder dependency with no sub-dependencies.
9 | We are just going to compare instances, so there's nothing else needed in this dependency.
10 |
11 | ```python hl_lines="5-6"
12 | --8<-- "docs_src/advanced/dependencies/tutorial_003.py"
13 | ```
14 |
15 | Next we'll create two dependencies that depend on this dependency to test that sub-dependencies are shared:
16 |
17 | ```python hl_lines="9-10 13-14"
18 | --8<-- "docs_src/advanced/dependencies/tutorial_003.py"
19 | ```
20 |
21 | Finally we create an endpoint that checks that the shared sub-dependencies are the same but the dependency declared with `use_cache=False` is not the same:
22 |
23 | ```python hl_lines="17-23"
24 | --8<-- "docs_src/advanced/dependencies/tutorial_003.py"
25 | ```
26 |
27 | You can test this by running the app and navigating to [http://127.0.0.1:800/shared](http://127.0.0.1:800/shared).
28 | You should get a `200 OK` response with no errors.
29 |
30 | [Scopes]: ../../tutorial/dependencies/scopes.md
31 |
--------------------------------------------------------------------------------
/docs/tutorial/forms.md:
--------------------------------------------------------------------------------
1 | # Forms
2 |
3 | To extract forms in Xpresso, you start by declaring a Pydantic model to unpack the form into.
4 | The fields of the model correspond to the fields of the form data.
5 |
6 | ```python
7 | --8<-- "docs_src/tutorial/forms/tutorial_001.py"
8 | ```
9 |
10 | This request extracts a `application/x-www-form-urlencoded` request into a `FormModel` object.
11 |
12 | ## Form serialization
13 |
14 | Xpresso fully supports the [OpenAPI parameter serialization] standard.
15 | You can customize deserialization using the `style` and `explode` keyword arguments to `FormField()`:
16 |
17 | ```python
18 | --8<-- "docs_src/tutorial/forms/tutorial_002.py"
19 | ```
20 |
21 | ## Multipart requests
22 |
23 | Multipart requests (`multipart/form-data`) can be parsed almost identically to `application/x-www-form-urlencoded`.
24 | You can't upload mixed files and data in an `application/x-www-form-urlencoded` request, so you'll need to use a Multipart request.
25 | Multipart requests even support multiple files:
26 |
27 | ```python
28 | --8<-- "docs_src/tutorial/forms/tutorial_003.py"
29 | ```
30 |
31 | !!! tip "Tip"
32 | Fields in a `application/x-www-form-urlencoded` or `multipart/form-data` request can be repeated.
33 | This just means that a field of the same name appears more than once in the request.
34 | Often this is used to upload multiple files, such as in the example above.
35 |
36 | [openapi parameter serialization]: https://swagger.io/docs/specification/serialization/
37 |
--------------------------------------------------------------------------------
/xpresso/binders/dependents.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import typing
3 |
4 | from di.api.dependencies import CacheKey
5 | from di.dependent import Dependent, Marker
6 |
7 | from xpresso._utils.typing import Protocol
8 | from xpresso.binders.api import SupportsExtractor, SupportsOpenAPI
9 |
10 | T = typing.TypeVar("T", covariant=True)
11 |
12 |
13 | class SupportsMarker(Protocol[T]):
14 | def register_parameter(self, param: inspect.Parameter) -> T:
15 | ...
16 |
17 |
18 | class Binder(Dependent[typing.Any]):
19 | def __init__(
20 | self,
21 | *,
22 | openapi: SupportsOpenAPI,
23 | extractor: SupportsExtractor,
24 | ) -> None:
25 | super().__init__(call=extractor.extract, scope="connection")
26 | self.openapi = openapi
27 | self.extractor = extractor
28 |
29 | @property
30 | def cache_key(self) -> CacheKey:
31 | return self.extractor
32 |
33 |
34 | class BinderMarker(Marker):
35 | def __init__(
36 | self,
37 | *,
38 | extractor_marker: SupportsMarker[SupportsExtractor],
39 | openapi_marker: SupportsMarker[SupportsOpenAPI],
40 | ) -> None:
41 | self.extractor_marker = extractor_marker
42 | self.openapi_marker = openapi_marker
43 |
44 | def register_parameter(self, param: inspect.Parameter) -> Binder:
45 | return Binder(
46 | openapi=self.openapi_marker.register_parameter(param),
47 | extractor=self.extractor_marker.register_parameter(param),
48 | )
49 |
--------------------------------------------------------------------------------
/docs/advanced/proxies-root-path.md:
--------------------------------------------------------------------------------
1 | # Setting the `root_path` for your Xpresso App
2 |
3 | In most cases, when your app gets a request for `https://example.com/v1/api/app` the [URL path] will be `/v1/api/app`.
4 | But sometimes if you are running behind a reverse proxy or some other network layer that does routing based on the path, some prefix of the path may be stripped.
5 | For example, your proxy may be set up to direct traffic between `/v1/api/app` and `/v2/api/app`, and when it forwards the request to one of your applications, it may strip the `/v{1,2}/api` part of the path.
6 | This means your application would _think_ that it is being called at `/app` when really the client is calling it at `/v1/api/app`.
7 | Amongst other problems, this means that your OpenAPI docs won't work as intended: when Xpresso generates the Swagger client that your browser loads (usually `/docs`), it needs to inject it's own URL into it so that your browser can make a request back to the API to get the OpenAPI spec (usually `/openapi.json`).
8 | If your app doesn't know about the `/v1/api` part of the path, it will tell the frontend (Swagger) to load `/openapi.json`, when it should have used `/v1/api/openapi.json` instead.
9 |
10 | To get around these situations, the ASGI specification uses a parameter called `root_path`.
11 | This is set by servers (like Uvicorn) or by your application.
12 | To set this value in Xpresso, use the `root_path` parameter to `App`:
13 |
14 | ```python
15 | --8<-- "docs_src/advanced/root_path.py"
16 | ```
17 |
18 | [URL path]: https://sethmlarson.dev/blog/why-urls-are-hard-path-params-urlparse
19 |
--------------------------------------------------------------------------------
/docs/tutorial/minimal_app.md:
--------------------------------------------------------------------------------
1 | # Getting started: a minimal Xpresso app
2 |
3 | Start by making a file called `main.py` and fill out the following code:
4 |
5 | ```python
6 | --8<-- "docs_src/tutorial/minimal_app.py"
7 | ```
8 |
9 | What we've done here so far is:
10 |
11 | 1. Create an endpoint function.
12 | 1. Create a PathItem. A PathItem represents a unique HTTP resource, and can have several http methods attached to it.
13 | 1. Bind our endpoint function to the PathItem's GET method.
14 | 1. Create an `App` instance that uses the PathItem.
15 |
16 | This is actually all we need, so you can run this using Uvicorn:
17 |
18 | ```python
19 | uvicorn main:app
20 | ```
21 |
22 | Now navigate to [http://localhost:8000/](http://localhost:8000/) and you should see `{"message": "Hello World"}` on your screen.
23 |
24 | ## Interactive Swagger Docs
25 |
26 | You can also navigate to [http://localhost:8000/docs](http://localhost:8000/docs) to see the OpenAPI docs, served via [Swagger UI].
27 |
28 | !!! info "Info"
29 | Swagger UI is a collection of HTML and scripts that serve as a frontend to an OpenAPI specification.
30 | Swagger gives you an interactive UI where you can send requests and get responses from your backend, all based on the OpenAPI specification that Xpresso automatically builds for you.
31 |
32 | Since we didn't give Xpresso much info on the endpoint function's return value (it is implicitly `None`) and there is no request body, there isn't much information in OpenAPI.
33 | In later chapters, you will see how we can give Xpresso more information.
34 |
35 | [Swagger UI]: https://swagger.io/tools/swagger-ui/
36 |
--------------------------------------------------------------------------------
/docs_src/advanced/binders/msgpack/extractor.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import sys
3 | from typing import Any, NamedTuple, Type
4 |
5 | if sys.version_info < (3, 8):
6 | from typing_extensions import get_args
7 | else:
8 | from typing import get_args
9 |
10 | import msgpack # type: ignore[import]
11 | from pydantic import BaseModel
12 |
13 | from xpresso import Request
14 | from xpresso.binders.api import SupportsExtractor
15 | from xpresso.requests import HTTPConnection
16 |
17 |
18 | class Extractor(NamedTuple):
19 | model: Type[BaseModel]
20 |
21 | async def extract(self, connection: HTTPConnection) -> Any:
22 | assert isinstance(connection, Request)
23 | data = await connection.body()
24 | deserialized_obj: Any = msgpack.unpackb(data) # type: ignore[assignment]
25 | # You probably want more checks and validation here
26 | # For example, handling empty bodies
27 | # This is just a tutorial!
28 | return self.model.parse_obj(deserialized_obj)
29 |
30 |
31 | class ExtractorMarker:
32 | def register_parameter(
33 | self, param: inspect.Parameter
34 | ) -> SupportsExtractor:
35 | # get the first paramater to Annotated, which should be our actual type
36 | model = next(iter(get_args(param.annotation)))
37 | if not issubclass(model, BaseModel):
38 | # You may want more rigourous checks here
39 | # Or you may want to accept non-Pydantic models
40 | # We do the easiest thing here
41 | raise TypeError(
42 | "MessagePack model must be a Pydantic model"
43 | )
44 | return Extractor(model)
45 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | exclude: "^.venv/.*|.html"
2 | repos:
3 | - repo: https://github.com/pre-commit/pre-commit-hooks
4 | rev: "v4.0.1"
5 | hooks:
6 | - id: trailing-whitespace
7 | - id: check-yaml
8 | - id: pretty-format-json
9 | args: ["--autofix"]
10 | - id: check-merge-conflict
11 | - repo: local
12 | hooks:
13 | - id: isort-source
14 | name: isort-source
15 | language: system
16 | entry: poetry run isort ./xpresso
17 | types: [python]
18 | pass_filenames: false
19 | - id: isort-tests
20 | name: isort-tests
21 | language: system
22 | entry: poetry run isort ./tests
23 | types: [python]
24 | pass_filenames: false
25 | - id: isort-docs
26 | name: isort-docs
27 | language: system
28 | entry: poetry run isort -l 70 ./docs_src
29 | types: [python]
30 | pass_filenames: false
31 | - id: blacken-source
32 | name: blacken-source
33 | language: system
34 | entry: poetry run black ./xpresso
35 | types: [python]
36 | pass_filenames: false
37 | - id: blacken-tests
38 | name: blacken-tests
39 | language: system
40 | entry: poetry run black ./tests
41 | types: [python]
42 | pass_filenames: false
43 | - id: blacken-docs
44 | name: blacken-docs
45 | language: system
46 | entry: poetry run black -l 70 ./docs_src
47 | types: [python]
48 | pass_filenames: false
49 | - id: flake8
50 | name: flake8
51 | language: system
52 | entry: poetry run flake8
53 | types: [python]
54 | - id: mypy
55 | name: mypy
56 | language: system
57 | entry: poetry run mypy
58 | types: [python]
59 | pass_filenames: false
60 |
--------------------------------------------------------------------------------
/xpresso/binders/_binders/media_type_validator.py:
--------------------------------------------------------------------------------
1 | import fnmatch
2 | import re
3 | import typing
4 |
5 | from pydantic.error_wrappers import ErrorWrapper
6 | from starlette import status
7 |
8 | from xpresso.exceptions import RequestValidationError
9 |
10 |
11 | class MediaTypeValidator:
12 | __slots__ = ("accepted",)
13 |
14 | def __init__(self, media_type: typing.Optional[str]) -> None:
15 | if media_type is None:
16 | self.accepted = None
17 | else:
18 | self.accepted = [
19 | re.compile(fnmatch.translate(p)) for p in media_type.lower().split(",")
20 | ]
21 |
22 | def validate(
23 | self,
24 | media_type: typing.Optional[str],
25 | ) -> None:
26 | if self.accepted is None:
27 | return
28 | if media_type is None:
29 | raise RequestValidationError(
30 | errors=[
31 | ErrorWrapper(
32 | ValueError("Media type missing in content-type header"),
33 | loc=("headers", "content-type"),
34 | )
35 | ],
36 | status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
37 | )
38 | media_type = next(iter(media_type.split(";"))).lower()
39 | for accepted in self.accepted:
40 | if accepted.match(media_type):
41 | return
42 | raise RequestValidationError(
43 | errors=[
44 | ErrorWrapper(
45 | ValueError(f"Media type {media_type} is not supported"),
46 | loc=("headers", "content-type"),
47 | )
48 | ],
49 | status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
50 | )
51 |
--------------------------------------------------------------------------------
/docs_src/tutorial/middleware/tutorial_001.py:
--------------------------------------------------------------------------------
1 | from typing import Sequence
2 |
3 | from pydantic import BaseModel, BaseSettings
4 |
5 | from xpresso import App, Path, Router
6 | from xpresso.middleware import Middleware
7 | from xpresso.middleware.cors import CORSMiddleware
8 | from xpresso.routing.mount import Mount
9 |
10 |
11 | class AppHealth(BaseModel):
12 | okay: bool
13 |
14 |
15 | async def healthcheck() -> AppHealth:
16 | return AppHealth(okay=True)
17 |
18 |
19 | class Greeting(BaseModel):
20 | message: str
21 |
22 |
23 | async def greet_user() -> Greeting:
24 | return Greeting(message="Hello!")
25 |
26 |
27 | class AppConfig(BaseSettings):
28 | cors_origins: Sequence[str]
29 |
30 |
31 | def create_app(config: AppConfig) -> App:
32 | v1_router = Router(
33 | routes=[Path("/landing", get=greet_user)],
34 | middleware=[
35 | Middleware(
36 | CORSMiddleware,
37 | allow_origins=config.cors_origins,
38 | allow_credentials=True,
39 | allow_methods=["*"],
40 | allow_headers=["*"],
41 | )
42 | ],
43 | )
44 |
45 | return App(
46 | routes=[
47 | Mount(
48 | "/v1",
49 | app=v1_router,
50 | ),
51 | Path(
52 | "/health",
53 | get=healthcheck,
54 | ),
55 | ],
56 | )
57 |
58 |
59 | def create_production_app() -> App:
60 | config = AppConfig() # loaded from env variables
61 | return create_app(config)
62 |
63 |
64 | def create_debug_app() -> App:
65 | origins = ("http://localhost:8000", "http://localhost:5000")
66 | config = AppConfig(cors_origins=origins)
67 | return create_app(config)
68 |
--------------------------------------------------------------------------------
/docs/advanced/dependencies/responses.md:
--------------------------------------------------------------------------------
1 | # Accessing Responses from Dependencies
2 |
3 | Xpresso gives you the ability to access and even modify responses from within dependencies.
4 | You will be able to:
5 |
6 | - Get a reference to the response returned by the endpoint function
7 | - Modify that response in place
8 | - Replace that response with a completely different response object
9 |
10 | This functionality is enabled through **response proxies**:
11 |
12 | - `xpresso.responses.get_response(request: Request) -> Response`
13 | - `xpresso.responses.set_response(request: Request, response: Response) -> None`
14 |
15 | These functions can only be called from within the teardown of a dependency.
16 | If called from anywhere else (inside the endpoint or in the setup of a context manager dependency) they will raise an exception.
17 | Further, modifying the response or calling `set_response()` will only work from a dependency in the `"endpoint"` scope (otherwise the response has already been sent).
18 |
19 | ## Reading responses
20 |
21 | Here is an example of a dependency that logs the status code for every response on a path:
22 |
23 | ```python hl_lines="18-29"
24 | --8<-- "docs_src/advanced/dependencies/tutorial_004.py"
25 | ```
26 |
27 | If your dependency has the `"connection"` scope (like in the example above) you will be able to get a copy of the request, but attempting to modify it or replace it will have no result since it was already sent to the client.
28 | The main advantage of using the `"connection"` scope is reduced latency for the client.
29 |
30 | ## Writing responses
31 |
32 | If you need to modify the response, use the `"endpoint"` scope.
33 | Here's an example of a simple request/context tracing system:
34 |
35 | ```python hl_lines="17-30"
36 | --8<-- "docs_src/advanced/dependencies/tutorial_005.py"
37 | ```
38 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: install-poetry .clean test test-mutation docs-build docs-serve
2 |
3 | GIT_SHA = $(shell git rev-parse --short HEAD)
4 | PACKAGE_VERSION = $(shell poetry version -s | cut -d+ -f1)
5 |
6 | .install-poetry:
7 | @echo "---- 👷 Installing build dependencies ----"
8 | deactivate > /dev/null 2>&1 || true
9 | poetry -V || pip install -U poetry
10 | touch .install-poetry
11 |
12 | install-poetry: .install-poetry
13 |
14 | .init: .install-poetry
15 | @echo "---- 📦 Building package ----"
16 | rm -rf .venv
17 | poetry install
18 | git init .
19 | poetry run pre-commit install --install-hooks
20 | touch .init
21 |
22 | .clean:
23 | rm -rf .init .mypy_cache .pytest_cache
24 | poetry -V || rm -rf .install-poetry
25 |
26 | init: .clean .init
27 | @echo ---- 🔧 Re-initialized project ----
28 |
29 | lint: .init
30 | @echo ---- ⏳ Running linters ----
31 | @(poetry run pre-commit run --all-files && echo "---- ✅ Linting passed ----" && exit 0|| echo "---- ❌ Linting failed ----" && exit 1)
32 |
33 | test: .init
34 | @echo ---- ⏳ Running tests ----
35 | @(poetry run pytest -v --cov --cov-report term && echo "---- ✅ Tests passed ----" && exit 0 || echo "---- ❌ Tests failed ----" && exit 1)
36 |
37 | test-mutation: .init
38 | @echo ---- ⏳ Running mutation testing ----
39 | @poetry run python -m pip install mutmut
40 | @(poetry run pytest --cov && poetry run mutmut run --use-coverage && echo "---- ✅ Passed ----" && exit 0 || echo "---- ❌ Failed ----" && exit 1)
41 |
42 | docs-serve: .init
43 | @echo ---- 📝 Serving docs ----
44 | @poetry run mkdocs serve --dev-addr localhost:8001
45 |
46 | docs-deploy: .init
47 | @echo ---- 🚀 Deploying docs ----
48 | @(poetry run mike deploy --push --update-aliases --branch gh-docs $(shell poetry version -s) latest && echo "---- ✅ Deploy succeeded ----" && exit 0 || echo "---- ❌ Deploy failed ----" && exit 1)
49 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/middleware/test_tutorial_001.py:
--------------------------------------------------------------------------------
1 | from docs_src.tutorial.middleware.tutorial_001 import AppConfig, create_app
2 | from xpresso.testclient import TestClient
3 |
4 |
5 | def test_cors_middleware() -> None:
6 | config = AppConfig(cors_origins=["https://frontend.example.com"])
7 |
8 | client = TestClient(create_app(config=config))
9 |
10 | # Test pre-flight response
11 | headers = {
12 | "Origin": "https://frontend.example.com",
13 | "Access-Control-Request-Method": "GET",
14 | "Access-Control-Request-Headers": "X-Example",
15 | }
16 | response = client.options("/v1/landing", headers=headers)
17 | assert response.status_code == 200, response.text
18 | assert response.text == "OK"
19 | assert (
20 | response.headers["access-control-allow-origin"]
21 | == "https://frontend.example.com"
22 | )
23 | assert response.headers["access-control-allow-headers"] == "X-Example"
24 |
25 | # Test standard response for /v1/landing
26 | headers = {"Origin": "https://frontend.example.com"}
27 | response = client.get("/v1/landing", headers=headers)
28 | assert response.status_code == 200, response.text
29 | assert response.json() == {"message": "Hello!"}
30 | assert (
31 | response.headers["access-control-allow-origin"]
32 | == "https://frontend.example.com"
33 | )
34 |
35 | # Test non-CORS response for /v1/landing
36 | response = client.get("/v1/landing")
37 | assert response.status_code == 200, response.text
38 | assert response.json() == {"message": "Hello!"}
39 | assert "access-control-allow-origin" not in response.headers
40 |
41 | # Test non-CORS response for /health
42 | response = client.get("/health")
43 | assert response.status_code == 200, response.text
44 | assert response.json() == {"okay": True}
45 |
--------------------------------------------------------------------------------
/tests/test_docs/advanced/test_websockets.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from docs_src.advanced.websockets import app
4 | from xpresso.exceptions import WebSocketValidationError
5 | from xpresso.testclient import TestClient
6 | from xpresso.websockets import WebSocketDisconnect
7 |
8 |
9 | def test_websockets_missing_header() -> None:
10 | client = TestClient(app)
11 |
12 | with pytest.raises(WebSocketValidationError) as err:
13 | with client.websocket_connect("/ws"):
14 | pass
15 |
16 | assert isinstance(err.value, WebSocketValidationError)
17 | assert err.value.errors() == [{'loc': ('header', 'x-header'), 'msg': 'Missing required header parameter', 'type': 'value_error'}]
18 |
19 |
20 | def test_websockets_unprocessable_header() -> None:
21 | client = TestClient(app)
22 |
23 | with pytest.raises(WebSocketValidationError) as err:
24 | with client.websocket_connect("/ws", headers={"X-Header": "not a number"}):
25 | pass
26 |
27 | assert isinstance(err.value, WebSocketValidationError)
28 | assert err.value.errors() == [{'loc': ('header', 'x-header'), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'}]
29 |
30 |
31 | def test_websockets_exception_in_user_dependency() -> None:
32 | client = TestClient(app)
33 | try:
34 | with client.websocket_connect("/ws", headers={"X-Header": "-1"}):
35 | raise AssertionError("Should not be called") # pragma: no cover
36 | except WebSocketDisconnect:
37 | pass
38 | else:
39 | raise AssertionError(
40 | "Expected a WebSocketDisconnect to be raised"
41 | ) # pragma: no cover
42 |
43 |
44 | def test_websockets_valid_header() -> None:
45 | client = TestClient(app)
46 | with client.websocket_connect("/ws", headers={"X-Header": "123"}) as websocket:
47 | data = websocket.receive_text()
48 | assert data == "123"
49 |
--------------------------------------------------------------------------------
/docs/tutorial/files.md:
--------------------------------------------------------------------------------
1 | # Files
2 |
3 | You can read the raw request body directly into a file or bytes.
4 | This will read the data from the top level request body, and can only support 1 file.
5 | To receive multiple files, see the [multipart/form-data documentation].
6 |
7 | ```python
8 | --8<-- "docs_src/tutorial/files/tutorial_001.py"
9 | ```
10 |
11 | !!! note "Note"
12 | `UploadFile` is a class provided by Starlette that buffers the data in memory and overflows to disk if the data is larger than a predefined threshold.
13 | This prevents a large file from exhausting your hardware's memory, but does use disk space and CPU cycles for buffering.
14 |
15 | !!! note "Implementation detail"
16 | `xpresso.UploadFile` is just a thin wrapper around `starlette.datastructures.UploadFile`, but you must use `xpresso.UploadFile` instead of `starlette.datastructures.UploadFile` directly, otherwise Xpresso won't know how to build the argument.
17 |
18 | ## As bytes
19 |
20 | Xpresso can read the entire file into memory if you'd like:
21 |
22 | ```python
23 | --8<-- "docs_src/tutorial/files/tutorial_002.py"
24 | ```
25 |
26 | This can be convenient if you know the files are not large.
27 |
28 | ## As a stream
29 |
30 | If you want to read the bytes without buffering to disk or memory, use `AsyncIterator[bytes]` as the type:
31 |
32 | ```python
33 | --8<-- "docs_src/tutorial/files/tutorial_003.py"
34 | ```
35 |
36 | ## Setting the expected content-type
37 |
38 | You can set the media type via the `media_type` parameter to `RawBody()` and enforce it via the `enforce_media_type` parameter:
39 |
40 | ```python
41 | --8<-- "docs_src/tutorial/files/tutorial_004.py"
42 | ```
43 |
44 | Media types can be a media type (e.g. `image/png`) or a media type range (e.g. `image/*`).
45 |
46 | If you do not explicitly set the media type, all media types are accepted.
47 | Once you set an explicit media type, that media type in the requests' `Content-Type` header will be validated on incoming requests, but this behavior can be disabled via the `enforce_media_type` parameter to `RawBody()`.
48 |
49 | [multipart/form-data documentation]: forms.md#multipart-requests
50 |
--------------------------------------------------------------------------------
/docs/tutorial/dependencies/nested.md:
--------------------------------------------------------------------------------
1 | # Nested dependencies
2 |
3 | Dependencies can have sub-dependencies, which in turn can have more sub-dependencies, creating a nested structure of dependencies.
4 | Xpresso supports arbitrarily deep nesting of dependencies and will organize them so that each dependency only gets executed once all of its sub-dependencies have already been executed.
5 |
6 | !!! tip "Tip"
7 | The technical term for this sort of structure is a [Directed Acyclic Graph] (DAG for short).
8 | But don't worry, you don't need to understand graph theory to use nested dependencies.
9 |
10 | To build nested dependencies, just create a dependency that depends on another dependency.
11 | Continuing with our example of `httpx.AsyncClient`, we can create a dependency that holds the configuration for the client, namely the `base_url` for HTTPBin.
12 |
13 | We'll start by declaring a Pydantic model using [Pydantic's config management system]:
14 |
15 | ```python hl_lines="2 8-12"
16 | --8<-- "docs_src/tutorial/dependencies/tutorial_003.py"
17 | ```
18 |
19 | `pydantic.BaseSettings` subclasses are actually a great example of things that cannot be auto-wired by the dependency injection system (in this case, it is for various technical reasons that are not relevant to this tutorial). But we can easily tell the dependency injection system to just build the class with no parameters by default:
20 |
21 | ```python hl_lines="16"
22 | --8<-- "docs_src/tutorial/dependencies/tutorial_003.py"
23 | ```
24 |
25 | The last thing we need to do is add the dependency to `get_client()` and use the config inside of `get_client()`:
26 |
27 | ```python hl_lines="20-21"
28 | --8<-- "docs_src/tutorial/dependencies/tutorial_003.py"
29 | ```
30 |
31 | Now the application will behave exactly the same as before except that you can override the URL used for HTTPBin with the `HTTPBIN_URL` environment variable. For example, try setting it `HTTPBIN_URL=http://httpbin.org` (`http` instead of `https`).
32 |
33 | [Directed Acyclic Graph]: https://en.wikipedia.org/wiki/Directed_acyclic_graph
34 | [Pydantic's config management system]: https://pydantic-docs.helpmanual.io/usage/settings/
35 |
--------------------------------------------------------------------------------
/xpresso/_utils/pydantic_utils.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import typing
3 | from enum import Enum
4 |
5 | from pydantic import BaseConfig, BaseModel
6 | from pydantic.fields import (
7 | MAPPING_LIKE_SHAPES,
8 | SHAPE_FROZENSET,
9 | SHAPE_LIST,
10 | SHAPE_SEQUENCE,
11 | SHAPE_SET,
12 | SHAPE_TUPLE,
13 | SHAPE_TUPLE_ELLIPSIS,
14 | ModelField,
15 | )
16 | from pydantic.schema import TypeModelOrEnum
17 |
18 | T = typing.TypeVar("T")
19 |
20 |
21 | def model_field_from_param(
22 | param: inspect.Parameter,
23 | alias: typing.Optional[str] = None,
24 | arbitrary_types_allowed: bool = False,
25 | ) -> ModelField:
26 |
27 | Config = BaseConfig
28 | if arbitrary_types_allowed:
29 |
30 | class _Config(BaseConfig):
31 | arbitrary_types_allowed = True
32 |
33 | Config = _Config
34 |
35 | return ModelField.infer(
36 | name=alias or param.name,
37 | value=param.default if param.default is not param.empty else ...,
38 | annotation=param.annotation,
39 | class_validators={},
40 | config=Config,
41 | )
42 |
43 |
44 | def filter_pydantic_models_from_set(
45 | s: typing.AbstractSet[typing.Any],
46 | ) -> typing.Set[TypeModelOrEnum]:
47 | def f(x: typing.Any) -> bool:
48 | return inspect.isclass(x) and issubclass(x, (BaseModel, Enum))
49 |
50 | return set(filter(f, s))
51 |
52 |
53 | def filter_pydantic_models_from_mapping(
54 | m: typing.Mapping[typing.Any, T]
55 | ) -> typing.Dict[TypeModelOrEnum, T]:
56 | keys = filter_pydantic_models_from_set(m.keys())
57 | return {k: m[k] for k in keys}
58 |
59 |
60 | def is_sequence_like(field: ModelField) -> bool:
61 | return field.shape in (
62 | SHAPE_TUPLE,
63 | SHAPE_TUPLE_ELLIPSIS,
64 | SHAPE_LIST,
65 | SHAPE_SET,
66 | SHAPE_FROZENSET,
67 | SHAPE_LIST,
68 | SHAPE_SEQUENCE,
69 | )
70 |
71 |
72 | def is_mapping_like(field: ModelField) -> bool:
73 | return (
74 | field.shape in MAPPING_LIKE_SHAPES
75 | or inspect.isclass(field.type_)
76 | and issubclass(field.type_, BaseModel)
77 | )
78 |
--------------------------------------------------------------------------------
/xpresso/openapi/_html.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import Any, Dict, Optional
3 |
4 | from starlette.responses import HTMLResponse
5 |
6 | from xpresso.encoders import Encoder, JsonableEncoder
7 |
8 |
9 | def get_swagger_ui_html(
10 | *,
11 | openapi_url: str,
12 | title: str,
13 | swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@4/swagger-ui-bundle.js",
14 | swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@4/swagger-ui.css",
15 | swagger_favicon_url: Optional[str] = None,
16 | oauth2_redirect_url: Optional[str] = None,
17 | init_oauth: Optional[Dict[str, Any]] = None,
18 | encoder: Encoder = JsonableEncoder(),
19 | ) -> HTMLResponse:
20 | if swagger_favicon_url:
21 | swagger_favicon_html = (
22 | f"""\n"""
23 | )
24 | else:
25 | swagger_favicon_html = ""
26 |
27 | html = f"""
28 |
29 |
30 |
31 | {swagger_favicon_html}
32 | {title}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
66 |
67 |
68 | """
69 | return HTMLResponse(html)
70 |
--------------------------------------------------------------------------------
/tests/test_dependencies/test_contextvars.py:
--------------------------------------------------------------------------------
1 | from contextvars import ContextVar
2 | from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Optional
3 |
4 | from starlette.middleware.base import BaseHTTPMiddleware
5 |
6 | from xpresso import App, Depends, Operation, Path, Request, Response
7 | from xpresso.middleware import Middleware
8 | from xpresso.testclient import TestClient
9 |
10 | legacy_request_state_context_var: ContextVar[Optional[Dict[str, Any]]] = ContextVar(
11 | "legacy_request_state_context_var", default=None
12 | )
13 |
14 |
15 | async def set_up_request_state_dependency() -> AsyncIterator[Dict[str, Any]]:
16 | request_state = {"user": "deadpond"}
17 | contextvar_token = legacy_request_state_context_var.set(request_state)
18 | yield request_state
19 | legacy_request_state_context_var.reset(contextvar_token)
20 |
21 |
22 | async def custom_middleware(
23 | request: Request, call_next: Callable[[Request], Awaitable[Response]]
24 | ):
25 | response = await call_next(request)
26 | response.headers["custom"] = "foo"
27 | return response
28 |
29 |
30 | def get_user():
31 | request_state = legacy_request_state_context_var.get()
32 | assert request_state
33 | return request_state["user"]
34 |
35 |
36 | app = App(
37 | routes=[
38 | Path(
39 | "/user",
40 | get=Operation(
41 | get_user, dependencies=[Depends(set_up_request_state_dependency)]
42 | ),
43 | )
44 | ],
45 | middleware=[Middleware(BaseHTTPMiddleware, dispatch=custom_middleware)],
46 | )
47 |
48 |
49 | client = TestClient(app)
50 |
51 |
52 | def test_dependency_contextvars():
53 | """
54 | Check that custom middlewares don't affect the contextvar context for dependencies.
55 |
56 | The code before yield and the code after yield should be run in the same contextvar
57 | context, so that request_state_context_var.reset(contextvar_token).
58 |
59 | If they are run in a different context, that raises an error.
60 | """
61 | response = client.get("/user")
62 | assert response.json() == "deadpond"
63 | assert response.headers["custom"] == "foo"
64 |
--------------------------------------------------------------------------------
/benchmarks/utils.py:
--------------------------------------------------------------------------------
1 | import time # noqa: F401
2 | from random import Random
3 | from typing import Any, Callable, Dict, Mapping, Tuple
4 |
5 | import anyio # noqa: F401
6 |
7 | random = Random(0)
8 |
9 |
10 | def generate_dag(
11 | make_depends: Callable[[str, str], str],
12 | glbls: Mapping[str, Any],
13 | levels: int,
14 | nodes_per_level: int,
15 | dependencies_per_node: int,
16 | *,
17 | sync: bool = False,
18 | sleep: Tuple[float, float] = (0, 0),
19 | ) -> Tuple[int, Callable[..., int]]:
20 | """Build a complex DAG of async dependencies"""
21 | sleep_func = time.sleep if sync else anyio.sleep
22 |
23 | template = (
24 | "def func_{}({}): sleep({});return 1"
25 | if sync
26 | else "async def func_{}({}): await sleep({});return 1"
27 | )
28 | globals = {**glbls, "sleep": sleep_func}
29 | total = 0
30 |
31 | funcs: Dict[str, Callable[..., Any]] = {}
32 | for level in range(levels):
33 | level_funcs: Dict[str, Callable[..., Any]] = funcs.copy()
34 | for node in range(nodes_per_level):
35 | total += 1
36 | name = f"{level}_{node}"
37 | # use funcs and not level_funcs here to make sure we get some concurrency
38 | deps = random.sample(
39 | list(funcs.keys()),
40 | k=min(len(funcs), dependencies_per_node),
41 | )
42 | params = ", ".join(
43 | [
44 | f"dep_{dep_name}: {make_depends('None', dep_name)}"
45 | for dep_name in deps
46 | ]
47 | )
48 | sleep_time = random.uniform(sleep[0], sleep[1])
49 | func_def = template.format(name, params, sleep_time)
50 | exec(func_def, globals, level_funcs)
51 | funcs.update(level_funcs)
52 | name = "final"
53 | deps = list(funcs.keys())
54 | params = ", ".join(
55 | [
56 | f"dep_{dep_name}: {make_depends('None', dep_name)}"
57 | for dep_name in deps
58 | ]
59 | )
60 | total += 1
61 | func_def = template.format(name, params, 0)
62 | exec(func_def, globals, funcs)
63 | return total, funcs["func_final"]
64 |
--------------------------------------------------------------------------------
/tests/test_dependencies/test_overrides.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 |
3 | from di.dependent import Marker
4 |
5 | from xpresso import App, Depends, Path
6 | from xpresso.dependencies import Injectable
7 | from xpresso.testclient import TestClient
8 | from xpresso.typing import Annotated
9 |
10 |
11 | def test_override_with_marker() -> None:
12 | def dep() -> int:
13 | ...
14 |
15 | async def endpoint(v: Annotated[int, Depends(dep)]) -> int:
16 | return v
17 |
18 | app = App([Path("/", get=endpoint)])
19 |
20 | app.dependency_overrides[dep] = lambda: 2
21 |
22 | client = TestClient(app)
23 |
24 | resp = client.get("/")
25 | assert resp.status_code == 200, resp.content
26 | assert resp.json() == 2
27 |
28 |
29 | def test_override_with_non_xpresso_marker() -> None:
30 | def dep() -> int:
31 | ...
32 |
33 | async def endpoint(v: Annotated[int, Marker(dep, scope="endpoint")]) -> int:
34 | return v
35 |
36 | app = App([Path("/", get=endpoint)])
37 |
38 | app.dependency_overrides[dep] = lambda: 2
39 |
40 | client = TestClient(app)
41 |
42 | resp = client.get("/")
43 | assert resp.status_code == 200, resp.content
44 | assert resp.json() == 2
45 |
46 |
47 | def test_override_match_by_annotation() -> None:
48 | @dataclass
49 | class Foo:
50 | bar: str = "bar"
51 |
52 | async def endpoint(foo: Foo) -> str:
53 | return foo.bar
54 |
55 | app = App([Path("/", get=endpoint)])
56 |
57 | app.dependency_overrides[Foo] = lambda: Foo(bar="baz")
58 |
59 | client = TestClient(app)
60 |
61 | resp = client.get("/")
62 | assert resp.status_code == 200, resp.content
63 | assert resp.json() == "baz"
64 |
65 |
66 | def test_override_injectable_cls() -> None:
67 | @dataclass
68 | class Foo(Injectable):
69 | bar: str = "bar"
70 |
71 | async def endpoint(foo: Foo) -> str:
72 | return foo.bar
73 |
74 | app = App([Path("/", get=endpoint)])
75 |
76 | app.dependency_overrides[Foo] = lambda: Foo(bar="baz")
77 |
78 | client = TestClient(app)
79 |
80 | resp = client.get("/")
81 | assert resp.status_code == 200, resp.content
82 | assert resp.json() == "baz"
83 |
--------------------------------------------------------------------------------
/xpresso/middleware/exceptions.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from starlette.concurrency import run_in_threadpool
4 | from starlette.exceptions import HTTPException
5 | from starlette.middleware.exceptions import (
6 | ExceptionMiddleware as StarletteExceptionMiddleware,
7 | )
8 | from starlette.requests import Request
9 | from starlette.types import Message, Receive, Scope, Send
10 |
11 | from xpresso._utils.asgi import XpressoHTTPExtension
12 |
13 |
14 | class ExceptionMiddleware(StarletteExceptionMiddleware):
15 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
16 | if scope["type"] != "http":
17 | await self.app(scope, receive, send) # type: ignore
18 | return
19 |
20 | response_started = False
21 |
22 | async def sender(message: Message) -> None:
23 | nonlocal response_started
24 |
25 | if message["type"] == "http.response.start":
26 | response_started = True
27 | await send(message)
28 |
29 | try:
30 | await self.app(scope, receive, sender) # type: ignore
31 | except Exception as exc:
32 | handler = None
33 |
34 | if isinstance(exc, HTTPException):
35 | handler = self._status_handlers.get(exc.status_code) # type: ignore
36 |
37 | if handler is None:
38 | handler = self._lookup_exception_handler(exc) # type: ignore
39 |
40 | if handler is None:
41 | raise exc
42 |
43 | if response_started:
44 | msg = "Caught handled exception, but response already started."
45 | raise RuntimeError(msg) from exc
46 |
47 | request = Request(scope, receive=receive)
48 | if asyncio.iscoroutinefunction(handler): # type: ignore
49 | response = await handler(request, exc) # type: ignore
50 | else:
51 | response = await run_in_threadpool(handler, request, exc) # type: ignore
52 | extension: XpressoHTTPExtension = scope["extensions"]["xpresso"]
53 | extension.response = response
54 | await response(scope, receive, sender)
55 | extension.response_sent = True
56 |
--------------------------------------------------------------------------------
/xpresso/__init__.py:
--------------------------------------------------------------------------------
1 | from starlette import status as status
2 | from starlette.exceptions import HTTPException
3 | from starlette.responses import Response
4 |
5 | from xpresso.applications import App
6 | from xpresso.bodies import (
7 | RawBody, # backwards compatibility aliases; TODO: remove in a couple of releases
8 | )
9 | from xpresso.bodies import (
10 | BodyUnion,
11 | Form,
12 | FormField,
13 | FormFile,
14 | FromBodyUnion,
15 | FromFormData,
16 | FromFormField,
17 | FromFormFile,
18 | FromJson,
19 | FromMultipart,
20 | )
21 | from xpresso.bodies import FromRawBody
22 | from xpresso.bodies import FromRawBody as FromFile
23 | from xpresso.bodies import Json, Multipart
24 | from xpresso.datastructures import UploadFile
25 | from xpresso.dependencies import Depends
26 | from xpresso.exception_handlers import ExcHandler
27 | from xpresso.parameters import (
28 | CookieParam,
29 | FromCookie,
30 | FromHeader,
31 | FromPath,
32 | FromQuery,
33 | HeaderParam,
34 | PathParam,
35 | QueryParam,
36 | )
37 | from xpresso.requests import Request
38 | from xpresso.routing.operation import Operation
39 | from xpresso.routing.pathitem import Path
40 | from xpresso.routing.router import Router
41 | from xpresso.routing.websockets import WebSocketRoute
42 | from xpresso.websockets import WebSocket
43 |
44 | __all__ = (
45 | "ExcHandler",
46 | "Operation",
47 | "Path",
48 | "QueryParam",
49 | "HeaderParam",
50 | "CookieParam",
51 | "PathParam",
52 | "Json",
53 | "FormFile",
54 | "FromFormFile",
55 | "Form",
56 | "RawBody",
57 | "Multipart",
58 | "Depends",
59 | "App",
60 | "Router",
61 | "UploadFile",
62 | "FromBodyUnion",
63 | "BodyUnion",
64 | "FromCookie",
65 | "FromMultipart",
66 | "FromFormField",
67 | "FromFormData",
68 | "FromHeader",
69 | "FromJson",
70 | "FormField",
71 | "FromPath",
72 | "FromQuery",
73 | "HTTPException",
74 | "FromRawBody",
75 | "status",
76 | "Request",
77 | "Response",
78 | "WebSocketRoute",
79 | "WebSocket",
80 | # backwards compatibility aliases
81 | # TODO: remove in a couple of releases
82 | "File",
83 | "FromFile",
84 | )
85 |
--------------------------------------------------------------------------------
/docs_src/tutorial/routing/tutorial_002.py:
--------------------------------------------------------------------------------
1 | from typing import List, Mapping
2 |
3 | from pydantic import BaseModel
4 |
5 | from xpresso import App, FromJson, Operation, Path, Response, Router
6 | from xpresso.openapi.models import Server
7 | from xpresso.responses import ResponseSpec
8 | from xpresso.routing.mount import Mount
9 |
10 |
11 | class Item(BaseModel):
12 | name: str
13 | price: float
14 |
15 |
16 | fake_items_db = {
17 | "chair": Item(name="chair", price=30.29),
18 | "hammer": Item(name="hammer", price=1.99),
19 | }
20 |
21 |
22 | async def get_items() -> Mapping[str, Item]:
23 | """Docstring will be ignored"""
24 | return fake_items_db
25 |
26 |
27 | async def create_item(item: FromJson[Item]) -> Response:
28 | """Documentation from docstrings!
29 | You can use any valid markdown, for example lists:
30 |
31 | - Point 1
32 | - Point 2
33 | """
34 | fake_items_db[item.name] = item
35 | return Response(status_code=204)
36 |
37 |
38 | async def delete_items(items_to_delete: FromJson[List[str]]) -> None:
39 | for item_name in items_to_delete:
40 | fake_items_db.pop(item_name, None)
41 |
42 |
43 | items = Path(
44 | "/items",
45 | get=Operation(
46 | get_items,
47 | description="The **items** operation",
48 | summary="List all items",
49 | deprecated=True,
50 | tags=["read"],
51 | ),
52 | post=Operation(
53 | create_item,
54 | responses={204: ResponseSpec(description="Success")},
55 | servers=[
56 | Server(url="https://us-east-1.example.com"),
57 | Server(url="http://127.0.0.1:8000"),
58 | ],
59 | tags=["write"],
60 | ),
61 | delete=Operation(
62 | delete_items,
63 | include_in_schema=False,
64 | ),
65 | include_in_schema=True, # the default
66 | servers=[Server(url="http://127.0.0.1:8000")],
67 | tags=["items"],
68 | )
69 |
70 | app = App(
71 | routes=[
72 | Mount(
73 | path="/v1",
74 | app=Router(
75 | routes=[items],
76 | responses={
77 | 404: ResponseSpec(description="Item not found")
78 | },
79 | tags=["v1"],
80 | ),
81 | )
82 | ]
83 | )
84 |
--------------------------------------------------------------------------------
/docs/tutorial/dependencies/lifecycle.md:
--------------------------------------------------------------------------------
1 | # Dependency Lifecycle
2 |
3 | Up until now we've only seen dependencies that return a value directly.
4 | But often you'll want to do some work (like creating a database connection), **yield** that thing (the connection object) and then do some more work to **teardown** that thing (for example closing the connection).
5 |
6 | Xpresso lets you declare this type of execution using **context manager dependencies**.
7 |
8 | These are dependencies that use the **yield** keyword **once** to give back control and then wait until they get back control to run their **teardown**.
9 |
10 | !!! note "Note"
11 | Any function `func()` that could be passed to [@contextlib.contextmanager] or [@contextlib.asynccontextmanager] will work.
12 |
13 | We can apply this concept to our `httpx.AsyncClient` example to clean up the client after we are done using it.
14 | All we have to do is change our function to be a context manager like function (an async one in this case) and then use `httpx.AsyncClient`'s context manager within the function:
15 |
16 | ```python hl_lines="22-26"
17 | --8<-- "docs_src/tutorial/dependencies/tutorial_004.py"
18 | ```
19 |
20 | !!! check
21 | Did you notice that we also converted `get_client()` from a `def` function to an `async def` function?
22 | Making changes like this is super easy using Xpresso's dependency injection system!
23 | It decouples you from execution so that you can mix and match sync and async dependencies without worrying about `await`ing from a sync dependency and other complexities of cooperative concurrency.
24 |
25 | !!! tip "Tip"
26 | It is always best to use `httpx.AsyncClient` as a context manager to ensure that connections get cleaned up.
27 | Otherwise, httpx will give you a warning which you'd see in your logs.
28 |
29 | Once again, nothing will change from the application user's perspective, but our backend is now a lot more resilient!
30 |
31 | The order of execution here is `get_client() -> echo_headers() -> get_client()` and is roughly equivalent to:
32 |
33 | ```python
34 | async with asynccontextmanager(get_client(HttpBinConfig())) as client:
35 | await echo_headers(client)
36 | ```
37 |
38 | [@contextlib.contextmanager]: https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager
39 | [@contextlib.asynccontextmanager]: https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager
40 |
--------------------------------------------------------------------------------
/docs/advanced/responses.md:
--------------------------------------------------------------------------------
1 | # Documenting OpenAPI responses
2 |
3 | So far we have mostly just let Xpresso _infer_ the response model from type annotation.
4 | By default, Xpresso assumes the response is JSON serializable type and uses `"application/json"` with an HTTP 200 status code.
5 |
6 | You can also declare other responses or override the default response's status code, media type or schema.
7 |
8 | ## Adding an error response
9 |
10 | We can document a 404 response as follows:
11 |
12 | ```python
13 | --8<-- "docs_src/advanced/responses/tutorial_001.py"
14 | ```
15 |
16 | !!! warning Warning
17 | Notice that when you are returning a non-default status code, you **must** return an actual `Response`, not an arbitrary JSON serializable object.
18 |
19 | ## Adding media types to the default response
20 |
21 | ```python
22 | --8<-- "docs_src/advanced/responses/tutorial_002.py"
23 | ```
24 |
25 | !!! tip Tip
26 | We could have also specified the return type for the `200` status code via the `default_response_model` parameter to `Operation`.
27 | Either way, we had to specify it because our function returns `Any` (it could return `Union[dict, Response]`, the situation is the same) so Xpresso can't infer the right response model from type annotations.
28 |
29 | ## Changing the media type for the default response
30 |
31 | ```python
32 | --8<-- "docs_src/advanced/responses/tutorial_003.py"
33 | ```
34 |
35 | !!! warning Warning
36 | Just changing the media type does not change how the response gets encoded, so we also had to pass `response_encoder=None` to avoid encoding `bytes` as JSON!
37 |
38 | ## Changing the status code for the default response
39 |
40 | ```python
41 | --8<-- "docs_src/advanced/responses/tutorial_004.py"
42 | ```
43 |
44 | Changing the default response status code via `default_response_status_code` changes the runtime behavior of our application in addition to the OpenAPI documentation: our endpoint will now return HTTP 201 responses.
45 |
46 | ## Responses on Router and Path
47 |
48 | You can also set responses on Router and Path, which will get merged into any responses defined for Operation.
49 | Responses for Operation take precedence over those of Path, and Path takes precedence over Routers.
50 | Status codes, media types and headers are merged.
51 | Examples and response models are overridden.
52 |
53 | ```python
54 | --8<-- "docs_src/advanced/responses/tutorial_005.py"
55 | ```
56 |
--------------------------------------------------------------------------------
/docs/tutorial/dependencies/scopes.md:
--------------------------------------------------------------------------------
1 |
2 | # Scopes
3 |
4 | In our last tutorial with `httpx.AsyncClient` we left off at [dependency lifecycles].
5 | As you may have noticed, we are creating and tearing down the `httpx.AsyncClient` instance for each incoming request.
6 | This is very inefficient!
7 | Really what we want to do is create the `httpx.AsyncClient` once, when our application starts up, and then use the same instance for each request, only tearing down the client when our app shuts down.
8 |
9 | To achieve this, we need to introduce **scopes**.
10 | Scopes let you control the "lifetime" of your dependency and are inspired by [pytest]'s fixture system.
11 | In Pytest you may have used scopes like "session", "module" or "function".
12 | In Xpresso there are three scopes available:
13 |
14 | 1. `"endpoint"`: the dependency is created right before calling the endpoint function and torn down right after your function returns, but before the response is sent to the client.
15 | 1. `"connection"` (default): this scope is entered before the endpoint scope and before calling your endpoint function and is torn down right after the response is sent to the client.
16 | 1. `"app"`: the outermost scope. Dependencies in this scope are tied to the [lifespan] of the application.
17 |
18 | So for our use case, we'll be wanting to use the `"app"` scope for `httpx.AsyncClient`:
19 |
20 | ```python hl_lines="19 31"
21 | --8<-- "docs_src/tutorial/dependencies/tutorial_006.py"
22 | ```
23 |
24 | Everything else can stay the same, this is all we need!
25 |
26 | If you run this and navigate to [http://127.0.0.1:8000/echo/url](http://127.0.0.1:8000/echo/url) the response will be the same, but you will probably notice reduced latency if you refresh to make several requests.
27 |
28 | ## Scope inference
29 |
30 | In Pytest a `"session"` scoped fixture can't depend on a `"function"` scoped fixture, and in Xpresso an `"app"` scoped fixture can't depend on an `"endpoint"` scoped fixture.
31 |
32 | Unlike Pytest which forces you to hardcode all of the scopes, Xpresso is able to infer scopes from the dependency graph.
33 | So in this case it says "oh, I see that `get_client` depends on `HttpBinConfig` and `HttpBinConfig` was not explicitly assigned a scope; I'll give `HttpBinConfig` and `"app"` scope then so that it is compatible with `get_client`".
34 |
35 | [pytest]: https://docs.pytest.org/en/6.2.x/fixture.html
36 | [dependency lifecycles]: lifecycle.md
37 |
--------------------------------------------------------------------------------
/xpresso/_utils/overrides.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | import inspect
3 | import typing
4 | from types import TracebackType
5 |
6 | from di import Container
7 | from di.api.dependencies import DependentBase
8 | from di.api.providers import DependencyProvider
9 | from di.dependent import Dependent
10 |
11 | from xpresso._utils.typing import Annotated, get_args, get_origin
12 |
13 |
14 | def get_type(param: inspect.Parameter) -> type:
15 | if get_origin(param.annotation) is Annotated:
16 | return next(iter(get_args(param.annotation)))
17 | return param.annotation
18 |
19 |
20 | class DependencyOverrideManager:
21 | _stacks: typing.List[contextlib.ExitStack]
22 |
23 | def __init__(self, container: Container) -> None:
24 | self._container = container
25 | self._stacks = []
26 |
27 | def __setitem__(
28 | self, target: DependencyProvider, replacement: DependencyProvider
29 | ) -> None:
30 | def hook(
31 | param: typing.Optional[inspect.Parameter],
32 | dependent: DependentBase[typing.Any],
33 | ) -> typing.Optional[DependentBase[typing.Any]]:
34 | if not isinstance(dependent, Dependent):
35 | return None
36 | scope = dependent.scope
37 | dep = Dependent(
38 | replacement,
39 | scope=scope, # type: ignore[arg-type]
40 | use_cache=dependent.use_cache,
41 | wire=dependent.wire,
42 | )
43 | if param is not None and param.annotation is not param.empty:
44 | type_ = get_type(param)
45 | if type_ is target:
46 | return dep
47 | if dependent.call is not None and dependent.call is target:
48 | return dep
49 | return None
50 |
51 | cm = self._container.bind(hook)
52 | if self._stacks:
53 | self._stacks[-1].enter_context(cm)
54 |
55 | def __enter__(self) -> "DependencyOverrideManager":
56 | self._stacks.append(contextlib.ExitStack().__enter__())
57 | return self
58 |
59 | def __exit__(
60 | self,
61 | __exc_type: typing.Optional[typing.Type[BaseException]],
62 | __exc_value: typing.Optional[BaseException],
63 | __traceback: typing.Optional[TracebackType],
64 | ) -> typing.Optional[bool]:
65 | return self._stacks.pop().__exit__(__exc_type, __exc_value, __traceback)
66 |
--------------------------------------------------------------------------------
/docs/advanced/dependencies/composition-root.md:
--------------------------------------------------------------------------------
1 | # Controlling the composition root
2 |
3 | In dependency injection technical jargon, the "composition root" is a single logical place (function, module, etc.) where all of your dependendencies are "composed" together and abstractions are bound to concrete implementations.
4 |
5 | You can acheive this in Xpresso if your are willing to take some control of application initialization.
6 |
7 | In many cases, this will let you cut out intermediary dependencies (e.g. a dependency to get a database connection or load a config from the environment): you can load your config, create your database connection and bind your repos/DAOs so that your application never has to know about a config or even what database backend it is using.
8 |
9 | ```python
10 | --8<-- "docs_src/advanced/dependencies/tutorial_006.py"
11 | ```
12 |
13 | Notice that we didn't have to add a verbose `Depends(...)` to our endpoint function since we are wiring `WordsRepo` up in our composition root.
14 |
15 | This pattern also lends iteself natually to _depending on abstractions_: because you aren't forced to specify how `WordsRepo` should be built, it can be an abstract interface (`SupportsWordsRepo`, using `typing.Protocol` or `abc.ABC`), leaving you with a clean and testable endpoint handler that has no mention of the concrete implementation of `SupportsWordsRepo` that will be used at runtime
16 |
17 | ## Running an ASIG server programmatically
18 |
19 | If you are running your ASGI server programmatically you have control of the event loop, allowing you to intialize arbitrarily complex dependencies (for example, a database connection that requires an async context manager).
20 |
21 | This also has the side effect of making ASGI lifespans in redundant since you can do anything a lifespan can yourself before starting the ASGI server.
22 |
23 | Here is an example of this pattern using Uvicorn
24 |
25 | ```python
26 | --8<-- "docs_src/advanced/dependencies/tutorial_007.py"
27 | ```
28 |
29 | There are many variations to this pattern, you should try different arrangements to find one that best fits your use case.
30 | For example, you could:
31 |
32 | - Splitting out your aggregate root into a `build_app()` function
33 | - Mix this manual wiring in the aggregate root with use of `Depends()`
34 | - Use Uvicorn's `--factory` CLI parameter or `uvicorn.run(..., factory=True)` if you'd like to wire up your dependencies in a composition root but don't need to take control of the event loop or need to run under Gunicorn.
35 |
--------------------------------------------------------------------------------
/benchmarks/fastapi_app.py:
--------------------------------------------------------------------------------
1 | from typing import Mapping, List, Union
2 |
3 | from fastapi import FastAPI as App, Response, Request, Depends, Query, Path
4 | from fastapi.routing import Mount, APIRouter as Router, APIRoute
5 | from starlette.routing import BaseRoute, Route
6 |
7 | from benchmarks.constants import DAG_SHAPE, DELAY, NO_DELAY, ROUTING_PATHS
8 | from benchmarks.utils import generate_dag
9 |
10 |
11 | def make_depends(type_: str, provider: str) -> str:
12 | return f"{type_} = Depends({provider})"
13 |
14 |
15 | glbls = {"Depends": Depends}
16 |
17 |
18 | async def simple(request: Request) -> Response:
19 | """An endpoint that does the minimal amount of work"""
20 | return Response()
21 |
22 |
23 | dag_size, dep_without_delays = generate_dag(
24 | make_depends, glbls, *DAG_SHAPE, sleep=NO_DELAY
25 | )
26 | print("/fast_deps dag size: ", dag_size)
27 |
28 |
29 | async def fast_dependencies(
30 | _: int = Depends(dep_without_delays),
31 | ) -> Response:
32 | """An endpoint with dependencies that execute instantly"""
33 | return Response()
34 |
35 |
36 | dag_size, dep_with_delays = generate_dag(
37 | make_depends, glbls, *DAG_SHAPE, sleep=DELAY
38 | )
39 | print("/slow_deps dag size: ", dag_size)
40 |
41 |
42 | async def slow_dependencies(
43 | _: int = Depends(dep_with_delays),
44 | ) -> Response:
45 | """An endpoint with dependencies that simulate IO"""
46 | return Response()
47 |
48 |
49 | Paths = Mapping[str, Union["Paths", None]] # type: ignore[misc]
50 |
51 |
52 | def recurisively_generate_routes(paths: Paths) -> Router:
53 | routes: List[BaseRoute] = []
54 | for path in paths:
55 | subpaths = paths[path]
56 | if subpaths is None:
57 | routes.append(Route(f"/{path}", simple))
58 | else:
59 | routes.append(Mount(f"/{path}", app=recurisively_generate_routes(subpaths)))
60 | return Router(routes=routes)
61 |
62 |
63 | async def parameters(
64 | p1: str = Path(...),
65 | p2: int = Path(...),
66 | q1: str = Query(...),
67 | q2: int = Query(...),
68 | ) -> Response:
69 | return Response()
70 |
71 |
72 | app = App(
73 | routes=[
74 | APIRoute("/simple", simple),
75 | APIRoute("/fast_deps", fast_dependencies),
76 | APIRoute(
77 | "/slow_deps",
78 | slow_dependencies,
79 | ),
80 | Mount("/routing", app=recurisively_generate_routes(ROUTING_PATHS)),
81 | APIRoute("/parameters/{p1}/{p2}", parameters),
82 | ]
83 | )
84 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/dependencies/test_tutorial_007.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import Any, Dict, Iterable
3 |
4 | import pytest
5 |
6 | from docs_src.tutorial.dependencies.tutorial_007 import app
7 | from xpresso.testclient import TestClient
8 |
9 |
10 | @pytest.mark.parametrize(
11 | "roles,status_code,json_response",
12 | [
13 | (
14 | [],
15 | 403,
16 | json.load(
17 | open("docs_src/tutorial/dependencies/tutorial_007_response_001.json")
18 | ),
19 | ),
20 | (
21 | ["user"],
22 | 403,
23 | json.load(
24 | open("docs_src/tutorial/dependencies/tutorial_007_response_002.json")
25 | ),
26 | ),
27 | (
28 | ["user", "items-user"],
29 | 200,
30 | json.load(
31 | open("docs_src/tutorial/dependencies/tutorial_007_response_003.json")
32 | ),
33 | ),
34 | ],
35 | )
36 | def test_get_item_authorization(
37 | roles: Iterable[str],
38 | status_code: int,
39 | json_response: Dict[str, Any],
40 | ):
41 | client = TestClient(app)
42 | params = tuple([("roles", role) for role in roles])
43 | response = client.get("/items/foobar", params=params)
44 | assert response.status_code == status_code, response.content
45 | assert response.json() == json_response
46 |
47 |
48 | @pytest.mark.parametrize(
49 | "roles,status_code,json_response",
50 | [
51 | (
52 | [],
53 | 403,
54 | json.load(
55 | open("docs_src/tutorial/dependencies/tutorial_007_response_001.json")
56 | ),
57 | ),
58 | (
59 | ["user"],
60 | 403,
61 | json.load(
62 | open("docs_src/tutorial/dependencies/tutorial_007_response_002.json")
63 | ),
64 | ),
65 | (["user", "items-user"], 403, {"detail": "Missing roles: ['items-admin']"}),
66 | (["user", "items-user", "items-admin"], 200, None),
67 | ],
68 | )
69 | def test_delete_item_authorization(
70 | roles: Iterable[str],
71 | status_code: int,
72 | json_response: Dict[str, Any],
73 | ):
74 | client = TestClient(app)
75 | params = tuple([("roles", role) for role in roles])
76 | response = client.delete("/items/foobar", params=params)
77 | assert response.status_code == status_code, response.content
78 | assert response.json() == json_response
79 |
--------------------------------------------------------------------------------
/xpresso/binders/_binders/pydantic_validators.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | from pydantic.error_wrappers import ErrorWrapper
4 | from pydantic.fields import ModelField
5 | from starlette.requests import HTTPConnection
6 |
7 | from xpresso.exceptions import RequestValidationError, WebSocketValidationError
8 | from xpresso.typing import Some
9 |
10 |
11 | def validate_param_field(
12 | field: ModelField,
13 | name: str,
14 | in_: str,
15 | values: typing.Optional[Some],
16 | connection: HTTPConnection,
17 | ) -> typing.Any:
18 | """Validate after parsing. Only used by the top-level body"""
19 | if values is None:
20 | if field.required is False:
21 | return field.get_default()
22 | else:
23 | err = [
24 | ErrorWrapper(
25 | ValueError(f"Missing required {in_} parameter"),
26 | loc=(in_, name),
27 | )
28 | ]
29 | if connection.scope["type"] == "websocket":
30 | raise WebSocketValidationError(err)
31 | raise RequestValidationError(err)
32 | val, errs = field.validate(values.value, {}, loc=(in_, name))
33 | if errs:
34 | if isinstance(errs, ErrorWrapper):
35 | errs = [errs]
36 | errs = typing.cast(typing.List[ErrorWrapper], errs)
37 | if connection.scope["type"] == "websocket":
38 | raise WebSocketValidationError(errs)
39 | raise RequestValidationError(errs)
40 | return val
41 |
42 |
43 | def validate_body_field(
44 | values: typing.Optional[Some],
45 | *,
46 | field: ModelField,
47 | loc: typing.Tuple[typing.Union[str, int], ...],
48 | ) -> typing.Any:
49 | """Validate after extraction. Should only be used by the top-level body"""
50 | if values is None:
51 | if field.required is False:
52 | return field.get_default()
53 | else:
54 | raise RequestValidationError(
55 | [ErrorWrapper(ValueError("Missing required value"), loc=loc)]
56 | )
57 | val, err_or_errors = field.validate(values.value, {}, loc=loc)
58 | if err_or_errors:
59 | errors: typing.List[ErrorWrapper]
60 | if isinstance(err_or_errors, ErrorWrapper):
61 | errors = [err_or_errors]
62 | else:
63 | errors = typing.cast(
64 | typing.List[ErrorWrapper], err_or_errors
65 | ) # already a list
66 | raise RequestValidationError(errors)
67 | return val
68 |
--------------------------------------------------------------------------------
/xpresso/_utils/routing.py:
--------------------------------------------------------------------------------
1 | import typing
2 | from dataclasses import dataclass
3 |
4 | from starlette.routing import BaseRoute, Mount
5 | from starlette.routing import Router as StarletteRouter
6 |
7 | from xpresso._utils.typing import Protocol
8 | from xpresso.routing.pathitem import Path
9 | from xpresso.routing.router import Router as XpressoRouter
10 | from xpresso.routing.websockets import WebSocketRoute
11 |
12 | Router = typing.Union[XpressoRouter, StarletteRouter]
13 |
14 |
15 | class App(Protocol):
16 | @property
17 | def router(self) -> XpressoRouter:
18 | ...
19 |
20 |
21 | AppType = typing.TypeVar("AppType", bound=App)
22 |
23 |
24 | @dataclass(frozen=True)
25 | class VisitedRoute(typing.Generic[AppType]):
26 | path: str
27 | nodes: typing.List[typing.Union[Router, AppType]]
28 | route: BaseRoute
29 |
30 |
31 | def visit_routes(
32 | app_type: typing.Type[AppType],
33 | router: Router,
34 | nodes: typing.List[typing.Union[Router, AppType]],
35 | path: str,
36 | ) -> typing.Generator[VisitedRoute[AppType], None, None]:
37 | for route in typing.cast(typing.Iterable[BaseRoute], router.routes): # type: ignore # for Pylance
38 | if isinstance(route, Mount):
39 | app: typing.Any = route.app
40 | mount_path: str = route.path # type: ignore # for Pylance
41 | if isinstance(app, (StarletteRouter, XpressoRouter)):
42 | yield VisitedRoute(
43 | path=path,
44 | nodes=nodes + [app],
45 | route=route,
46 | )
47 | yield from visit_routes(
48 | app_type=app_type,
49 | router=app,
50 | nodes=nodes + [app],
51 | path=path + mount_path,
52 | )
53 | elif isinstance(app, app_type):
54 | yield VisitedRoute(
55 | path=path,
56 | nodes=nodes + [app, app.router],
57 | route=route,
58 | )
59 | yield from visit_routes(
60 | app_type=app_type,
61 | router=app.router,
62 | nodes=nodes + [app, app.router],
63 | path=path + mount_path,
64 | )
65 | elif isinstance(route, (Path, WebSocketRoute)):
66 | yield VisitedRoute(
67 | path=path + route.path,
68 | nodes=nodes,
69 | route=route,
70 | )
71 |
--------------------------------------------------------------------------------
/tests/test_exception_handlers.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from starlette.responses import JSONResponse
3 |
4 | from xpresso import App, FromPath, HTTPException, Path, Request
5 | from xpresso.exception_handlers import ExcHandler
6 | from xpresso.exceptions import RequestValidationError
7 | from xpresso.testclient import TestClient
8 |
9 |
10 | def http_exception_handler(request: Request, exception: HTTPException):
11 | return JSONResponse({"exception": "http-exception"})
12 |
13 |
14 | def request_validation_exception_handler(
15 | request: Request, exception: RequestValidationError
16 | ):
17 | return JSONResponse({"exception": "request-validation"})
18 |
19 |
20 | def server_error_exception_handler(request: Request, exception: Exception):
21 | return JSONResponse(status_code=500, content={"exception": "server-error"})
22 |
23 |
24 | def route_with_http_exception():
25 | raise HTTPException(status_code=400)
26 |
27 |
28 | def route_with_request_validation_exception(param: FromPath[int]):
29 | pass # pragma: no cover
30 |
31 |
32 | def route_with_server_error():
33 | raise RuntimeError("Oops!")
34 |
35 |
36 | app = App(
37 | routes=[
38 | Path("/http-exception", get=route_with_http_exception),
39 | Path(
40 | "/request-validation/{param}/", get=route_with_request_validation_exception
41 | ),
42 | Path("/server-error", get=route_with_server_error),
43 | ],
44 | exception_handlers=[
45 | ExcHandler(HTTPException, http_exception_handler),
46 | ExcHandler(RequestValidationError, request_validation_exception_handler),
47 | ExcHandler(Exception, server_error_exception_handler),
48 | ],
49 | )
50 |
51 | client = TestClient(app)
52 |
53 |
54 | def test_override_http_exception():
55 | response = client.get("/http-exception")
56 | assert response.status_code == 200
57 | assert response.json() == {"exception": "http-exception"}
58 |
59 |
60 | def test_override_request_validation_exception():
61 | response = client.get("/request-validation/invalid")
62 | assert response.status_code == 200
63 | assert response.json() == {"exception": "request-validation"}
64 |
65 |
66 | def test_override_server_error_exception_raises():
67 | with pytest.raises(RuntimeError):
68 | client.get("/server-error")
69 |
70 |
71 | def test_override_server_error_exception_response():
72 | client = TestClient(app, raise_server_exceptions=False)
73 | response = client.get("/server-error")
74 | assert response.status_code == 500
75 | assert response.json() == {"exception": "server-error"}
76 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Xpresso
2 |
3 | Xpresso is a pure python package that you should be able to get up in running in < 5 minutes.
4 |
5 | ## Clone the repo
6 |
7 | First, clone the repository:
8 |
9 | ```shell
10 | git clone https://github.com/adriangb/xpresso.git
11 | ```
12 |
13 | Then change directories into the repository you just cloned:
14 |
15 | ```shell
16 | cd xpresso
17 | ```
18 |
19 | ## Set up the project
20 |
21 | ### Automated setup
22 |
23 | If you have [Make] installed, you can just run:
24 |
25 | ```shell
26 | make init && make test && make lint
27 | ```
28 |
29 | Which will set up a virtual enviromnet (using [Poetry]), install all of the project's dependencies, install git hooks and run all of the tests.
30 |
31 | ### Manual setup
32 |
33 | Alternatively, you can set up the project manually like any other Poetry project:
34 |
35 | ```shell
36 | pip install -U poetry
37 | poetry install
38 | ```
39 |
40 | Then to run tests:
41 |
42 | ```shell
43 | poetry run python -m pytest -v
44 | ```
45 |
46 | To install git hooks (managed by [pre-commit]):
47 |
48 | ```shell
49 | pip install -U pre-commit
50 | pre-commit install
51 | ```
52 |
53 | And to run linters:
54 |
55 | ```shell
56 | pre-commit run --all-files
57 | ```
58 |
59 | ## Making changes
60 |
61 | First you will need to fork the repository on GitHub.
62 | Once you have your own fork, clone it and follow the instructions above to set up the project.
63 | You will make changes in your fork and then open a pull request (PR) against [https://github.com/adriangb/xpresso](https://github.com/adriangb/xpresso).
64 |
65 | All changes are expected to come with tests.
66 | If the change impacts user facing behavior, it should also have documentation associated with it.
67 | Once you've made your changes and have passing tests, you can submit a PR.
68 | Every pull request merge will trigger a release, so you need to include a version bump (by editing the version in `pyproject.toml`).
69 | We adhere to [Semantic Versioning].
70 | Use your best judgment as to what sort of version bump your changes warrant and it will be discussed as part of the PR review process.
71 |
72 | Pull requests are squash merged, so you do not need to keep a tidy commit history, although it is appreciated if you still do keep your work in progress commit messages clear and consice to aid in the review process.
73 | You are encouraged, but not required, to use [Conventional Commits].
74 |
75 | [Make]: https://www.gnu.org/software/make/
76 | [Poetry]: https://python-poetry.org
77 | [pre-commit]: https://pre-commit.com
78 | [Semantic Versioning]: https://semver.org
79 | [Conventional Commits]: https://www.conventionalcommits.org/en/v1.0.0/
80 |
--------------------------------------------------------------------------------
/benchmarks/xpresso_app.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict, Mapping, Union, List
2 |
3 | from starlette.routing import BaseRoute, Route
4 |
5 | from xpresso import App, Depends, Operation, Path, Request, Response, Router, FromQuery, FromPath
6 | from xpresso.routing.mount import Mount
7 | from xpresso.typing import Annotated
8 |
9 | from benchmarks.constants import DAG_SHAPE, DELAY, NO_DELAY, ROUTING_PATHS
10 | from benchmarks.utils import generate_dag
11 |
12 | def make_depends(type_: str, provider: str) -> str:
13 | return f"Annotated[{type_}, Depends({provider})]"
14 |
15 |
16 | glbls: Dict[str, Any] = {
17 | "Depends": Depends,
18 | "Annotated": Annotated,
19 | }
20 |
21 |
22 | def simple(request: Request) -> Response:
23 | """An endpoint that does the minimal amount of work"""
24 | return Response()
25 |
26 |
27 | dag_size, dep_without_delays = generate_dag(
28 | make_depends, glbls, *DAG_SHAPE, sleep=NO_DELAY
29 | )
30 | print("/fast_deps dag size: ", dag_size)
31 |
32 |
33 | def fast_dependencies(
34 | _: Annotated[int, Depends(dep_without_delays)]
35 | ) -> Response:
36 | """An endpoint with dependencies that execute instantly"""
37 | return Response()
38 |
39 |
40 | dag_size, dep_with_delays = generate_dag(
41 | make_depends, glbls, *DAG_SHAPE, sleep=DELAY
42 | )
43 | print("/slow_deps dag size: ", dag_size)
44 |
45 |
46 | def slow_dependencies(
47 | _: Annotated[int, Depends(dep_with_delays)]
48 | ) -> Response:
49 | """An endpoint with dependencies that simulate IO"""
50 | return Response()
51 |
52 |
53 | Paths = Mapping[str, Union["Paths", None]] # type: ignore[misc]
54 |
55 |
56 | def recurisively_generate_routes(paths: Paths) -> Router:
57 | routes: List[BaseRoute] = []
58 | for path in paths:
59 | subpaths = paths[path]
60 | if subpaths is None:
61 | routes.append(Route(f"/{path}", simple))
62 | else:
63 | routes.append(Mount(f"/{path}", app=recurisively_generate_routes(subpaths)))
64 | return Router(routes=routes)
65 |
66 |
67 | async def parameters(
68 | p1: FromPath[str],
69 | p2: FromPath[int],
70 | q1: FromQuery[str],
71 | q2: FromQuery[int],
72 | ) -> Response:
73 | return Response()
74 |
75 |
76 | app = App(
77 | routes=[
78 | Path("/simple", get=simple),
79 | Path("/fast_deps", get=fast_dependencies),
80 | Path(
81 | "/slow_deps",
82 | get=Operation(
83 | slow_dependencies,
84 | execute_dependencies_concurrently=True,
85 | ),
86 | ),
87 | Mount("/routing", app=recurisively_generate_routes(ROUTING_PATHS)),
88 | Path("/parameters/{p1}/{p2}", get=parameters)
89 | ]
90 | )
91 |
--------------------------------------------------------------------------------
/xpresso/binders/_binders/query_params.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | from typing import Any, NamedTuple, Optional
3 |
4 | from pydantic.error_wrappers import ErrorWrapper
5 | from pydantic.fields import ModelField
6 | from starlette.requests import HTTPConnection
7 |
8 | from xpresso._utils.pydantic_utils import model_field_from_param
9 | from xpresso.binders._binders.formencoded_parsing import Extractor as FormExtractor
10 | from xpresso.binders._binders.formencoded_parsing import (
11 | InvalidSerialization,
12 | get_extractor,
13 | )
14 | from xpresso.binders._binders.pydantic_validators import validate_param_field
15 | from xpresso.binders.api import SupportsExtractor
16 | from xpresso.exceptions import RequestValidationError, WebSocketValidationError
17 |
18 | ERRORS = {
19 | "websocket": WebSocketValidationError,
20 | "http": RequestValidationError,
21 | }
22 |
23 |
24 | class Extractor(NamedTuple):
25 | name: str
26 | field: ModelField
27 | extractor: FormExtractor
28 |
29 | def __hash__(self) -> int:
30 | return hash((self.__class__, self.name))
31 |
32 | def __eq__(self, __o: object) -> bool:
33 | return isinstance(__o, Extractor) and __o.name == self.name
34 |
35 | async def extract(
36 | self,
37 | connection: HTTPConnection,
38 | ) -> Any:
39 | try:
40 | extracted = self.extractor(
41 | name=self.name, params=connection.query_params.multi_items()
42 | )
43 | except InvalidSerialization:
44 | raise ERRORS[connection.scope["type"]](
45 | [
46 | ErrorWrapper(
47 | exc=TypeError("Data is not a valid URL encoded query"),
48 | loc=tuple(("query", self.name)),
49 | )
50 | ]
51 | )
52 | return validate_param_field(
53 | field=self.field,
54 | in_="query",
55 | name=self.name,
56 | connection=connection,
57 | values=extracted,
58 | )
59 |
60 |
61 | class ExtractorMarker(NamedTuple):
62 | alias: Optional[str]
63 | explode: bool
64 | style: str
65 |
66 | def register_parameter(self, param: inspect.Parameter) -> SupportsExtractor:
67 | if self.style == "deepObject" and not self.explode:
68 | # no such thing in the spec
69 | raise ValueError("deepObject can only be used with explode=True")
70 | field = model_field_from_param(param)
71 | name = self.alias or param.name
72 | extractor = get_extractor(style=self.style, explode=self.explode, field=field)
73 | name = self.alias or field.alias
74 | return Extractor(field=field, name=name, extractor=extractor)
75 |
--------------------------------------------------------------------------------
/docs/types.md:
--------------------------------------------------------------------------------
1 | # Typing in Python
2 |
3 | This documentation assumes that you are familiar with type annotations in Python.
4 | If you are not, that is okay! There are a lot of excellent guides out there to get you started:
5 |
6 | - [RealPython's Python Type Checking guide](https://realpython.com/python-type-checking/)
7 | - [FastAPI's introduction to python types](https://fastapi.tiangolo.com/python-types/)
8 |
9 | ## Runtime types (reflection)
10 |
11 | Most languages lose a lot of their type information at runtime.
12 | This can range between complete loss of type information (like TypeScript) or only weak support for runtime reflection (Golang).
13 |
14 | Python stands out for it's strong support for typing information at runtime (often called _reflection_). Because of Python's dynamic runtime behavior, it is possible to read types and modify the runtime behavior of the program.
15 |
16 | ## `Annotated` and parameter metadata
17 |
18 | Python 3.9 (via [PEP 593]) introduced the `Annotated` typing construct.
19 | Since it's release in Python 3.9, this construct has been backported to older Python versions via the [typing_extensions] package.
20 | So it is available all the way back to Python 3.7.
21 | Xpresso uses `Annotated` extensively since it provides a composable, cohesive and widely supported pattern for attaching metadata for function parameters and class field declarations that is available at runtime.
22 |
23 | If you've used FastAPI, you may be used to declaring things like `param: str = Header()`.
24 | When FastAPI was first released, this was the only way to add runtime metadata to a parameter in Python.
25 | But now there is a better way to do this!
26 | In Xpresso this same declaration would look like `param: FromHeader[str]` or `param: Annotated[str, HeaderParam()]` (the former is just syntactic sugar for the latter).
27 | As you see more usages of `Annotated` you will get used to it.
28 | But for now all you need to know is that `param: Annotated[str, HeaderParam()]` is pretty much equivalent to `param: str = Header()` in FastAPI.
29 |
30 | One of the main advantages to using `Annotated` is composability: multiple tools/libraries can include metadata together without conflict.
31 | For example, we can include information for both Xpresso and Pydantic using `param: Annotated[str, Path(), Field(min_length=1)`.
32 | This is in contrast to FastAPI where `Query()` and friends are actually subclasses of Pydantic's `Field()`, which couples the web framework to Pydantic and adds complexity into `Query()`, `Path()`, etc. that is not really related to them directly.
33 | To see an example of this in action, head over to the [Path Parameters] section of our documentation.
34 |
35 | [typing_extensions]: https://pypi.org/project/typing-extensions/
36 | [PEP 593]: https://www.python.org/dev/peps/pep-0593/
37 | [Path Parameters]: tutorial/path_params.md
38 |
--------------------------------------------------------------------------------
/xpresso/binders/api.py:
--------------------------------------------------------------------------------
1 | from typing import Any, AsyncIterator, Awaitable, Dict, List, Union
2 |
3 | from starlette.requests import HTTPConnection
4 |
5 | import xpresso.openapi.models as openapi_models
6 | from xpresso._utils.typing import Protocol
7 |
8 |
9 | class SupportsExtractor(Protocol):
10 | def extract(
11 | self, connection: HTTPConnection
12 | ) -> Union[Awaitable[Any], AsyncIterator[Any]]:
13 | """Extract data from an incoming connection.
14 |
15 | The `connection` parameter will always be either a Request object or a WebSocket object,
16 | which are both subclasses of HTTPConnection.
17 | If you just need access to headers, query params, or any other metadata present in HTTPConnection
18 | then you can use the parameter directly.
19 | Otherwise, you can do `isinstance(connection, Request)` before accessing `Request.stream()` and such.
20 |
21 | The return value can be an awaitable or an async iterable (context manager like).
22 | The iterator versions will be wrapped with `@contextlib.{async}contextmanager`.
23 | """
24 | ...
25 |
26 | # __hash__ and __eq__ are required so that the dependency injection system can cache extracted values
27 | # (for example allowing the user to get multiple references to a request body without parsing twice)
28 |
29 | def __hash__(self) -> int:
30 | ...
31 |
32 | def __eq__(self, __o: object) -> bool:
33 | ...
34 |
35 |
36 | Model = type
37 | ModelNameMap = Dict[Model, str]
38 |
39 |
40 | class SupportsOpenAPI(Protocol):
41 | def get_models(self) -> List[type]:
42 | """Collect all of the types that OpenAPI schemas will be
43 | produced from.
44 |
45 | Xpresso will then assign a schema name to each type and pass
46 | that back via the ModelNameMap parameter.
47 |
48 | This ensures that all schema models have a unique name,
49 | even if their Python class names conflict.
50 | """
51 | ...
52 |
53 | def modify_operation_schema(
54 | self,
55 | model_name_map: ModelNameMap,
56 | operation: openapi_models.Operation,
57 | components: openapi_models.Components,
58 | ) -> None:
59 | """Callback to modify the OpenAPI schema.
60 |
61 | Implementers should modify the operation and components as they see fit,
62 | but take care to not needlessly add keys or try to access keys which may not exist.
63 |
64 | When determining what string name to use to represent a model/schema (for example to add it to components/schemas)
65 | you MUST use the model_name_map parameter to find the name assigned for each type.
66 | For example:
67 | >>> components.schemas[model_name_map[MyModel]] = MyModel.get_schema()
68 | """
69 | ...
70 |
--------------------------------------------------------------------------------
/docs/advanced/dependencies/overrides.md:
--------------------------------------------------------------------------------
1 | # Dependency Overrides
2 |
3 | We've already seen one way of telling the dependency injection system how to wire a dependency that it can't auto-wire in the form of [Markers].
4 |
5 | There are however other situations where Markers may not be the answer.
6 | For these situations, Xpresso offers **dependency overrides** which lets you dynamically bind a provider to a dependency.
7 | When you override a dependency, you completely replace the original provider (if any) in Xpresso's directed acyclic graph of dependencies.
8 | This means that any sub-dependencies of the original provider (if any) will not be executed.
9 | This also means that the provider you are registering can itself have sub-dependencies.
10 | Those will get treated just like any other dependency, all of the same rules apply.
11 |
12 | As an example, let's look at how we might write a test for our ongoing `httpx.AsyncClient` examples.
13 | Here is the example we had previously from the [Dependency Injection - Introduction] section:
14 |
15 | ```python
16 | --8<-- "docs_src/tutorial/dependencies/tutorial_001.py"
17 | ```
18 |
19 | We don't want to actually make network calls to HTTPBin in our tests, so we swap out the `httpx.AsyncClient` for one using `httpx.MockTransport`:
20 |
21 | ```python hl_lines="12-13 15-16"
22 | --8<-- "tests/test_docs/tutorial/dependencies/test_tutorial_001.py"
23 | ```
24 |
25 | !!! tip
26 | You can use `app.dependency_overrides` both as a context manager (like in the example above) and as a regular mapping-like object.
27 | If used as a context manager, the binding will be reversed when the context manager exits.
28 | Otherwise, the bind is permanent.
29 | You probably should use the context manager form in tests so that you don't leak state from one test to another.
30 |
31 | !!! tip
32 | Notice how we used a lambda to always return the same instance.
33 | Depending on what your dependency is, and what `scope` it was declared with, you may want to return a new instance each time.
34 |
35 | !!! note
36 | Xpresso's `app.dependency_overrides` is just a wrapper around the more advanced functionality offered in [di].
37 | The lowest level, but most powerful, interface is `Container.register_hook` (`App.container.register_hook` when accessed from an Xpresso App).
38 | See [di's provider binding docs] for more details.
39 |
40 | You can also use this same mechanism to declare a dependency on an abstract interface (including `typing.Protocol` classes) and then register a concrete implementation in some `create_production_app()` class and a different concrete implementation in `create_test_app()`.
41 |
42 | [Markers]: ../../tutorial/dependencies/README.md#explicit-dependencies-with-markers
43 | [Dependency Injection - Introduction]: ../../tutorial/dependencies/README.md
44 | [di]: https://github.com/adriangb/di
45 | [di's provider binding docs]: https://www.adriangb.com/di/latest/binds/
46 |
--------------------------------------------------------------------------------
/xpresso/responses.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Mapping, NamedTuple, Optional, Union
2 |
3 | from pydantic import BaseModel
4 | from starlette.responses import FileResponse as FileResponse # noqa: F401
5 | from starlette.responses import HTMLResponse as HTMLResponse # noqa: F401
6 | from starlette.responses import JSONResponse as JSONResponse # noqa: F401
7 | from starlette.responses import PlainTextResponse as PlainTextResponse # noqa: F401
8 | from starlette.responses import RedirectResponse as RedirectResponse # noqa: F401
9 | from starlette.responses import Response as Response # noqa: F401
10 | from starlette.responses import StreamingResponse as StreamingResponse # noqa: F401
11 |
12 | from xpresso._utils.asgi import XpressoHTTPExtension
13 | from xpresso._utils.typing import Literal
14 | from xpresso.openapi.models import Example, ResponseHeader
15 | from xpresso.requests import Request
16 |
17 |
18 | def get_response(request: Request) -> Response:
19 | xpresso_extension: "XpressoHTTPExtension" = request.scope["extensions"]["xpresso"] # type: ignore # for Pylance
20 | if xpresso_extension.response is None:
21 | raise LookupError(
22 | "xpresso.responses.get_response was called"
23 | " before the endpoint has finished executing or the endpoint raised an exception"
24 | )
25 | return xpresso_extension.response
26 |
27 |
28 | def set_response(request: Request, response: Response) -> None:
29 | xpresso_extension: "XpressoHTTPExtension" = request.scope["extensions"]["xpresso"] # type: ignore # for Pylance
30 | if xpresso_extension.response_sent:
31 | raise RuntimeError(
32 | 'set_response() can only be used from "endpoint" scoped dependendencies'
33 | )
34 | xpresso_extension.response = response
35 |
36 |
37 | class TypeUnset:
38 | ...
39 |
40 |
41 | class ResponseModel(NamedTuple):
42 | model: Any = TypeUnset
43 | examples: Optional[Mapping[str, Union[Example, Any]]] = None
44 |
45 |
46 | class ResponseSpec(BaseModel):
47 | description: Optional[str] = None
48 | content: Optional[Mapping[str, Union[ResponseModel, type]]] = None
49 | headers: Optional[Mapping[str, Union[ResponseHeader, str]]] = None
50 |
51 | class Config:
52 | arbitrary_types_allowed = True
53 |
54 |
55 | class FileResponseSpec(ResponseSpec):
56 | content: Optional[Mapping[str, Union[ResponseModel, type]]] = {
57 | "application/octetstream": ResponseModel(bytes)
58 | }
59 |
60 |
61 | class HTMLResponseSpec(ResponseSpec):
62 | content: Optional[Mapping[str, Union[ResponseModel, type]]] = {
63 | "text/html": ResponseModel(str)
64 | }
65 |
66 |
67 | class PlainTextResponseSpec(ResponseSpec):
68 | content: Optional[Mapping[str, Union[ResponseModel, type]]] = {
69 | "text/plain": ResponseModel(str)
70 | }
71 |
72 |
73 | ResponseStatusCode = Union[Literal["default", "2XX", "3XX", "4XX", "5XX"], int]
74 |
--------------------------------------------------------------------------------
/docs/tutorial/routing.md:
--------------------------------------------------------------------------------
1 | # Routing
2 |
3 | Xpresso has a simple routing system, based on Starlette's routing system and the OpenAPI spec.
4 |
5 | There are 4 main players in Xpresso's routing system:
6 |
7 | - **Operation**: this is equivalent to an OpenAPI operation. An operation is a unique combination of an HTTP method and a path, and has a 1:1 relationship with endpoint functions. Xpresso's `Operation` class is derived from a Starlette `BaseRoute`.
8 | - **Path**: this is the equivalent of an OpenAPI PathItem. A Path can contain 1 Operation for each method (but does not have to). Paths are were you specify your actual path like `/items/{item_id}`. `Path` is derived from Starlette's `Route`.
9 | - **Router**: similar to Starlette's router but only supporting adding routes at initialization (`@router.route` does not exist in Xpresso). Additionally, it supports adding router-level dependencies, middleware and OpenAPI tags.
10 | - **App**: this is the top-level application object where you configure your dependency injection container and OpenAPI docs. Functionality is similar to Starlette's `Starlette` application, but just like `Router` the dynamic methods like `add_middleware()` and `add_exception_handler()` are not supported. `App` also accepts middleware and dependencies, but these are just passed though to its router (`App.router`).
11 |
12 | All of these are meant to work with Starlette, so you can mount a `Starlette` application into Xpresso as a route using `Mount`, or use a `Starlette` router in the middle of Xpresso's routing system.
13 |
14 | ```python
15 | --8<-- "docs_src/tutorial/routing/tutorial_001.py"
16 | ```
17 |
18 | See [Starlette's routing docs] for more general information on Starlette's routing system.
19 |
20 | ## Customizing OpenAPI schemas for Operation and Path
21 |
22 | `Operation`, `Path` and `Router` let you customize their OpenAPI schema.
23 | You can add descriptions, tags and detailed response information:
24 |
25 | - Add tags via the `tags` parameter
26 | - Exclude a specific Operation from the schema via the `include_in_schema` parameter
27 | - Add a summary for the Operation via the `summary` parameter
28 | - Add a description for the Operation via the `description` parameter (by default the endpoint function's docstring)
29 | - Mark the operation as deprecated via the `deprecated` parameter
30 | - Customize responses via the `responses` parameter
31 |
32 | ```python
33 | --8<-- "docs_src/tutorial/routing/tutorial_002.py"
34 | ```
35 |
36 | This will look something like this:
37 |
38 | 
39 |
40 | !!! note "Note"
41 | Tags and responses accumulate.
42 | Responses are overwritten with the lower level of the routing tree tacking precedence, so setting the same status code on a Router and Operation will result in the Operation's version overwriting Router's.
43 | The servers array completely overwrites any parents: setting `servers` on Operation will overwrite _all_ servers set on Routers or Path.
44 |
45 | [Starlette's routing docs]: https://www.starlette.io/routing/
46 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: "Xpresso"
2 | site_description: Developer centric, performant and extensible ASGI framework
3 | site_url: https://www.xpresso-api.dev
4 |
5 | theme:
6 | name: "material"
7 | logo: assets/images/xpresso-bean.png
8 | favicon: assets/images/xpresso-bean.png
9 | custom_dir: docs/overrides
10 | palette:
11 | - scheme: "default"
12 | primary: "black"
13 | accent: "amber"
14 | media: "(prefers-color-scheme: light)"
15 | toggle:
16 | icon: "material/weather-night"
17 | name: "Switch to dark mode"
18 | - scheme: "slate"
19 | primary: "black"
20 | accent: "amber"
21 | media: "(prefers-color-scheme: dark)"
22 | toggle:
23 | icon: "material/weather-sunny"
24 | name: "Switch to light mode"
25 |
26 | repo_name: adriangb/xpresso
27 | repo_url: https://github.com/adriangb/xpresso
28 | edit_uri: "blob/main/docs/"
29 |
30 | nav:
31 | - Xpresso: "README.md"
32 | - Python Types: "types.md"
33 | - Tutorial:
34 | - Minimal App: "tutorial/minimal_app.md"
35 | - Path Parameters: "tutorial/path_params.md"
36 | - Query Parameters: "tutorial/query_params.md"
37 | - Parameter Constraints and Metadata: "tutorial/param_constraints_and_metadata.md"
38 | - Request Body: "tutorial/body.md"
39 | - Cookie Parameters: "tutorial/cookie_params.md"
40 | - Header Parameters: "tutorial/header_params.md"
41 | - File Uploads: "tutorial/files.md"
42 | - Forms: "tutorial/forms.md"
43 | - Dependencies:
44 | - Introduction: "tutorial/dependencies/README.md"
45 | - Nested Dependencies: "tutorial/dependencies/nested.md"
46 | - Dependency Lifecycle: "tutorial/dependencies/lifecycle.md"
47 | - HTTP Parameter Dependencies: "tutorial/dependencies/http-params.md"
48 | - Scopes: "tutorial/dependencies/scopes.md"
49 | - Shared Dependencies: "tutorial/dependencies/shared.md"
50 | - Middleware:
51 | - Introduction: "tutorial/middleware/README.md"
52 | - Lifespans: "tutorial/lifespan.md"
53 | - Routing: "tutorial/routing.md"
54 | - Advanced Usage:
55 | - Customizing Responses: advanced/responses.md
56 | - WebSockets: advanced/websockets.md
57 | - Dependencies:
58 | - Performance: "advanced/dependencies/performance.md"
59 | - Caching: "advanced/dependencies/caching.md"
60 | - Accessing Responses: "advanced/dependencies/responses.md"
61 | - Dependency Overrides: "advanced/dependencies/overrides.md"
62 | - Composition Root: "advanced/dependencies/composition-root.md"
63 | - Binders: advanced/binders.md
64 | - Proxies and URL paths: advanced/proxies-root-path.md
65 | - Body Unions: advanced/body-union.md
66 | - Contributing: "contributing.md"
67 |
68 | markdown_extensions:
69 | - admonition
70 | - pymdownx.highlight
71 | - pymdownx.snippets
72 | - pymdownx.superfences
73 |
74 | extra_css:
75 | - css/custom.css
76 |
77 | extra:
78 | version:
79 | provider: mike
80 |
81 | plugins:
82 | - mike:
83 | version_selector: true
84 | - search:
85 |
--------------------------------------------------------------------------------
/tests/test_docs/tutorial/files/test_tutorial_003.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict
2 |
3 | from docs_src.tutorial.files.tutorial_003 import app
4 | from xpresso.testclient import TestClient
5 |
6 | client = TestClient(app)
7 |
8 | openapi_schema: Dict[str, Any] = {
9 | "openapi": "3.0.3",
10 | "info": {"title": "API", "version": "0.1.0"},
11 | "paths": {
12 | "/count-bytes": {
13 | "put": {
14 | "responses": {
15 | "200": {
16 | "description": "OK",
17 | "content": {
18 | "application/json": {
19 | "schema": {"type": "integer"}
20 | }
21 | },
22 | },
23 | "422": {
24 | "description": "Validation Error",
25 | "content": {
26 | "application/json": {
27 | "schema": {
28 | "$ref": "#/components/schemas/HTTPValidationError"
29 | }
30 | }
31 | },
32 | },
33 | },
34 | "requestBody": {
35 | "content": {
36 | "*/*": {"schema": {"type": "string", "format": "binary"}}
37 | },
38 | "required": True,
39 | },
40 | }
41 | }
42 | },
43 | "components": {
44 | "schemas": {
45 | "ValidationError": {
46 | "title": "ValidationError",
47 | "required": ["loc", "msg", "type"],
48 | "type": "object",
49 | "properties": {
50 | "loc": {
51 | "title": "Location",
52 | "type": "array",
53 | "items": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
54 | },
55 | "msg": {"title": "Message", "type": "string"},
56 | "type": {"title": "Error Type", "type": "string"},
57 | },
58 | },
59 | "HTTPValidationError": {
60 | "title": "HTTPValidationError",
61 | "type": "object",
62 | "properties": {
63 | "detail": {
64 | "title": "Detail",
65 | "type": "array",
66 | "items": {"$ref": "#/components/schemas/ValidationError"},
67 | }
68 | },
69 | },
70 | }
71 | },
72 | }
73 |
74 |
75 | def test_openapi_schema():
76 | response = client.get("/openapi.json")
77 | assert response.status_code == 200, response.content
78 | assert response.json() == openapi_schema
79 |
80 |
81 | def test_put_file():
82 | response = client.put("/count-bytes", data=b"123")
83 | assert response.status_code == 200
84 | assert response.json() == 3
85 |
--------------------------------------------------------------------------------
/tests/test_routing/test_operation.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict
2 |
3 | import pytest
4 | import starlette.routing
5 |
6 | from xpresso import App, FromJson, FromRawBody, Operation, Path
7 | from xpresso.routing.operation import NotPreparedError
8 | from xpresso.testclient import TestClient
9 |
10 |
11 | async def endpoint_1() -> None:
12 | ...
13 |
14 |
15 | async def endpoint_2() -> None:
16 | ...
17 |
18 |
19 | def test_url_path_for_with_path_parameters() -> None:
20 | operation = Operation(endpoint_1, name="endpoint")
21 | with pytest.raises(starlette.routing.NoMatchFound):
22 | operation.url_path_for("endpoint", param="foobar")
23 |
24 |
25 | def test_url_path_for_no_match() -> None:
26 | operation = Operation(endpoint_1, name="endpoint")
27 | with pytest.raises(starlette.routing.NoMatchFound):
28 | operation.url_path_for("not-operation")
29 |
30 |
31 | def test_url_path_for_matches() -> None:
32 | operation = Operation(endpoint_1, name="endpoint")
33 |
34 | url = operation.url_path_for("endpoint")
35 |
36 | assert str(url) == "/"
37 |
38 |
39 | def test_operation_comparison() -> None:
40 | assert Operation(endpoint_1) == Operation(endpoint_1)
41 | assert Operation(endpoint_1) != Operation(endpoint_2)
42 |
43 |
44 | @pytest.mark.skip
45 | def test_multiple_bodies_are_not_allowed() -> None:
46 | async def endpoint(
47 | body1: FromRawBody[bytes],
48 | body2: FromJson[str],
49 | ) -> None:
50 | raise AssertionError("Should not be called") # pragma: no cover
51 |
52 | app = App(
53 | routes=[
54 | Path(
55 | "/test",
56 | post=endpoint,
57 | )
58 | ]
59 | )
60 |
61 | client = TestClient(app)
62 | with pytest.raises(
63 | ValueError, match=r"Only 1 top level body is allowed in OpenAPI specs"
64 | ):
65 | client.get("/openapi.json")
66 |
67 |
68 | def test_usage_outside_of_xpresso() -> None:
69 | app = starlette.routing.Router(routes=[Path("/", get=endpoint_1)])
70 |
71 | msg = r"Operation.prepare\(\) was never called on this Operation"
72 |
73 | # error triggered with lifespan
74 | with TestClient(app) as client:
75 | with pytest.raises(NotPreparedError, match=msg):
76 | client.get("/")
77 |
78 | # error triggered without lifespan
79 | client = TestClient(app)
80 | with pytest.raises(NotPreparedError, match=msg):
81 | client.get("/")
82 |
83 |
84 | def test_include_in_schema() -> None:
85 | async def endpoint() -> None:
86 | ...
87 |
88 | app = App([Path("/", get=Operation(endpoint, include_in_schema=False))])
89 |
90 | client = TestClient(app)
91 |
92 | expected_openapi: Dict[str, Any] = {
93 | "openapi": "3.0.3",
94 | "info": {"title": "API", "version": "0.1.0"},
95 | "paths": {"/": {}},
96 | }
97 |
98 | resp = client.get("/openapi.json")
99 | assert resp.status_code == 200, resp.content
100 | assert resp.json() == expected_openapi
101 |
--------------------------------------------------------------------------------