├── 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 | ![Swagger UI](routing_002.png) 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 | --------------------------------------------------------------------------------