question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Would be nice to be able to route request using header's Accept field (or generic header's field)

See original GitHub issue

First Check

  • I added a very descriptive title to this issue.
  • I used the GitHub search to find a similar issue and didn’t find it.
  • I searched the FastAPI documentation, with the integrated search.
  • I already searched in Google “How to X in FastAPI” and didn’t find any information.
  • I already read and followed all the tutorial in the docs and didn’t find an answer.
  • I already checked if it is not related to FastAPI but to Pydantic.
  • I already checked if it is not related to FastAPI but to Swagger UI.
  • I already checked if it is not related to FastAPI but to ReDoc.

Commit to Help

  • I commit to help with one of those options 👆

Example Code

N.A.

Description

In some cases would be nice to specify header’s field as routing rules.

One important example is to support API versioning based on Header’s Accept field

Wanted Solution

Ability to specify some header’s fields in the .get(), .post() … decorators

Wanted Code

from fastapi import FastAPI

app = FastAPI()


@app.get("/", accept="application/json;version=1.0")
async def root():
    return {"message": "Hello World v1.0"}

@app.get("/", accept="application/json;version=1.1")
async def root():
    return {"message": "Hello World v1.1"}

Alternatives

from fastapi import FastAPI

app = FastAPI()

@app.get(“/”, headers={“accept”: “application/json;version=1.0”}) async def root(): return {“message”: “Hello World v1.0”}

@app.get(“/”, headers={“accept”: “application/json;version=1.1”}) async def root(): return {“message”: “Hello World v1.1”}

Operating System

macOS

Operating System Details

No response

FastAPI Version

python -c “import fastapi; print(fastapi.version)”

Python Version

Python 3.9.7

Additional Context

No response

Issue Analytics

  • State:open
  • Created 2 years ago
  • Reactions:6
  • Comments:19 (9 by maintainers)

github_iconTop GitHub Comments

10reactions
HHK1commented, Jan 28, 2022

You could use the specification for vendor types in the Accept header, and have headers like: Accept: application/vnd.myapp.v1.1+json

Then you need to do 3 things:

  • Read that version inside an ASGI middleware
  • Decorate your routes to add the version to them
  • Override the route matches function to find the matching version

(basically what @dmontagu indicates here: https://github.com/tiangolo/fastapi/issues/200#issuecomment-525126712)

Middleware looks like this

from typing import Union, cast

from asgiref.typing import (
    ASGI3Application,
    ASGIReceiveCallable,
    ASGISendCallable,
    HTTPScope,
    Scope,
    WebSocketScope,
)
from black import re


class AcceptHeaderVersionMiddleware:
    """
    Use this middleware to parse the Accept Header if present and get an API version
    from the vendor tree. See https://www.rfc-editor.org/rfc/rfc6838#section-3.2

    If incoming http or websocket request contains an Accept header with the following
    value: `"accept/vnd.vendor_prefix.v42+json"`, the scope of the ASGI application
    will then contain an `api_version` of 42.

    If the http or websocket request does not contain an Accept header, or if the accept
    header value does not use a proper format, the scope of the ASGI application will
    then contain an `api_version` that defaults to the provided `latest_version`
    """

    def __init__(
        self, app: ASGI3Application, vendor_prefix: str, latest_version: str
    ) -> None:
        self.app = app
        self.latest_version = latest_version
        self.accept_regex = rf"^application/vnd\.{vendor_prefix}\.v([0-9]+)\+.*"

    async def __call__(
        self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
    ) -> None:
        if scope["type"] in ("http", "websocket"):
            scope = cast(Union[HTTPScope, WebSocketScope], scope)
            headers = dict(scope["headers"])
            scope["latest_version"] = self.latest_version  # type: ignore[index]
            scope["requested_version"] = self.latest_version  # type: ignore[index]

            if b"accept" in headers:
                accept_header = headers[b"accept"].decode("latin1")
                match = re.search(self.accept_regex, accept_header)
                if match is not None:
                    api_version = match.group(1)
                    if api_version is not None:
                        scope["requested_version"] = api_version  # type: ignore[index]

        return await self.app(scope, receive, send)

Router + Route subclasses:

from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union

from fastapi import APIRouter, params
from fastapi.datastructures import Default
from fastapi.types import DecoratedCallable

from starlette.responses import JSONResponse, Response, PlainTextResponse
from starlette.routing import BaseRoute, Match
from starlette.types import ASGIApp, Receive, Scope, Send
from starlette.exceptions import HTTPException
from fastapi.routing import APIRoute


class VersionedAPIRoute(APIRoute):
    @property
    def endpoint_version(self) -> str:
        return str(self.endpoint.__api_version__)  # type:ignore

    def is_version_matching(self, scope: Scope) -> bool:
        requested_version = scope["requested_version"]
        is_latest = self.endpoint_version == "latest"

        return (
            is_latest and requested_version == scope["latest_version"]
        ) or self.endpoint_version == requested_version

    def matches(self, scope: Scope) -> Tuple[Match, Scope]:
        match, child_scope = super().matches(scope)

        if match == Match.NONE or match == Match.PARTIAL:
            return match, child_scope
        if self.is_version_matching(scope):
            return Match.FULL, child_scope
        else:
            return Match.PARTIAL, child_scope

    async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
        if not self.is_version_matching(scope):
            if "app" in scope:
                raise HTTPException(
                    406,
                    f"Requested version {scope['requested_version']} does not exist. "
                    f"Latest available version is {scope['latest_version']}.",
                )
            else:
                response = PlainTextResponse("Not Acceptable", status_code=406)
            await response(scope, receive, send)
        await super().handle(scope, receive, send)


class VersionedAPIRouter(APIRouter):
    def __init__(
        self,
        *,
        prefix: str = "",
        tags: Optional[List[str]] = None,
        dependencies: Optional[Sequence[params.Depends]] = None,
        default_response_class: Type[Response] = Default(JSONResponse),
        responses: Optional[Dict[Union[int, str], Dict[str, Any]]] = None,
        callbacks: Optional[List[BaseRoute]] = None,
        routes: Optional[List[BaseRoute]] = None,
        redirect_slashes: bool = True,
        default: Optional[ASGIApp] = None,
        dependency_overrides_provider: Optional[Any] = None,
        route_class: Type[VersionedAPIRoute] = VersionedAPIRoute,
        on_startup: Optional[Sequence[Callable[[], Any]]] = None,
        on_shutdown: Optional[Sequence[Callable[[], Any]]] = None,
        deprecated: Optional[bool] = None,
        include_in_schema: bool = True,
    ) -> None:
        super().__init__(
            prefix=prefix,
            tags=tags,
            dependencies=dependencies,
            default_response_class=default_response_class,
            responses=responses,
            callbacks=callbacks,
            routes=routes,
            redirect_slashes=redirect_slashes,
            default=default,
            dependency_overrides_provider=dependency_overrides_provider,
            route_class=route_class,
            on_startup=on_startup,
            on_shutdown=on_shutdown,
            deprecated=deprecated,
            include_in_schema=include_in_schema,
        )

    def version(
        self, api_version: str
    ) -> Callable[[DecoratedCallable], DecoratedCallable]:
        def decorator(func: DecoratedCallable) -> DecoratedCallable:
            func.__api_version__ = api_version  # type:ignore
            return func

        return decorator

Then you can plug everything together:

router = VersionedAPIRouter()

GREETINGS_V1 = "Hello"
GREETINGS_V2 = "It's me"
GREETINGS_V3 = "I was wondering if after all these years you'd like to meet"
GREETINGS_LATEST = "Hello, can you hear me?"

router = VersionedAPIRouter()

@router.get("/hello")
@router.version("1")
async def hello_v1() -> Dict:
    return {"greetings": GREETINGS_V1}

@router.get("/hello")
@router.version("2")
async def hello_v2() -> Dict:
    return {"greetings": GREETINGS_V2}

@router.get("/hello")
@router.version("3")
async def hello_v3() -> Dict:
    return {"greetings": GREETINGS_V3}

@router.get("/hello")
@router.version("latest")
async def hello_latest() -> Dict:
    return {"greetings": GREETINGS_LATEST}


app = FastAPI(title="Versioned app")
app.add_middleware(
    AcceptHeaderVersionMiddleware, vendor_prefix="mytestapp", latest_version="4"
)
app.include_router(router)
1reaction
HHK1commented, Jan 28, 2022

How does this look on the swagger page? I wasn’t aware that openapi supported multi version or for the routes to have different params for the same route

Haven’t tested that yet, I’ll take a look. I was thinking that since it’s using the accept header for versioning, we could use the media type option of the open API spec: https://swagger.io/docs/specification/media-types/. That could work, at least for the response format.

Regarding request params, I don’t know exactly how that would be described in swagger.

Read more comments on GitHub >

github_iconTop Results From Across the Web

HTTP/1.1: Header Field Definitions
The Accept request-header field can be used to specify certain media types which are acceptable for the response. Accept headers can be used...
Read more >
HTTP headers | Accept - GeeksforGeeks
The HTTP Accept header is a request type header. The Accept header is used to inform the server by the client that which...
Read more >
HTTP headers - MDN Web Docs - Mozilla
HTTP headers let the client and the server pass additional information with an HTTP request or response. An HTTP header consists of its ......
Read more >
Session Initiation Protocol (SIP) Parameters
When present in a Require or Proxy-Require header field of a ... a User Agent ability of accepting a REFER request without establishing...
Read more >
HTTP header manipulation - Envoy Proxy
Envoy will always set the :scheme header while processing a request. It should always be available to filters, and should be forwarded upstream...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found