内容简介:FastAPI actually plays very well with DjangoYou know me, I’m aMy only issue with Django was that it never really had a good way of making APIs. I hate DRF with somewhat of a passion, I always found its API way too complicated and verbose, and never managed
FastAPI actually plays very well with Django
You know me, I’m a Django fan. It’s my preferred way of developing web apps, mainly because of the absolutely vast ecosystem of apps and libraries it has, and the fact that it is really well-designed. I love how modular it is, and how it lets you use any of the parts you like and forget about the ones you don’t want. This is going to be emphasized rather spectacularly in this article, as I’m going to do things nobody should ever have to do.
My only issue with Django was that it never really had a good way of making APIs. I hate DRF with somewhat of a passion, I always found its API way too complicated and verbose, and never managed to grok it. Even the simplest things felt cumbersome, and the moment your API objects deviated from looking exactly like your DB models, you were in a world of hurt. I generally prefer writing a simple class-based view for my APIs, but then I don’t get automatic docs and other niceties.
It’s no surprise, then, that when I found FastAPI I was really excited, I really liked it’s autogenerated docs, dependency injection system, and lack of magical “request” objects or big JSON blobs. It looked very simple, well-architected and with sane defaults, and I seriously considered developing the API for my company’s next product on it, but was apprehensive about two things: It lacked Django’s ecosystem, and it didn’t have an ORM as good and well-integrated as Django’s. I would also miss Django’s admin interface a lot.
It would have been great if FastAPI was a Django library, but I guess the asynchronicity wouldn’t have been possible. Still, there’s no reason for DRF not to have an API as nice as FastAPI’s, but there’s no helping that. A fantastical notion caught hold of me: What if I could combine FastAPI’s view serving with Django’s ORM and apps? Verily, I say unto thee, it would be rad.
And that’s exactly what I did. Here’s how:
General details
Each part of this unholy union needed a different integration. I will go into details on each part here, and post code in the next section.
High-level overview
The way this profane joining works is by using FastAPI as the view layer, and importing and using parts of Django separately. This means that some things like middleware will obviously not work, since Django is not handling views at all.
I also didn’t try to get asynchronicity working, which might be a dealbreaker for many people. I suspect I’d have issues, as Django doesn’t support it very well yet, and the ORM would probably block all view threads. If you do get it working, or try to, please leave a comment here or contact me.
ORM
The ORM works quite well, basically exactly as if you were using Django. Migrations work fine, and any app that interfaces with or enhances the ORM should also work properly.
Tests
Tests are working great as well, using Django’s ./manage.py test
harness/discovery.
If you want to use another runner like pytest, that should also work properly, though I haven’t tested it.
To clarify, we’ll be testing the FastAPI
views using Django’s test runner and ./manage.py test
. The one problem I had was that the usual TestCase
class didn’t work (it gave me “table is locked” errors), but TransactionTestCase
works splendidly and is probably quite a bit faster, too.
The admin and other views
The admin is a vital part of Django, it makes dealing with your models much, much easier than having to wrangle with the database, so it was important to me to get working. And I did! It’s a pretty hacky way, but it works perfectly, since the trick is to just run Django on another port and have it serve the admin. You can have it serve any other views you want, this way you can use FastAPI to serve your API and use Django for static files and everything else (assuming you have anything else).
All you need is a second stanza in your reverse proxy.
The code
Without further delay, the part you’ve all been waiting for, the code. This section includes both the bare essentials for getting the integration working, and some best practices I’ve found when working with FastAPI, such as URL reversing, easier DB model to API model conversion, etc. I hope you’ll find it useful.
Starting out
To begin, install FastAPI, Uvicorn and Django and create a Django project as you normally would.
I recommend my “new project” template
, it contains various niceties you may want to use.
We’re going to replace some parts and add others.
I’ve created a project called goatfish
and an app called main
to illustrate, you can call yours whatever you want.
Make sure you add "main"
to Django’s INSTALLED_APPS
list in the settings so it discovers the models and migrations.
The models
We should probably start with our data model so everything else makes sense.
The API is going to be a straightforward CRUD API, which will serve a model we’ll call Simulation
and provide authentication.
Since we have an API and a database, and the models for the two are neither semantically nor functionally identical, we’ll have two sections in our models.py
, one for each model type.
main/models.py:
from typing import Any from typing import Dict from typing import List import shortuuid from django.contrib.auth.models import AbstractUser from django.db import models from pydantic import BaseModel def generate_uuid() -> str: """Generate a UUID.""" return shortuuid.ShortUUID().random(20) ########## # Models # class CharIDModel(models.Model): """Base model that gives children random string UUIDs.""" id = models.CharField( max_length=30, primary_key=True, default=generate_uuid, editable=False ) class Meta: abstract = True class User(AbstractUser): api_key = models.CharField( max_length=100, default=generate_uuid, db_index=True, ) def __str__(self): return self.username class Simulation(CharIDModel): user = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=300) start_date = models.DateField() end_date = models.DateField() @classmethod def from_api(cls, user: User, model: "DamnFastAPISimulation"): """ Return a Simulation instance from an APISimulation instance. """ return cls( user=user, name=model.name, start_date=model.start_date, end_date=model.end_date, ) def update_from_api(self, api_model: "DamnFastAPISimulation"): """ Update the Simulation Django model from an APISimulation instance. """ self.name = api_model.name self.start_date = api_model.start_date self.end_date = api_model.end_date def __str__(self): return self.name ########## # Types # # Over here I'm really ticked off I need to make two models just to # exclude the id field from being required in the bodies of POSTs. # # Please comment on this bug if you feel as strongly: # https://github.com/tiangolo/fastapi/issues/1357 class DamnFastAPISimulation(BaseModel): name: str start_date: date end_date: date @classmethod def from_model(cls, instance: Simulation): """ Convert a Django Simulation model instance to an APISimulation instance. """ return cls( id=instance.id, name=instance.name, start_date=instance.start_date, end_date=instance.end_date, ) class APISimulation(DamnFastAPISimulation): id: str class APISimulations(BaseModel): items: List[APISimulation] @classmethod def from_qs(cls, qs): """ Convert a Django Simulation queryset to APISimulation instances. """ return cls(items=[APISimulation.from_model(i) for i in qs])
A few notes:
Each class instance has helper methods, like from_api
, from_model
, from_qs
, etc to facilitate converting between API-level and DB-level objects easily.
Also, unfortunately we have to make two separate type classes just to avoid having the id
field show up in the POST
request, as the user of the API should not be able to send/set the id when creating a new object.
It’s annoying, and I have opened an issue on FastAPI’s GitHub
.
The entry point
To serve requests, we need a place for our wsgi app to live.
The best place is, naturally, wsgi.py
:
goatfish/wsgi.py:
import os from django.core.wsgi import get_wsgi_application from fastapi import FastAPI os.environ.setdefault("DJANGO_SETTINGS_MODULE", "goatfish.settings") application = get_wsgi_application() from main.urls import router as main_router app = FastAPI( title="Goatfish", description="A demo project. Also, an actual fish with a weird name.", version="We aren't doing versions yet. Point oh.", ) app.include_router(main_router, prefix="/api")
This will allow Uvicorn to load and serve our app.
Keep in mind that app
is FastAPI’s entry point and application
is Django’s.
You’ll need to remember that if you deploy them to something that needs to load the WSGI entry points.
You can start the server by running uvicorn goatfish.wsgi:app --reload
, but it’ll probably crash at this point because there are no URL routes yet.
Let’s add them.
The routes
I prefer the Django convention of having each app’s route in a urls.py
in the app directory, so let’s put them there:
main/urls.py:
from fastapi import APIRouter from main import views # The API model for one object. from main.models import APISimulation # The API model for a collection of objects. from main.models import APISimulations router = APIRouter() router.get( "/simulation/", summary="Retrieve a list of all the simulations.", tags=["simulations"], response_model=APISimulations, name="simulations-get", )(views.simulations_get) router.post( "/simulation/", summary="Create a new simulation.", tags=["simulations"], response_model=APISimulation, name="simulations-post", )(views.simulation_post) router.get( "/simulation/{simulation_id}/", summary="Retrieve a specific simulation.", tags=["simulations"], response_model=APISimulation, name="simulation-get", )(views.simulation_get) router.put( "/simulation/{simulation_id}/", summary="Update a simulation.", tags=["simulations"], response_model=APISimulation, name="simulation-put", )(views.simulation_put) router.delete( "/simulation/{simulation_id}/", summary="Delete a simulation.", tags=["simulations"], response_model=APISimulation, name="simulation-delete", )(views.simulation_delete)
This should be pretty straightforward. summary
and tags
are used purely for the documentation site, they have no other functional purpose. name
is used for getting a route’s URL by name, and response_model
is used for validation of the response and documentation.
Onto the views!
The views
The views are more straightforward than you’d expect.
We have the GET/POST on the collection and GET/POST/PUT/DELETE on the object, but the heavy lifting is done by the from_model
/ from_api
methods.
Also, notice how authentication is done with the Depends(get_user)
dependency, making it mandatory for each endpoint, and the simulation
parameter is an actual Simulation
model instance, not an ID.
The model instance also gets validated to make sure it actually belongs to the user.
We’ll see exactly how in a later section.
main/views.py:
from fastapi import Body from fastapi import Depends from .models import APISimulation from .models import APISimulations from .models import APIUser from .models import DamnFastAPISimulation from .models import Simulation from .models import User from .utils import get_simulation from .utils import get_user def simulations_get(user: User = Depends(get_user)) -> APISimulations: """ Return a list of available simulations. """ simulations = user.simulation_set.all() api_simulations = [APISimulation.from_model(s) for s in simulations] return APISimulations(items=api_simulations) def simulation_post( simulation: DamnFastAPISimulation, user: User = Depends(get_user) ) -> APISimulation: """ Create a new simulation. """ s = Simulation.from_api(user, simulation) s.save() return APISimulation.from_model(s) def simulation_get( simulation: Simulation = Depends(get_simulation), user: User = Depends(get_user) ) -> APISimulation: """ Return a specific simulation object. """ return APISimulation.from_model(simulation) def simulation_put( simulation: Simulation = Depends(get_simulation), sim_body: DamnFastAPISimulation = Body(...), user: User = Depends(get_user), ) -> APISimulation: """ Update a simulation. """ simulation.update_from_api(sim_body) simulation.save() return APISimulation.from_model(simulation) def simulation_delete( simulation: Simulation = Depends(get_simulation), user: User = Depends(get_user) ) -> APISimulation: """ Delete a simulation. """ d = APISimulation.from_model(simulation) simulation.delete() return d
Utils
In this file we define methods to authenticate users and retrieve objects from the database by their ID while still making sure they belong to the authenticating user. get_object
is a generic function to avoid repeating ourselves for every one of our models.
main/utils.py:
from typing import Type from django.db import models from fastapi import Header from fastapi import HTTPException from fastapi import Path from main.models import Simulation from main.models import User # This is to avoid typing it once every object. API_KEY_HEADER = Header(..., description="The user's API key.") def get_user(x_api_key: str = API_KEY_HEADER) -> User: """ Retrieve the user by the given API key. """ u = User.objects.filter(api_key=x_api_key).first() if not u: raise HTTPException(status_code=400, detail="X-API-Key header invalid.") return u def get_object( model_class: Type[models.Model], id: str, x_api_key: str ) -> models.Model: """ Retrieve an object for the given user by id. This is a generic helper method that will retrieve any object by ID, ensuring that the user owns it. """ user = get_user(x_api_key) instance = model_class.objects.filter(user=user, pk=id).first() if not instance: raise HTTPException(status_code=404, detail="Object not found.") return instance def get_simulation( simulation_id: str = Path(..., description="The ID of the simulation."), x_api_key: str = Header(...), ): """ Retrieve the user's simulation from the given simulation ID. """ return get_object(Simulation, simulation_id, x_api_key)
Tests
The tests are very close to what you’re already used to from Django.
We’ll be using Django’s testing harness/runner, but we can test FastAPI’s views by using FastAPI’s client
.
We’ll also be using Django’s/unittest’s assert
functions, as I find them more convenient, but you can use anything you’d use with Django for those.
As I mentioned earlier, the plain TestCase
didn’t work for me, so I had to use the TransactionTestCase
.
main/tests.py:
# We use this as TestCase doesn't work. from django.test import TransactionTestCase from fastapi.testclient import TestClient from .models import User from goatfish.wsgi import app # A convenient helper for getting URL paths by name. reverse = app.router.url_path_for class SmokeTests(TransactionTestCase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Warning: Naming this `self.client` leads Django to overwrite it # with its own test client. self.c = TestClient(app) def setUp(self): self.user = User.objects.create(username="user", api_key="mykey") self.headers = {"X-API-Key": self.user.api_key} def test_read_main(self): response = self.c.get(reverse("simulations-get"), headers=self.headers) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"username": "user"})
That’s it!
Epilogue
I hope that was clear enough, there is a bit of confusion when trying to figure out which part is served by which library, but I’m confident that you’ll be able to figure it all out without much difficulty. Just keep in mind that FastAPI does everything view-specific and Django does everything else.
I was legitimately surprised at how well the two libraries worked together, and how minimal the amounts of hackery involved were. I don’t really see myself using anything else for APIs in the future, as the convenience of using both libraries is hard to beat. I hope that asynchronicity will work when Django releases async support, which would complete this integration and give us the best of both worlds.
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。