Existing Projects#

Restly adds views to a FastAPI application that you already have. It does not replace the application, and it does not need its own project layout. Three facts make this work:

  • fr.include_view() registers the routes of a view on any FastAPI app or APIRouter, next to the routes that are already there.

  • There is no global Restly router. A view has routes only where you call fr.include_view().

  • There is no autodiscovery. Nothing scans your packages for views or models. You import each view and register it yourself.

Restly also needs one call to fr.configure() at startup, which tells it where its database sessions come from; see Database & Configuration.

To start a new project instead, see New Projects. That page also shows the layout that we recommend as an application grows.

Add a view next to your existing routes#

Call fr.include_view() where you already compose your routes. Your existing routers and the new views can share the same app or router. They also share your login check and your database session:

from fastapi import APIRouter, Depends, FastAPI
import fastapi_restly as fr

from .auth import require_login       # the login check your routes already use
from .db import get_db                # the session generator your routes already use
from .orders import router as orders_router

app = FastAPI()
fr.configure(app, async_session_generator=get_db)

api = APIRouter(prefix="/api")

api.include_router(orders_router, prefix="/orders")  # existing FastAPI routes


class UserView(fr.AsyncRestView):
    prefix = "/users"
    dependencies = [Depends(require_login)]
    model = User
    schema = UserSchema


fr.include_view(api, UserView)  # registers the /api/users CRUD routes

app.include_router(api)

You adopt Restly one resource at a time. In the example, the orders routes stay hand-written, and the users routes come from UserView. If you add a ProductView later, the orders router does not change.

require_login runs before every route of the view; see Run a dependency on every route. FastAPI calls get_db once for each request, so the view and your dependencies that use Depends(get_db), such as require_login, get the same session. An override of get_db in app.dependency_overrides gives the views their session too, so a test setup that overrides get_db keeps working; see Use the view’s session in your own dependencies. A sync app passes its generator as session_generator, and its views are fr.RestView. Before you pass get_db, check the two rules in the next section.

Details

fr.include_view() also works as a class decorator; Views explains both forms. Pass app to fr.configure(), also when the views are on a router. That call checks the OpenAPI schema of the app for two classes with the same name, and fr.include_view() on a router cannot do this; see Database & Configuration. The check also covers the routes that you had before Restly. So the first warning can name two classes of your own, which OpenAPI already showed under long names; see Name your schemas.

A plain FastAPI route can also sit next to a view, for an endpoint that is not part of the CRUD routes:

@api.get("/users/{id}/export")
async def export_user(id: int):
    ...

Two rules for your get_db#

A get_db that works for your own routes can still break the views.

Create its sessionmaker with expire_on_commit=False, and for async views also with autoflush=False, as Restly does:

SessionLocal = async_sessionmaker(engine, expire_on_commit=False, autoflush=False)

Restly reads the response from the object after the commit. With SQLAlchemy’s default, expire_on_commit=True, that read fails in an async view with MissingGreenlet, so every POST and PATCH answers 500. A sync view still works, but snapshot(new) in after_action_commit is empty. Restly warns once for each view, sync or async, that writes through such a session.

And get_db must not commit. A get_db that commits after the yield saves changes that should be rolled back, such as those of a route that forgot to commit. Remove that commit:

async def get_db():
    async with SessionLocal() as session:
        yield session
        # no session.commit() here

Warning

First add a commit to each of your own routes that writes. A route that still relies on the commit in get_db answers 200, but its writes are lost, and nothing warns you.

Build sessions with your own generator has the details of both rules.

Write one CRUD route by hand#

To write one of the default CRUD routes yourself, leave it out of the view with exclude_routes and add your own FastAPI route:

class UserView(fr.AsyncRestView):
    prefix = "/users"
    model = User
    schema = UserSchema
    exclude_routes = (fr.ViewRoute.DELETE,)


fr.include_view(api, UserView)


@api.delete("/users/{id}", status_code=204)
async def delete_user(id: int):
    ...

UserView still serves list, create, read and update. Your FastAPI route serves DELETE. When the HTTP contract stays the same and only the logic changes, override a business method instead, such as delete. For a different status code, response shape or query interface, see Request Lifecycle & Hooks.

Move a resource back to plain FastAPI#

A resource has Restly routes only where you call fr.include_view(). To move it back to plain FastAPI, remove that call and register an APIRouter with the same prefix and path operations. Your models, schemas, dependencies and session setup can stay where they are.

Replace a hand-written router with a view#

Before you replace a hand-written CRUD router with a RestView, compare the two HTTP contracts:

  1. Map your routes to the default CRUD routes of the view: GET /, POST /, and GET, PATCH and DELETE on /{id} (the exact contract). Other routes on the router, such as exports and actions, become custom routes on the view or stay as plain FastAPI routes next to it.

  2. Keep routes with a different contract out of the change. For example, a PUT update or a delete that does not answer 204 can stay hand-written: leave it out with exclude_routes until you change its contract.

  3. Write tests before you change anything. Test the responses of the old router with RestlyTestClient. Then replace the router with the view and run the same tests again. A changed payload or status code then shows at once.

A resource in the middle of this change looks like this:

class ProductView(fr.AsyncRestView):
    prefix = "/products"
    model = Product
    schema = ProductSchema          # match your old response shape exactly
    exclude_routes = (fr.ViewRoute.DELETE,)  # the old DELETE returns the object


fr.include_view(api, ProductView)
api.include_router(legacy_delete_router)  # until the contract is changed

Keep your engine and your models#

Restly can use the database setup and the models that your application already has:

See also#