Package: fastapi-plan
Dead simple but powerful template manager for FastAPI applications.AboutQuickstartInitializationDevelopmentDebug (http)Production (https)Project structureHigh level overviewHow to add new endpoint (step by step)AboutFeatures:postgresql database withTortoise ORMas ORMwell organised, rock solid project structure (see sectionProject structure)ready-to-use user model, authentiaction system (JWT), hashing with Bcrypteasy to undarstand config.py with settings (there is only one file for changes:.env)out-of-the-box well-tested routes for login and user (register, read, read_me, update etc.)aerich for migrationswell-designed tests folder filled with tests for existing user model/user endpointsauto-generated strong passwords for database, secret_key and superuser passwordpoetry or pipdeployment ready docker-compose.prod.yml file with poetry, you will only need own domainFurthermore:fullproject structure schemahigh level overviewhow this project is organised and why, questions like where do the settings live or what every variable in.envfile is used forstep by step explanationhow to add new endpoint, from creating new model, adding schemas and routes to migrating database and writting tests (it's always better to have it and optionally adopt it, than wasting time trying to figure out the best dev path)QuickstartNOTE: you will needdockerand optional but recommendedpoetryinstalled!install via pip (or poetry)globally:pipinstallfastapi-planthere are 3 docker-compose files available, one for development, one for running project via http, and the last one is for production with enabled https using traefic as a proxy (and letsencrypt for ssl), steps to initialize new project are the same for every approach, you can then choose from 1-3.0. INITIALIZATIONinitialize new FastAPI project:fastapi-planenter project_name and other information and after project is ready,cd project_nameand continue installing dependencies:poetryinstall# optional if you selected "requirements.txt" (with venv installed)pipinstall-rrequirements.txt1. DEVELOPMENTsince we wanna use uvicorn in development, create only postgres container using docker-compose.yml file like that:docker-composeup-dnow run aerich migrations and configure tortoise (and add first superuser)aerichupgrade
pythonapp/initial_data.pyfinally you can run this command to start uvicorn serveruvicornapp.main:app--reload2. DEBUG (http)To make it available fromhttp://localhoston your local machine orhttp://your-host-nameon VM just rundocker-compose-fdocker-compose.debug.ymlup-dThe diffrence between development approach is that web server automatically runs aerich and initial_data.py using shell script (app/initial.sh), so you don't have to do anything except changing some lines in.envfile:PROJECT_NAME- it will show up in docs view as a name of project.FIRST_SUPER_USER_EMAIL- first account emailDEBUG- when it's false, thePOSTGRES_SERVERis set tolocalhostfor development, so change it toDEBUG=trueto usedbpostgres server.3. PRODUCTION (https, own domain)To make it available from https://your_domain.com on VM rundocker-compose-fdocker-compose.prod.ymlup-dThe diffrence between development approach is that web server automatically runs aerich and initial_data.py using shell script (app/initial.sh), so you don't have to do anything except changing some lines in.envfile:PROJECT_NAME- it will show up in docs view as a name of project.FIRST_SUPER_USER_EMAIL- first account emailDEBUG- when it's false, thePOSTGRES_SERVERis set tolocalhostfor development, so change it toDEBUG=trueto usedbpostgres server.DEFAULT_FROM_EMAIL- your private email for ssl purposes, e.g. they will inform you shortly after some problems with you certificate.MAIN_DOMAIN- your own domain e.g.example.comPlesae also note that to get no-test certificate, you should comment line"--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"indocker-compose.prod.ymlfile, by default you will use test certifactes (to be sure that everything works, there are some hard limits on number of certifiactes you can ask per week!). You should comment line"--log.level=DEBUG"also (but it can be useful when debugging traefik). There would probably be problems anyway, just be sure that everything works via http using2. DEBUGapropach. If it then doesn't with the https, you should refer to traefik docs.Project structure|── app
|    ├── api                              # endpoints/dependecies
|    |
|    ├── core                             # settings and security algorithms
|    |
|    ├── crud                             # CRUD operations
|    |
|    ├── migrations                       # for aerich migrations
|    |
|    ├── models                           # tortoise models
|    |
|    ├── schemas                          # pandatic schemas
|    |
|    ├── tests                            # tests
|    |
|    ├── initial.sh                       # initial shell script used by docker
|    ├── initial_data.py                  # init database and add first superuser
|    ├── main.py                          # main fastapi application file
|
├── config                                # nginx server config file
|
├── .env                                  # .env file with settings
|
├── Dockerfile                            # dockerfile for web app
|
├── aerich.ini                            # aerich (migrations) configuration
|
├── docker-compose.prod.yml               # puts it all together in prod (https)
|
├── docker-compose.debug.yml              # puts it all together in debug (http)
|
├── docker-compose.yml                    # puts it all together (development)
|
├── (optional) pyproject.toml             # python dependencies (poetry)
|
├── (optional) poetry.lock                # python dependencies (poetry)
|
├── requirements.txt                      # python dependencies (pip)High level overviewThis project strucutre is mostly based on theofficial template(but not only) which is really great but unfortunatly does not support Tortoise ORM and is... (too?) complicated. All the security or problematic stuff (app/core/security.pywithverify_passwordfunction, login and token routes, JWT token schemas) are just copied from there, so you can be pretty sure it will work as expected.The main thougts are:There two sorts of settings, first one located in.envfile for the ENTIRE project, and python-specific settings which lives inapp/core/config.py, the file is based on pydantic solution (using dotenv lib). Why? Well, that's simple, this is due to12factor methodology, python-specific settings inherit from.envfile, so this is the only place where you actually change something. If you have any problems understanding mentionedconfig.pyfile, just refer topydantic - settings management, it's pretty clear.Models, crud, schemas, api routes, tests... it might be confusing how to actually ADD SOMETHING NEW here, but after following next section (learn by doing, step by step), it should be pretty easyDatabase-related stuff is very convinient, taken mostly fromTortoise ORMdocs and justworking. There isregister_tortoisefunction inmain.py,TORTOISE_ORMvariable inapp/core/config.py. Please, be aware that if you don't runinitial_data.pySOMEHOW (in development- you have to do it yourself, in debug/production it is handled by shell scriptinitial.sh, which also runs tests and migrations), you won't be able to connect to database.initial_data.pyis hearbly based on the same named file inofficial templatementioned earlier. It has two responsibilities, first is runninginitfunction from Tortoise to initialize connection, and the second - creating first superuser (defined in.env) if one doesn't yet exists.Migrations are also provided by Tortiose (the tool is aerich), docs can be foundhere in aerich repo. The default migration (default user model) file is already included. After changes in models (e.g. new modelCars), just runaerich migrate,aerich upgradeand you are good to go.All tests lives intestsfolder, with some pytest-specific content included. If you feel unconfortable with pytest, feel free to read articles about using it, and if you just want to see how to test new enpoints/models, just read next section.How to add new endpointLet's imagine we need to create API for a website where users brag about their dogs... or whatever, they just can crud dogs in user panel for some reason. We will add dummy modelDogto our API, with relation to the default tableUserand crud auth endpoints, then test it shortly.Create filedog.pyinapp/modelsfolder:fromtortoiseimportfieldsfromtortoise.modelsimportModelclassDog(Model):name=fields.CharField(max_length=100)age=fields.IntField(null=True,default=None)breed=fields.CharField(max_length=100,null=True,default=None)owner=fields.ForeignKeyField("models.User",related_name="dogs")Add import inapp/models.__init__.py:from.dogimportDog# type: ignoreMigrate changesaerichmigrate
aerichupgradeCreate filedog.pyinapp/schemasfolder (pydantic schemas with typing support):fromtypingimportOptionalfromtortoiseimportTortoisefromtortoise.contrib.pydantic.creatorimport(pydantic_model_creator,pydantic_queryset_creator,)frompydanticimportBaseModelfromapp.modelsimportDog# Pydantic models from Tortoise models, pls refer# https://tortoise-orm.readthedocs.io/en/latest/examples/pydantic.html#basic-pydanticTortoise.init_models(["app.models"],"models")DogPydantic=pydantic_model_creator(Dog,exclude=("owner",))DogPydanticList=pydantic_queryset_creator(Dog,exclude=("owner",))# Unfortunately, it doesn't work the other way aroundclassDogCreate(BaseModel):name:strage:Optional[int]breed:Optional[str]classDogUpdate(BaseModel):name:Optional[str]age:Optional[int]breed:Optional[str]Add import inapp/schemas.__init__.py:from.dogimportDogUpdate,DogCreate,DogPydantic,DogPydanticList# type: ignoreCreatecrud_dog.pyinapp/crudfolderfromapp.schemasimportDogCreate,DogUpdatefromapp.crud.baseimportCRUDBasefromapp.modelsimportDog,UserclassCRUDDog(CRUDBase[Dog,DogCreate,DogUpdate]):defget_dogs_by_user(self,user:User,skip:int=0,limit:int=100):returnDog.filter(owner=user).offset(offset=skip).limit(limit=limit)asyncdefcreate_dog_me(self,dog_in:DogCreate,user:User):new_dog=awaitDog.create(name=dog_in.name,age=dog_in.age,breed=dog_in.breed,owner=user)returnnew_dogasyncdefget_by_id_and_user(self,dog_id:int,user:User):returnawaitDog.get(id=dog_id,owner=user)asyncdefremove_all_user_dogs(self,user:User):awaitDog.filter(owner=user).delete()returndog=CRUDDog(Dog)Add import inapp/crud.__init__.py:from.crud_dogimportdog# type: ignoreCreatedogs.pywith endpoints inapp/api/routersfolderfromfastapiimportAPIRouter,Depends,HTTPException,statusfromappimportcrud,models,schemasfromapp.apiimportdepsfromapp.modelsimportDogrouter=APIRouter()@router.get("/{dog_id}",response_model=schemas.DogPydantic)asyncdefread_dog(dog_id:int,):dog=awaitcrud.dog.get(dog_id)ifnotdog:raiseHTTPException(status_code=status.HTTP_404_NOT_FOUND,detail="The dog does not exist in the system",)returnawaitschemas.DogPydantic.from_tortoise_orm(dog)@router.post("/",response_model=schemas.DogPydantic,status_code=status.HTTP_201_CREATED)asyncdefcreate_dog_me(dog_in:schemas.DogCreate,current_user:models.User=Depends(deps.get_current_active_user),):dog:Dog=awaitcrud.dog.create_dog_me(dog_in,current_user)returnawaitschemas.DogPydantic.from_tortoise_orm(dog)@router.put("/",response_model=schemas.DogPydantic)asyncdefupdate_dog_me(dog_id:int,dog_in:schemas.DogUpdate,current_user:models.User=Depends(deps.get_current_active_user),):dog=awaitcrud.dog.get_by_id_and_user(dog_id,current_user)ifnotdog:raiseHTTPException(status_code=status.HTTP_404_NOT_FOUND,detail="The dog does not exist in the system",)new_dog=awaitcrud.dog.update(dog,dog_in)returnawaitschemas.DogPydantic.from_tortoise_orm(new_dog)@router.delete("/",response_model=None,status_code=status.HTTP_204_NO_CONTENT)asyncdefdelete_dog_me(dog_id:int,current_user:models.User=Depends(deps.get_current_active_user),):dog=awaitcrud.dog.get_by_id_and_user(dog_id,current_user)ifnotdog:raiseHTTPException(status_code=status.HTTP_404_NOT_FOUND,detail="The dog does not exist in the system",)awaitcrud.dog.remove(dog_id)returnNone@router.delete("/all",response_model=None,status_code=status.HTTP_204_NO_CONTENT)asyncdefdelete_dogs_me(current_user:models.User=Depends(deps.get_current_active_user),):awaitcrud.dog.remove_all_user_dogs(current_user)returnNone@router.get("/all",response_model=schemas.DogPydanticList)asyncdefread_all_dogs_me(skip:int=0,limit:int=100,current_user:models.User=Depends(deps.get_current_active_user),):dogs=crud.dog.get_dogs_by_user(current_user,skip,limit)returnawaitschemas.DogPydanticList.from_queryset(dogs)@router.get("/all/{user_id}",response_model=schemas.DogPydanticList)asyncdefread_all_dogs(user_id:int,skip:int=0,limit:int=100,):user=awaitcrud.user.get(user_id)ifnotuser:raiseHTTPException(status_code=status.HTTP_404_NOT_FOUND,detail="The user does not exist",)dogs=crud.dog.get_dogs_by_user(user,skip,limit)returnawaitschemas.DogPydanticList.from_queryset(dogs)Finally add those enpoints to the app with label "dogs", add this line inapp/api/api.pyfile:api_router.include_router(dogs.router,prefix="/dogs",tags=["dogs"])API endpoints are ready to go, you can play with them atlocalhost:8000by defaultNow we gonna create tests for crud and endpoints, let's first create utils for dog model (we can use it in multiple places in tests then), adddog.pyinapp/tests/utilsfolder:fromasyncioimportAbstractEventLoopasEventLoopfromappimportmodelsimportapp.tests.utils.utilsasutilsdefcreate_random_dog(user:models.User,event_loop:EventLoop)->models.Dog:name=utils.random_lower_string()breed=utils.random_lower_string()age=utils.random_integer_below_100()dog:models.Dog=event_loop.run_until_complete(models.Dog.create(name=name,breed=breed,age=age,owner=user))returndogThen addtest_dog.pyinapp/tests/crudfolder:importpytestfromasyncioimportAbstractEventLoopasEventLoopfromtypingimportListfromappimportcrud,models,schemasfromapp.tests.utils.utilsimport(random_lower_string,random_integer_below_100,)fromapp.tests.utils.dogimportcreate_random_dog@pytest.fixture(autouse=True)defdrop_dogs(event_loop:EventLoop)->None:yieldevent_loop.run_until_complete(models.Dog.all().delete())deftest_get_dogs_by_user(event_loop:EventLoop,normal_user:models.User):dog0=create_random_dog(normal_user,event_loop)dog1=create_random_dog(normal_user,event_loop)dog_lst:List[models.Dog]=list(event_loop.run_until_complete(crud.dog.get_dogs_by_user(normal_user)))assertlen(dog_lst)==2assertdog_lst[0].name==dog0.nameassertdog_lst[1].name==dog1.nameassertdog_lst[0].age==dog0.ageassertdog_lst[1].age==dog1.ageassertdog_lst[0].breed==dog0.breedassertdog_lst[1].breed==dog1.breeddeftest_create_dog_me(event_loop:EventLoop,normal_user:models.User):name=random_lower_string()breed=random_lower_string()age=random_integer_below_100()dog_in=schemas.DogCreate(name=name,breed=breed,age=age)dog:models.Dog=event_loop.run_until_complete(crud.dog.create_dog_me(dog_in,normal_user))assertdog.name==nameassertdog.breed==breedassertdog.age==ageassertdog.owner==normal_userdeftest_get_dog_by_user(event_loop:EventLoop,normal_user:models.User):dog0=create_random_dog(normal_user,event_loop)dog:models.Dog=event_loop.run_until_complete(crud.dog.get_by_id_and_user(dog0.pk,normal_user))assertdog==dog0deftest_remove_all_user_dogs(event_loop:EventLoop,normal_user:models.User):create_random_dog(normal_user,event_loop)create_random_dog(normal_user,event_loop)dog_number0:int=event_loop.run_until_complete(models.Dog.filter(owner=normal_user).count())assertdog_number0==2event_loop.run_until_complete(crud.dog.remove_all_user_dogs(normal_user))dog_number1:int=event_loop.run_until_complete(models.Dog.filter(owner=normal_user).count())assertdog_number1==0And thentest_dogs.pyfor endpoints inapp/tests/apifolder