Fastapi: [QUESTION] Are there plans to make class-based-views a first-class feature?

Created on 29 May 2019  路  21Comments  路  Source: tiangolo/fastapi

Description

Hi @tiangolo! First off kudos on FastAPI, beautiful stuff. My team (about a dozen backenders, pinging one here @gvbgduh) are in the midst of porting out Py2 services to Py3 and thought it'd be a good time to move away from Flask and Flask-RESTful and into something that makes better use of the Py3 features (predominantly async). Thus far FastAPI/Starlette is the top contender but the one feature we're missing the most is class-based-views (CBVs) as opposed to the defacto function-based-views (FBVs). Thus we were wondering if there's any plans to introduce CBVs as a first-class feature in FastAPI.

While we know Starlette plays well with CBVs we looove the automagical features FastAPI offers like validation, OpenAPI generation, etc, things we had to do in very round-about ways prior.

Way we see it CBVs have the following primary perks:

  • Centralised routing since you tend to declare the different routes in one place, typically after instantiating the application.
  • Code reusability since you can easily do OOP and inherit (eg through mixins) or compose common functionality around.
  • Would make it much easier to port existing CBVs to FastAPI since we'd be coming from Flask-RESTful.

Thus far we've found we can 'hack' CBVs into FastAPI as such:

from typing import Dict

import fastapi
from starlette.endpoints import HTTPEndpoint

from app.services.api.models import Something


app = fastapi.FastAPI(debug=True)


class ResourceRoot(HTTPEndpoint):
    def get(self):
        # do stuff


class ResourceSomething(HTTPEndpoint):

    def get(self, some_id: str) -> Dict:
        # do stuff

    def post(self, something: Something) -> None:
        # do stuff


resource_root = ResourceRoot(scope={"type": "http"})
resource_something = ResourceSomething(scope={"type": "http"})
app.add_api_route(
    path="/",
    endpoint=resource_root.get,
    methods=["GET"],
)
app.add_api_route(
    path="/something",
    endpoint=resource_something.post,
    methods=["POST"]
)
app.add_api_route(
    path="/something/{some_id}",
    endpoint=resource_something.get,
    methods=["GET"]
)

and as far as we can tell we're maintaining the FastAPI fanciness but it might be a little confusing for our devs. Is there a better way to do this?

question

Most helpful comment

For anyone following this thread, I recently released fastapi-utils which provides a version of a class-based view.

It doesn't support automatically inferring decorators if you name your method get, post, etc., but given the amount of info that currently goes into the decorators, I'm not sure how useful that would be anyway.

What it does do is allow you to reuse dependencies across multiple routes without repeating them in each signature by just declaring them as class attributes.

If you are interested, you can read the documentation here: https://fastapi-utils.davidmontague.xyz/user-guide/class-based-views/

All 21 comments

I also want this feature. Great, support!

FWIW, I'm looking for this feature because I have a HTTP header I need to pass through to external APIs - right now I'm passing it through a bunch of different functions, would be nice to have a class so I can just do self.my_header = whatever and just pass in self.my_header where I need to.

@knyghty wouldn't a middleware make more sense there?

@somada141 it was my first thought, but I don't only need to replicate the header on the response, but also to other requests to external services I'm making.

Hi @tiangolo! First off kudos on FastAPI, beautiful stuff.

I'm glad you're liking it!

[...] CBVs [...]

Thanks for the request and the explanation @somada141 .

Help me understand something, in your example, the same ResourceSomething has two different URL paths, /something and /something/{some_id}.

From the examples I can see in Flask-restful and the ones for HTTPEndpoint in Starlette, I see that class-based views would only be able to handle one path operation at a time, right?

So, it would have to be a class for /something, I guess with get to read all the resources of that type and post to create new ones.

...and then another class for /something/{some_id}, with put to update an item and get to read a specific item.

Is that correct? How would you expect that to work?


Maybe to put it another way, how would you imagine the code you write using FastAPI with class-based views? How would it look like?

@tiangolo you're right per the Flask-RESTful paradigm you'd have one path per resource-class though I've seen cases where different HTTP methods and paths were assigned to the same class. Nonetheless I wouldn't think that's necessary for a clean CBV approach.

A simplified approach would be:
1 endpoint - 1 path - 1 resource class - N methods (eg GETting, POSTing, PUTting an item etc)

Hi @tiangolo, thanks for the great effort!

It's really great how it's hooked with pydantic!

But, yeah, CBV would be really great. It especially pays back while the project is growing. There're many use cases for them and advantages.

Say, you can have one endpoint/view/resource for the entity, like

class Employee:
    async def get(...)  # List
    async def post(...)
    async def put(...)
    async def delete(...)
    ...

That'll allow to cover all actions in the one endpoint for one route, say /employees (it doesn't prevent to define several routes to this endpoint) and the differences in payloads can be handled separate schemas, like it would be common to pass filter, limit, offset to the GET one, full dict for POST and partial one for PATCH.
The great adventure here is that response schema will be same and the context of the fields can be fully defined in the model of this entity.

Between entities there might a lot of logic shared (for pagination, filtering, creating, updating, deleting, etc.) and the difference might be only in the model reference (not always though), so that staff can be moved out as example

class EntitiesCollection:
    model = None
    async def(...):
        # query model with the given params
    async post(...):
    ...

So, the target model can become:

class Employee(EntitiesCollection):
    model = EmployeeORMModel

Sometimes, it's not aligned that well or some additional functionality needed that's logically separated from the model itself, so it's handy move to mixins, like
class PermissionRequiredMixin: or class LoginRequiredMixin: or JWTAuthMixin, so the parent class can become

class EntitiesCollection(JWTAuthMixin, PermissionRequiredMixin)

and all child classes will comply.

Or there can be a shared business logic, I'm making it up, but say there're also departments and as well as employees that should comply to some business rules, that can be implemented in the parent class or in the mixin and reused across related endpoints.

It's not impossible with function based views, but it's becoming quite verbose with the project growth.

It covers not all cases though, in terms of API flask-RESTful and Django REST Framework are quite bright examples, but they have a lot of stuff in it and it might be quite reckless to repeat all of that but generic support for HTTPEndpoint would be great, so dev can decide what they can implement and if it's required some structures of that can be implemented as add-ons to the fastapi.

starlette has starlette.endpoints.HTTPEndpoint and from what I got the support of it is dropped in fastapi.routing.APIRoute
with explicit restriction to the function or the method - https://github.com/tiangolo/fastapi/blob/master/fastapi/routing.py#L285
so it's processed accordingly bellow with request_response in https://github.com/tiangolo/fastapi/blob/master/fastapi/routing.py#L296

while startlette.routing.Route allows to handle HTTPEndpoint as well

        if inspect.isfunction(endpoint) or inspect.ismethod(endpoint):
            # Endpoint is function or method. Treat it as `func(request) -> response`.
            self.app = request_response(endpoint)
            if methods is None:
                methods = ["GET"]
        else:
            # Endpoint is a class. Treat it as ASGI.
            self.app = endpoint

https://github.com/encode/starlette/blob/master/starlette/routing.py#L152:L159

So, I wonder if could be possible to introduce it as well keeping the functionality fastapi already provides. That would be fantastic.

If you need some assistance I might be able to help to come up with a PR.

Thanks for the feedback and arguments! I'll check it thoroughly soon.

Right now I'm thinking about if the decorator should be on the class or on each method, as the decorator has the path and also the response model, etc

yeah, I think I see the problem here, it's an interesting dilemma.
Logically, it would be nice to have it on the class as in general HTTPEndpoint.dispatch should take care of further flow, so like

@app.route("/some_url")
class SomeEndpoint(HTTPEndpoint):
    async def get(self, request):
        # some logic
    async def post(self, request):
        # some logic
    ...

and it would also allow to do it as

api_v1 = Router([
    Route('/some_url', endpoint=SomeEndpoint, methods=['GET', 'POST', 'PATCH', 'DELETE]),
    ...
])

# And if there're some apps
app = FastAPI()
app.mount('/api_v1', app=api_v1)

But indeed complication comes from the response schemas.

As inherited from starlette.routing.Route the call is made in APIRoute.__call__.
In general request_response does the same as HTTPEndpoint.dispatch.

Both request_response and HTTPEndpoint.__init__ comply with args/interface (scope: Scope, receive: Receive, send: Send).
In fastapi request_response is given enhanced get_app where all magic happens.

But the problem arises as get_app takes args in APIRoute.__init__ as part of the whole app instantiation, but the HTTPEnpoint is actually instantiated in the Route.__call__, where request_response has already prepared get_app func. It works well as request_response takes relevant scope every request, but the HTTPEnpoint class has to instantiated at the APIRoute.__call__ time.

But it's the time where APIRoute.__call__ has signature
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
and uses request_response or HTTPEnpoint.__init__ with those params.
https://github.com/encode/starlette/blob/master/starlette/routing.py#L199:L207

That's where I hit the wall.

Hm, after all that long thought I think I might have an idea.

So at the APIRoute.__init__ step we need an Endpoint class (_not an instance_) that respects

    dependant: Dependant,
    body_field: Field = None,
    status_code: int = 200,
    response_class: Type[Response] = JSONResponse,
    response_field: Field = None,
    response_model_include: Set[str] = None,
    response_model_exclude: Set[str] = set(),
    response_model_by_alias: bool = True,
    response_model_skip_defaults: bool = False,
    dependency_overrides_provider: Any = None,

that can be later instantiated in the APIRoute.__call__ and use them in the HTTPEndpoint.dispatch (in the same way get_app does), ideally without overriding the inherited __call__ method. And having a fresh instance every request.

And that sounds to me like the metaclass recipe.
Now I wonder how far it can fly.

I was mulling over this one myself yday. A method decorator would be convenient in terms of providing the same arguments for summary, description, responses, model, etc but would not cover the instantiation of the resource-class (e.g. in the way Flask-Restful does it, https://flask-restful.readthedocs.io/en/0.3.5/intermediate-usage.html#passing-constructor-parameters-into-resources).

At that point a method-decorator would effectively boil down to a solution similar to what I showed originally where instantiation happens outside of FastAPI having methods 'passed' to FastAPI individually. That would maintain the same functionality and preclude interface compliance tricks but might be confusing for developers. Arguably, a method decorator could 'imply' a FastAPI-managed instantiation of the encompassing class but it would be hard to guarantee a 1-N class-methods instantiations without keeping track of 'related' decorators.

Alternatively I suppose is would be possible to decorate both as in decorate the class designating it as a resource-class and have a decorator that is a subset of add_api_route defining parameters like the response model, etc on the method itself. I've never written decorator 'pairs' like this so I'm not sure what the repercussions would be though.

I was playing around with this, and it looks you can implement class-based views today by annotating self as a Depends -- no need for any new features!

Well, to be clear, you still have to decorate the individual methods, but this enables you to:

  1. reuse dependency declarations
  2. reuse initialization logic
  3. get reusable cleanup logic without resorting to middleware (similar to the goals of a context-manager dependency, I believe)

And as far as I can tell, this causes no issues even with strict mypy.

Here is a self-contained example showing it basically works with everything:

from fastapi import FastAPI, Depends
from starlette.testclient import TestClient


async def async_dep():
    return 1


def sync_dep():
    return 2


app = FastAPI()


class DepClass:
    def __init__(self, int1: int = Depends(async_dep), int2: int = Depends(sync_dep)) -> None:
        print("Initializing DepClass instance")
        self.int1 = int1
        self.int2 = int2


class MyRoutes(DepClass):
    @app.get("/method_test_sync")
    def sync_route(self: DepClass = Depends()):
        print("sync")
        return self.int1, self.int2

    @app.get("/method_test_async")
    async def async_route(self: DepClass = Depends()):
        print("async")
        return self.int1, self.int2


client = TestClient(app)
print(client.get("/method_test_sync").content)
"""
Initializing DepClass instance
sync
b'[1,2]'
"""

print(client.get("/method_test_async").content)
"""
Initializing DepClass instance
async
b'[1,2]'
"""

(It also works if you use self = Depends(DepClass) instead).

Here is an example showing it works with inheritance, and that the __del__ method works as a way to trigger cleanup:

class DepSubclass(DepClass):
    def __init__(self, int1: int = Depends(async_dep), int2: int = Depends(sync_dep)) -> None:
        print("Initializing DepSubClass instance")
        super().__init__(int1, 2 * int2)

    def __del__(self):
        print("Cleaning up DepSubclass instance")



class MoreRoutes(DepSubclass):
    @app.get("/subclass_test")
    def route(self: DepSubclass = Depends()):
        return self.int1, self.int2


print(client.get("/subclass_test").content)
"""
Initializing DepSubClass instance
Initializing DepClass instance
Cleaning up DepSubclass instance
b'[1,4]'
"""

Note that __init__ and __del__ both have to be synchronous, but it is still possible to leverage async here:

  • FastAPI handles async dependencies in the usual way, letting you grab the results of any async calls you need to make as inputs to the __init__.
  • You can use __del__ for asynchronous cleanup via asyncio.create_task.

    • (Well, I haven't tried this for anything special but I think it will work. Maybe __del__ has some gotchas that prevent it from covering all clean-up cases, but I think it should cover most if not all.)


The lone awkwardness I see here is that you have to annotate self as a superclass (which is admittedly nonstandard since at evaluation time self will actually only be an instance of the superclass). Otherwise, the annotation won't be a callable when the route decorator is applied, and decoration will fail. If I were to request any modifications to FastAPI to support CBVs, I think it would be a way to have the decorator (lazily?) infer the type of self without needing explicit annotation. But I think even that is more of a nice-to-have than a need-to-have for me to use this capability today.

(Also, this wouldn't be able to replace the use of decorators with pure method names, but you can always use functools.partial or similar to reduce repetition.)

@somada141 @gbgduh @knyghty @bs32g1038 @tiangolo let me know if you see any obvious gaps in this approach, or hate the idea of using Depends to set a default value for self.

@dmontagu thanks for the approach, I wouldn't have considered this type of thing! Apart from the jarring self annotation my primary beef with this would be that it encourages decentralised routing since the decorators would be all over the place but then again this may be my own pet peeve.

Apart from that I don't see any obvious gaps but it would be preferable if that kinda thing could be 'hidden away' in the FastAPI code (and exposed as some class you inherit in your CBVs) cause I think it would be confusing for peeps.

Curious to see what the others think.

@somada141 I played with it some more and got the interface a lot cleaner:

app = FastAPI()
lazy_app: FastAPI = LazyDecoratorGenerator(app)

def get_1():
    return 1

def get_2():
    return 2

def get_3():
    return 3

class RouteBase(BaseDependencies):
    x: int = Depends(get_1)

class MyRoutes(RouteBase):
    y: int = Depends(get_2)

    @lazy_app.get("/sync_method_test")
    def sync_route(self):
        return self.x

    @lazy_app.get("/async_method_test")
    async def async_route(self, z: int = Depends(get_3)):
        return self.y, z

print(TestClient(app).get("/sync_method_test").content)
# b'1'
print(TestClient(app).get("/async_method_test").content)
# b'[2,3]'

The setup is ugly and could use some improvement, but it does work. (I included the setup code below if you want to try it out, just paste it on top of the code above.)

I had to use LazyDecoratorGenerator to delay the evaluation of the decorators, but at least now there is minimal syntactic cruft. With a "proper" implementation, maybe that need could be removed as well.


Setup

import inspect
import sys
from copy import deepcopy
from inspect import Parameter, signature
from typing import TYPE_CHECKING, Any, Dict, List, Tuple

from fastapi import Depends, FastAPI
from fastapi.params import Depends as DependsClass
from pydantic.utils import resolve_annotations
from starlette.testclient import TestClient

LazyDecoratorCall = Any
LazyMethodCall = Tuple[str, Tuple[Any, ...], Dict[str, Any]]


class _LazyDecoratorGenerator:
    def __init__(self, eager_instance: Any):
        self.eager_instance = eager_instance
        self.self_calls: List[LazyDecoratorCall] = []
        self.method_calls: List[Tuple[LazyMethodCall, _LazyDecoratorGenerator]] = []

    def __call__(self, func):
        self.self_calls.append(func)
        return func

    def __getattr__(self, name: str):
        def f(*args, **kwargs) -> _LazyDecoratorGenerator:
            subcaller = _LazyDecoratorGenerator(None)
            self.method_calls.append(((name, args, kwargs), subcaller))
            return subcaller

        return f

    def execute(self):
        for value in self.self_calls:
            self.eager_instance(value)

        for (name, args, kwargs), subcaller in self.method_calls:
            eager_subcaller = getattr(self.eager_instance, name)(*args, **kwargs)
            subcaller.eager_instance = eager_subcaller
            subcaller.execute()
        self.self_calls = []
        self.method_calls = []


def LazyDecoratorGenerator(eager_instance: Any) -> Any:
    return _LazyDecoratorGenerator(eager_instance)


class DependenciesMeta(type):
    def __new__(mcs, name, bases, namespace):
        local_funcs = {}
        dependencies = {}
        for base in reversed(bases):
            if issubclass(base, BaseDependencies) and base != BaseDependencies:
                dependencies.update(deepcopy(base.__dependencies__))

        annotations = namespace.get("__annotations__", {})
        if sys.version_info >= (3, 7):
            annotations = resolve_annotations(annotations, namespace.get("__module__", None))

        for k, v in namespace.items():
            if inspect.isfunction(v):
                local_funcs[k] = v
            elif isinstance(v, DependsClass):
                dependencies[k] = (annotations.get(k), v)

        namespace["__dependencies__"] = dependencies
        namespace["__local_funcs__"] = local_funcs
        return super().__new__(mcs, name, bases, namespace)


class BaseDependencies(metaclass=DependenciesMeta):
    if TYPE_CHECKING:
        __dependencies__: Dict[str, Tuple[Any, Any]]
        __local_funcs__: Dict[str, Any]

    def __init__(self, **kwargs):
        for k, v in kwargs.items():
            setattr(self, k, v)

    def __init_subclass__(cls, **kwargs):
        for name, member in cls.__local_funcs__.items():
            sig = signature(member)
            old_params: List[Parameter] = list(sig.parameters.values())
            new_first_param = old_params[0].replace(default=Depends(cls))
            new_remaining_params = [old_param.replace(kind=Parameter.KEYWORD_ONLY) for old_param in old_params[1:]]
            new_params = [new_first_param] + new_remaining_params
            member.__signature__ = sig.replace(parameters=new_params)

        sig = signature(cls.__init__)
        annotations = cls.__annotations__
        old_parameters = list(sig.parameters.values())
        new_parameters = [old_parameters[0]]
        for name, (annotation, value) in cls.__dependencies__.items():
            parameter = Parameter(name=name, kind=Parameter.KEYWORD_ONLY, default=value, annotation=annotation)
            new_parameters.append(parameter)
        sig = sig.replace(parameters=new_parameters)
        cls.__init__.__signature__ = sig

        for k, v in globals().items():
            if isinstance(v, _LazyDecoratorGenerator):
                v.execute()

I've gotten my implementation of this feature much simpler and more robust (though it depends on the nuances of some behavior of FastAPI that I could imagine changing in the future); see this gist if you are interested.

Hi,

I don't know if you've heard of Cornice already but it deals with the two paths issue by defining two attributes for a given resource: collection_path and path.

from cornice.resource import resource

_USERS = {1: {'name': 'gawel'}, 2: {'name': 'tarek'}}

@resource(collection_path='/users', path='/users/{id}')
class User(object):

    def __init__(self, request, context=None):
        self.request = request

    def __acl__(self):
        return [(Allow, Everyone, 'everything')]

    def collection_get(self):
        return {'users': _USERS.keys()}

    def get(self):
        return _USERS.get(int(self.request.matchdict['id']))

    def collection_post(self):
        print(self.request.json_body)
        _USERS[len(_USERS) + 1] = self.request.json_body
        return True

The biggest problem I see with this approach is that you typically at least want to provide a response model when using fastapi, so you still need decorators on all endpoints. Well, it鈥檚 not a problem per se, but I don鈥檛 think there鈥檚 much benefit to this design until the decorators can be removed from most endpoints.

I鈥檝e written a route class that uses the annotated return type as the default choice for response model; integrating that as an option might help. I don鈥檛 know what other decorator options are frequently used by other people though; probably not worth creating a new set of decorators just for this purpose.

For anyone following this thread, I recently released fastapi-utils which provides a version of a class-based view.

It doesn't support automatically inferring decorators if you name your method get, post, etc., but given the amount of info that currently goes into the decorators, I'm not sure how useful that would be anyway.

What it does do is allow you to reuse dependencies across multiple routes without repeating them in each signature by just declaring them as class attributes.

If you are interested, you can read the documentation here: https://fastapi-utils.davidmontague.xyz/user-guide/class-based-views/

Given all the arguments above, I think class-based views do provide extra value and it would make sense to have them in FastAPI itself, but I haven't had the time to investigate much about it.

Right now @dmontagu's CBV implementation in fastapi-utils is the best option (it's quite clever). :rocket:

Maybe at some point in the future, we'll have CBV in FastAPI itself, but if you need them now, fastapi-utils should be the best option. And maybe later we can integrate its CBVs (or ideas from it) into FastAPI itself :grimacing: .

The work done by @dmontagu seems fantastic and exactly what I was hoping for. I think this will more than enough for our work so I'll close this issue for now. Thanks heaps @dmontagu and @tiangolo!

A relatively simple snippet packed with magic: it lets you use class methods as dependencies or API endpoints.

""" A simple tool to implement class-based views for FastAPI.

It is a decorator that takes a method, and patches it to have the `self` argument
to be declared as a dependency on the class constructor:

* Arguments of the method become dependencies
* Arguments of the __init__() method become dependencies as well
* You can use the method as a dependency, or as an API endpoint.

Example:

    class View:
        def __init__(self, arg: str):  # Arguments are dependencies (?arg)
            self.arg = arg

        @fastapi_compatible_method
        async def get(self, another_arg: str):  # arguments are dependencies (?another_arg)
            return {
                'arg': self.arg,
                'another_arg': another_arg,
            }

    app.get('/test')(View.get)
    # Check: http://localhost:8000/test?arg=value&another_arg=value

It is a little awkward: you can't decorate the method with @app.get() directly: it's only possible
to do it after the class has fully created.

Thanks to: @dmontagu for the inspiration
"""
import inspect
from typing import Callable, TypeVar, Any

from app.src.deps import Depends

ClassmethodT = TypeVar('ClassmethodT', bound=classmethod)
MethodT = TypeVar('MethodT', bound=Callable)


class fastapi_compatible_method:
    """ Decorate a method to make it compatible with FastAPI """

    # It is a descriptor: it wraps a method, and as soon as the method gets associated with a class,
    # it patches the `self` argument with the class dependency and dissolves without leaving a trace.

    def __init__(self, method: Callable):
        self.method = method

    def __set_name__(self, cls: type, method_name: str):
        # Patch the function to become compatible with FastAPI.
        # We only have to declare `self` as a dependency on the class itself: `self = Depends(cls)`.
        patched_method = set_parameter_default(self.method, 'self', Depends(cls))
        # Put the method onto the class. This makes our descriptor go completely away
        return setattr(cls, method_name, patched_method)


def set_parameter_default(func: Callable, param: str, default: Any) -> Callable:
    """ Set a default value for one function parameter; make all other defaults equal to `...`

    This function is normally used to set a default value for `self` or `cls`:
    weird magic that makes FastAPI treat the argument as a dependency.
    All other arguments become keyword-only, because otherwise, Python won't let this function exist.

    Example:
        set_parameter_default(Cls.method, 'self', Depends(Cls))
    """
    # Get the signature
    sig = inspect.signature(func)
    assert param in sig.parameters  # make sure the parameter even exists

    # Make a new parameter list
    new_parameters = []
    for name, parameter in sig.parameters.items():
        # The `self` parameter
        if name == param:
            # Give it the default value
            parameter = parameter.replace(default=default)
        # Positional parameters
        elif parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
            # Make them keyword-only
            # We have to do it because func(one = default, b, c, d) does not make sense in Python
            parameter = parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY)
        # Other arguments, e.g. variadic: leave them as they are
        new_parameters.append(parameter)

    # Replace the signature
    setattr(func, '__signature__', sig.replace(parameters=new_parameters))
    return func

The awkward part is that you cannot decorate a method with @app.get() directly; you'll have to do it after the class is fully constructed.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

sm-Fifteen picture sm-Fifteen  路  29Comments

ArtsiomD01 picture ArtsiomD01  路  28Comments

tchiotludo picture tchiotludo  路  21Comments

sandys picture sandys  路  23Comments

Spenhouet picture Spenhouet  路  21Comments