diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..10af773 --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +Combines InferringRouter from [fastapi-utils](https://fastapi-utils.davidmontague.xyz/user-guide/inferring-router/) +and Result from [returns](https://returns.readthedocs.io/en/latest/pages/result.html#api-reference) +so that both primary (200) and [additional](https://fastapi.tiangolo.com/advanced/additional-responses/) +response types can be inferred from the path operation type signature. + +Install as user +```bash +pip install git+https://github.com/amacfie/fastapi_returns_inferring_router +``` + + +Install as developer +```bash +pip install --editable . +``` + + +# How to use + +```python +from typing import Literal + +from pydantic import BaseModel +from returns.result import Result, Success, Failure +import fastapi + +from fastapi_returns_inferring_router import ReturnsInferringRouter + + +class ForbiddenBecauseOfUser(BaseModel): + def status_code(*args): + return 403 + + msg: Literal["Forbidden because of user"] = "Forbidden because of user" + + +class ForbiddenBecauseOfKey(BaseModel): + def status_code(*args): + return 403 + + msg: Literal["Forbidden because of key"] = "Forbidden because of key" + + +app = fastapi.FastAPI() + +# one param is added to, APIRouter, called get_status_code. +# pass a function that can take either a return type or return value and return +# the HTTP status code you wish to use it for +r = ReturnsInferringRouter(get_status_code=lambda x: x.status_code()) + +# compatible with InferringRouter from fastapi-utils +@r.get("/foo/{bar}") +def foo(bar: str) -> str: + return bar + "b" + + +# use the Returns library to write a function with a return type of Result. +# the success type becomes the 200 response type +# any failure types are also added as response types under the status code +# given by get_status_code. +# here we have two types with the same status code so the schema for 403 will +# be the union of the two types +@r.get("/baz/{bar}") +def baz(bar: str) -> Result[str, ForbiddenBecauseOfKey | ForbiddenBecauseOfUser]: + if ...: + return Failure(ForbiddenBecauseOfKey()) + elif ...: + return Failure(ForbiddenBecauseOfUser()) + else: + return Success(bar + "b") + + +app.include_router(r) +``` + +Generated schema (paste to for nicer view): +```json +{ + "openapi": "3.0.2", + "info": { + "title": "FastAPI", + "version": "0.1.0" + }, + "paths": { + "/foo/{bar}": { + "get": { + "summary": "Foo", + "operationId": "foo_foo__bar__get", + "parameters": [ + { + "required": true, + "schema": { + "title": "Bar", + "type": "string" + }, + "name": "bar", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "title": "Response Foo Foo Bar Get", + "type": "string" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/baz/{bar}": { + "get": { + "summary": "Baz", + "operationId": "baz_baz__bar__get", + "parameters": [ + { + "required": true, + "schema": { + "title": "Bar", + "type": "string" + }, + "name": "bar", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "title": "Response Baz Baz Bar Get", + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "title": "Response 403 Baz Baz Bar Get", + "anyOf": [ + { + "$ref": "#/components/schemas/ForbiddenBecauseOfKey" + }, + { + "$ref": "#/components/schemas/ForbiddenBecauseOfUser" + } + ] + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ForbiddenBecauseOfKey": { + "title": "ForbiddenBecauseOfKey", + "type": "object", + "properties": { + "msg": { + "title": "Msg", + "enum": [ + "Forbidden because of key" + ], + "type": "string", + "default": "Forbidden because of key" + } + } + }, + "ForbiddenBecauseOfUser": { + "title": "ForbiddenBecauseOfUser", + "type": "object", + "properties": { + "msg": { + "title": "Msg", + "enum": [ + "Forbidden because of user" + ], + "type": "string", + "default": "Forbidden because of user" + } + } + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "ValidationError": { + "title": "ValidationError", + "required": [ + "loc", + "msg", + "type" + ], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + } + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + } + } + } + } +} +``` diff --git a/fastapi_returns_inferring_router/__init__.py b/fastapi_returns_inferring_router/__init__.py new file mode 100644 index 0000000..67b0f31 --- /dev/null +++ b/fastapi_returns_inferring_router/__init__.py @@ -0,0 +1,81 @@ +from functools import wraps +from types import UnionType +from typing import (TYPE_CHECKING, Any, Callable, Union, get_args, get_origin, get_type_hints) + +from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from returns.result import Result, Success, Failure + + +class ReturnsInferringRouter(APIRouter): + def __init__( + self, + *args, + get_status_code: Callable[[Any], int] | None=None, + **kwargs, + ): + super().__init__(*args, **kwargs) + self._get_status_code = get_status_code + + if not TYPE_CHECKING: # pragma: no branch + + def add_api_route( + self, path: str, endpoint: Callable[..., Any], **kwargs: Any + ) -> None: + new_endpoint = endpoint + + return_type = get_type_hints(endpoint).get("return") + + if get_origin(return_type) is Result: + @wraps(endpoint) + def new_endpoint(*args, **kwargs): + ret = endpoint(*args, **kwargs) + match ret: + case Success(value): + return value + case Failure(value): + return JSONResponse( + status_code=self._get_status_code(value), + content=jsonable_encoder(value), + ) + + suc_type, fail_type = get_args(return_type) + + # pydantic maps None to something openapi doesn't recognize + if suc_type is None: + suc_type = Any + if kwargs.get("response_model") is None: + kwargs["response_model"] = suc_type + + if kwargs.get("responses") is None: + if get_origin(fail_type) in {Union, UnionType}: + fail_models = get_args(fail_type) + else: + fail_models = (fail_type,) + responses = dict() + assert self._get_status_code is not None + for fail_model in fail_models: + code = self._get_status_code(fail_model) + # same pydantic issue as above + if fail_model is None: + fail_model = Any + if code in responses: + responses[code]["model"] = Union[ + responses[code]["model"], + fail_model, + ] + else: + responses[code] = {"model": fail_model} + kwargs["responses"] = responses + + else: + # same pydantic issue as above + if return_type is None: + return_type = Any + if kwargs.get("response_model") is None: + kwargs["response_model"] = return_type + + + return super().add_api_route(path, new_endpoint, **kwargs) + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1870a2e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,4 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2a80157 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,13 @@ +[metadata] +name = fastapi_returns_inferring_router +version = 0.0.0 + +[options] +packages = fastapi_returns_inferring_router +install_requires = + fastapi==0.* + returns==0.* + build + sphinx==4.* + twine +python_requires = >=3.10, <4