Skip to content

Starter kit for building robust APIs using the FastAPI framework. It provides a foundational structure that includes built-in authentication, a permission management system, and other useful features. The goal is to offer developers a streamlined starting point, simplifying the initial setup and promoting a better development experience.

License

Notifications You must be signed in to change notification settings

meschac38700/fastapi_getstarted

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Quality Gate Status

FastAPI template project

Note

This web application serves as a starter kit for building robust APIs using the FastAPI framework. It provides a foundational structure that includes built-in authentication, a permission management system, and other useful features. The goal is to offer developers a streamlined starting point, simplifying the initial setup and promoting a better development experience.

Python Version:

python >= 3.13.X

K8s repo

Get started

Rename the folder envs.example to envs Then change the environment information

Build Application environment

Install dependencies

required at least:
uv == 0.5.29

We use uv to manage dependencies: The following command will install all project dependencies in a virtual environment.

By default, uv will create a virtual env in a hidden folder named .venv if you want to change the location and name, you can set UV_PROJECT_ENVIRONMENT as environment variable before running the following command.

$ uv sync --all-extras

Next we need to activate the virtual environment created by uv previously

$ source ./.venv/bin/activate

Setup direnv

Note

This is optional but recommended to automatically export the environment variables needed to run the local server.

direnv is an extension for your shell. It augments existing shells with a new feature that can load and unload environment variables depending on the current directory.

Get started with direnv Once direnv is installed all you have to do is to allow the .envrc file

$ cd getstarted
$ direnv allow .

if you don't want to configure direnv, you need to export APP_ENVIRONMENT every time you move to the project directory:

$ export APP_ENVIRONMENT=dev

Run the application

Run prod server
$ docker compose up
Run development server
$ python src/manage.py serve
Using docker compose
$ docker compose -f docker-compose.dev.yaml up -d

Run application unit tests

$ python src/manage.py tests

Run application E2E tests

$ python src/manage.py tests -e

Running migrations

We use alembic to manage migrations. The following commands, as explained in the documentation, will allow you to create and run the migrations.

$ cd src
$ alembic revision --autogenerate -m "message of commit"
$ alembic upgrade head

Load fixtures

$ python src/manage.py fixtures

Tip

Run python src/manage.py --help to see all existing commands


Understand the structure of the application

If you are familiar with the Django application structure, you will find almost the same approach in this project.

Apps package

This folder is generally where you will be working most of the time and where all your work will reside. you can think of it as a Django project in which you can create all the applications you need.

It contains all the project's existing applications. To add a new application, simply create a new package in this folder. For example, let's say we want to add an application that will manage our blog posts. The following tree illustrates what our blog post application might look like. A valid application package requires at least a models and routers modules.

App complete tree
apps/
└── blog
    ├── __init__.py
    ├── commands
    │   ├── __init__.py
    │   └── latest_posts.py
    ├── dependencies
    │   ├── __init__.py
    │   └── access_rights.py
    ├── fixtures
    │   ├── initial_posts.yaml
    │   └── test
    │       └── fake_posts.yaml
    ├── models
    │   ├── __init__.py
    │   ├── schemas
    │   │   ├── __init__.py
    │   │   ├── create.py
    │   │   └── patch.py
    │   └── post.py
    ├── routers
    │   ├── __init__.py
    │   └── posts.py
    ├── web
    │   ├── __init__.py
    │   └── posts.py
    ├── signals
    │   ├── __init__.py
    │   ├── after_create_post.py
    │   └── before_create_post.py
    ├── statics
    │   └── blog
    │       └── styles.css
    ├── tasks
    │   ├── __init__.py
    │   └── load_initial_posts.py
    ├── templates
    │   └── blog
    │       └── list_posts.html
    ├── tests
    │   ├── tasks
    │   │   ├── __init__.py
    │   │   └── test_load_initial_posts.py
    │   ├── __init__.py
    │   ├── test_signals.py
    │   └── test_post_crud_operations.py
    └── utils
        ├── __init__.py
        └── types.py

Now let's take a closer look at the Apps package.


Models package

This could be a simple module or a package in which you could define all models related to your application.

Example:

File: apps.post.models.py
from sqlmodel import Field

from core.db.models import SQLTable
from core.db.mixins import BaseTable


# This base model will be useful later for declaring Pydantic schemas.
# For example, we'll use it to declare the following schemas: CreatePost or UpdatePost
class PostBaseModel(SQLTable):
    title: str
    description: str | None

# This is our final model (ORM)
# Extends BaseTable to define some generic fields, such as: id, created_at, updated_at
class Post(PostBaseModel, BaseTable, table=True):
    pass

You can use the package approach if you have many models to define.

Here's what the folder structure looks like:
apps/
└── blog
    └── models
        ├── __init__.py
        ├── statistical.py
        └── post.py
File: apps.post.models.post.py
from sqlmodel import Field, Relationship

from apps.user.models import User
from core.db.models import SQLTable
from core.db.mixins import BaseTable



# This base model will be useful later for declaring Pydantic schemas.
# For example, we'll use it to declare the following schemas: CreatePost or UpdatePost
class PostBaseModel(SQLTable):
    title: str
    description: str | None

# This is our final model (ORM)
class Post(PostBaseModel, BaseTable, table=True):
    author_username: str | None = Field(
        default=None, foreign_key="users.username", ondelete="SET NULL"
    )
    author: User = Relationship(sa_relationship_kwargs={"lazy": "joined"})
File: apps.post.models.statistical.py
from sqlmodel import Relationship, Field

from .post import Post
from core.db.models import SQLTable
from core.db.mixins import BaseTable


class PostStatisticalBaseModel(SQLTable):
    view_number: int = Field(default=0)
    shared_number: int = Field(default=0)

class PostStatistical(PostStatisticalBaseModel, BaseTable, table=True):
    id: int | None = Field(default=None, primary_key=True, allow_mutation=False)
    post_id: int = Field(default=None, foreign_key="post.id", ondelete="CASCADE")
    post: Post = Relationship(sa_relationship_kwargs={"lazy": "joined"})

[!IMPORTANT] Since this is a package, we need to explicitly export these models in the init file

File: apps.post.models.init.py
from .post import PostBaseModel, Post
from .statistical import PostStatisticalBaseModel, PostStatistical

__all__ = [
    "Post",
    "PostBaseModel",
    "PostStatistical",
    "PostStatisticalBaseModel",
]

All that remains is to create migrations and run them.

Perform migrations


Schemas module

The schemas are Pydantic models based on our SQLModel tables. We need them to validate user data. So let's create some Post schemas:

File: apps.post.models.schemas.post.py
from apps.post.models.post import PostBaseModel

class PostUpdate(PostBaseModel):
    """Validate user data with an http PUT/PATCH request to update a post."""

    class ConfigDict:
        from_attributes = True


class PostCreate(PostBaseModel):
    """Validate user data with an http POST request to create a post."""

    class ConfigDict:
        from_attributes = True

Important

And similarly, since we chose the package approach, we need to explicitly export these schemas

File: apps.post.models.schemas.__init__.py
from .post import PostCreate, PostUpdate

__all__ = [
    "PostCreate",
    "PostUpdate",
]
Here's what the folder structure looks like:
apps/
└── blog
    └──models
       ├── __init__.py
       ├── post.py
       ├── statistical.py
       └── schemas
           ├── __init__.py
           └── post.py

Routers package

This package, like the "models" package, can be a package or a module. This is where you'll define all the endpoints related to your application.

Let's implement an example based on our previous Post and PostStatistical models.

File: apps.post.routers.post.py
from http import HTTPStatus

from fastapi import APIRouter, HTTPException

from apps.post.models import Post
from apps.post.models.schema import PostCreate, PostUpdate


routers = APIRouter(prefix="/posts")

# GET /blog/posts/
@routers.get("/")
async def posts():
    return await Post.all()

@routers.post("/")
async def create_post(post: PostCreate):
    return await Post(**post.model_dump()).save()

@routers.get("/{pk}/")
async def get_post(pk: int):
    post = await Post.get(id=pk)
    if post is None:
        raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Post {pk} not found.")
    return post

@routers.put("/{pk}/")
async def update_post(pk: int, post: PostUpdate):
    stored_post = await Post.get(id=pk)
    if stored_post is None:
        raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Post {pk} not found.")
    return await Post(**post.model_dump()).save()

@routers.delete("/{pk}/")
async def delete_post(pk: int):
    stored_post = await Post.get(id=pk)
    if stored_post is None:
        raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=f"Post {pk} not found.")
    return await stored_post.delete()
apps.post.routers.statistical.py:
from fastapi import APIRouter

from apps.post.models import PostStatistical

routers = APIRouter(prefix="/{post_id}/statisticals")

# GET /blog/posts/{post_id}/statiticals/
@routers.get("/")
async def post_statisticals(post_id: int):
    return await PostStatistical.filter(post_id=post_id)
apps.post.routers.__init__.py:
from fastapi import APIRouter

from .post import routers as post_routers
from .statistical import routers as statistical_routers

routers = APIRouter(tags=["Blog"], prefix="/blog")
routers.include_router(post_routers)
routers.include_router(statistical_routers)

__all__ = ["routers"]
This is what our blog app looks like so far, with everything we've added:
apps/
└── blog
    ├── models
    │   ├── __init__.py
    │   ├── post.py
    │   ├── statistical.py
    │   └── schemas
    │   │   ├── __init__.py
    │   │   └── post.py
    └──  routers
         ├── __init__.py
         ├── post.py
         └── statistical.py

Web package

Identical to the router package except this is where you need to define all the routes used to render HTML.

Important

At this point, your blog application is fully functional. You can run the development server to check it.


Authorization (Permissions/Groups)

Why Permissions?

It's a common thought: why bother with a complex permission system when a simple "Depends" seems to do the trick?
While both might appear similar at first glance, they are actually complementary, not contradictory.
You can certainly define a router using "Depends" for access control, and the reverse is also true.
However, it's crucial to see permissions as a more advanced and dynamic control system.

Let's Look at Concrete Examples:

If you've ever used social media apps like Twitch, Facebook, or WhatsApp, you've probably encountered features such as:

  • Blocking a friend or limiting online visibility to specific friends (Facebook)
  • Blocking a viewer in chat (Twitch)
  • Limiting who can see your status or profile picture (WhatsApp)

All these features rely on a permission system behind the scenes.
Where "Depends" is configured once and remains static, permissions allow us to make user action control dynamic,
even after the application is deployed.

Consider the Twitch example again:

The chat is available to any viewer, as long as they are logged into the platform.
However, the streamer can block this access if a viewer spams or misbehaves.
We can imagine that Twitch has a permission system in place to enable this action.
Through their settings, a streamer can add or remove a permission for a viewer.
(Of course, it's not presented this way to the streamer, who simply sees "Block" or "Unblock," but a similar process is happening in the background.)

This is the core benefit of permissions.
They provide the flexibility and control needed for real-time, adaptable access management in complex applications.

Now that we understand the difference between Depends and Permissions, Let's see how to implement them in our Blog application.

Implementing Depends for authentication and Ownership

Depends is perfect for reusable, programmatic checks that are part of the request flow.

blog.dependencies.access.py:
from fastapi import Depends, HTTPException, status

from apps.blog.models import Post
from apps.authentication.dependencies.oauth2 import current_user


async def get_post_if_owner(pk: int, user: Depends(current_user)):
    """Dependency to get a post and ensure the current user is its owner."""
    post = await Post.get(id=pk)
    if post is None:
        detail = f"Post {pk} not found."
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)

    if post.author_id != user.id:
        detail = "Not authorized to access this post"
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail)

    return post

Then use it in certain Post routers

# blog.routers.post.py
from typing import Annotated

from fastapi import APIRouter, Depends, status, HTTPException

from apps.blog.dependencies.access import get_post_if_owner
from apps.post.models.schema import PostCreate, PostUpdate
from apps.blog.models import Post

routers = APIRouter(prefix="/posts")

@routers.get("/{pk}/")
async def get_post(post: Annotated[Post, Depends(get_post_if_owner)]):
    return post

@routers.put("/{pk}/")
async def update_post(post: Annotated[Post, Depends(get_post_if_owner)], post_data: PostUpdate):
    post.update_from_dict(post_data.model_dump(exclude_unset=True))
    return await post.save()

@routers.delete("/{pk}/")
async def delete_post(post: Annotated[Post, Depends(get_post_if_owner)]):
    return await post.delete()
Implementing Permissions for dynamic control

Permissions, in contrast to Depends, involve dynamic checks based on data stored in the database(or a dedicated permission service).

Let's imagine we want to restrict viewing post statitics to certain users or an "admin".

We can add a can_view_stats permission to users or group.

You can either use fixtures (recommended) or manually create the permission.

There's a command to install fixtures listed in the INITIAL_FIXTURES variable within your settings/constants.py file.

Once you've created your fixture file (blog.fixtures.blog_initial_permissions.yaml),
add its file name to this variable and run the following command:

class AppConstants:
    ...
    INITIAL_FIXTURES = [
      ...,
      "blog_initial_permissions",
    ]
    ...
python src/manage.py fixtures
The fixture could look like this:
- model: authorization.Permission
  properties:
    name: can_view_stats
    target_table: poststatistical

For the manual approach (not recommended):

$ cd src
$ python
Python 3.13.0 (main, Oct 16 2024, 03:23:02) [Clang 18.1.8 ] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import asyncio
>>> from apps.user.models import User
>>> from apps.authorization.models import Group, Permission
>>> can_view_stats = asyncio.run(Permission.get(name="can_view_stats"))
>>> user = asyncio.run(User.first())
>>> group = asyncio.run(Group.first())
>>> asyncio.run(user.add_permission(can_view_stats))
>>> asyncio.run(group.add_permission(can_view_stats))
The python module approach could look like this:
# file foo.py
import asyncio
from apps.user.models import User
from apps.authorization.models import Group, Permission


async def get_user_and_group():
    return await asyncio.gather(
        User.first(),
        Group.first()
    )

async def apply_view_stats_permission(user: User, group: Group):
    """Apply the 'can_view_stats' permission to a user and a group."""
    can_view_stats = await Permission.get(name="can_view_stats").save()
    await user.add_permission(can_view_stats)
    # And to the group
    await group.add_permission(can_view_stats)


if __name__ == "__main__":
    user, group = get_user_and_group()
    asyncio.run(apply_view_stats_permission(user, group))

Let's see how to use that permission in your application:

apps.blog.routers.statistical.py

[!NOTE] You can apply permissions either to the user or to a group. In this example, we check either option. If the user or their group has the permission, or if the user is an admin, then they are authorized to view the Post's stats.

from fastapi import APIRouter, Depends

from apps.post.models import PostStatistical
from apps.authorization.dependencies import permission_required

routers = APIRouter(prefix="/{post_id}/statisticals")

# GET /blog/posts/{post_id}/statiticals/
@routers.get("/",
    dependencies=[
        Depends(
            permission_required(permissions=["can_view_stats"], groups=["can_view_stats"])
        )
    ]
)
async def post_statisticals(post_id: int):
    return await PostStatistical.filter(post_id=post_id)

[!NOTE] As you can also see, the other advantage of permissions here is that we don't have to write any extra lines of code; simply adding the permission to the database is all it takes, and the decorator at the router level.

This is what our blog app looks like so far, with everything we've added:
apps/
└── blog
    ├── dependencies
    │   ├── __init__.py
    │   └── access.py
    ├── fixtures
    │   └── blog_initial_permissions.yaml
    ├── models
    │   ├── __init__.py
    │   ├── post.py
    │   ├── statistical.py
    │   └── schemas
    │   │   ├── __init__.py
    │   │   └── post.py
    └──  routers
         ├── __init__.py
         ├── post.py
         └── statistical.py

Fixtures package

Now that we've added some endpoints, we need to test our application. We could take a TDD approach, but it depends on you and which approach you're most effective with.

This is the perfect transition to introduce the fixtures package.

Fixtures are YAML files in which we define data for testing purposes. They can also be called "fake data."

All you need is to create your fixture YAML file then define in your Test class a fixtures variable, which contains the name of your fixture file.

Enough blah blah, let's put this into practice.

apps.blog.fixtures.testing.posts.yaml
apps.blog.fixtures.testing.posts.yaml
- model: user.User
  properties:
    username: d.john
    first_name: John
    last_name: DOE
    password: john_pass
    email: [email protected]
    role: staff

- model: user.User
  properties:
    username: d.jane
    first_name: Jane
    last_name: DOE
    password: jane_pass
    email: [email protected]
    role: active

---

- model: blog.Post
  properties:
    author_username: d.john
    title: X Chief Says She Is Leaving the Social Media Platform
    description: >
      Linda Yaccarino, whom Elon Musk hired to run X in 2023, grappled
      with the challenges the company faced after Mr. Musk took over. 13h agoBy
      Mike Isaac and Kate Conger Linda Yaccarino at a Senate Judiciary Committee
      hearing in 2024. She grew close to Elon Musk in 2023 when, as an executive
      at NBCUniversal, she pledged to keep running ads on Twitter as other
      advertisers were refusing to do so.
      CreditKenny Holston/The New York Times

- model: blog.Post
  properties:
    author_username: d.jane
    title: OpenAI and Microsoft Bankroll New A.I. Training for Teachers
    description: >
      The American Federation of Teachers said it would use the $23 million, including $500,000 from the A.I.
      start-up Anthropic, to create a national training center.
      By Natasha SINGER

Now that our fixtures file is ready, let's implement the tests

Since our blog application is a dedicated folder, it is a good practice to have all associated logic in this folder. We will create the tests folder inside the blog folder

apps.blog.tests.test_post_crud_operations.py
from fastapi import status

from core.unittest.async_case import AsyncTestCase
from apps.blog.models import Post


# AsyncTestCase is a wrapper class of IsolatedAsyncioTestCase
# It includes some logics to manage fixtures, client and so on
class TestPostCrudOperations(AsyncTestCase):
    fixtures = [
      "posts" # declaration of our fixtures (extension .yaml is optional)
    ]

    async def test_get_all_posts(self):
        response = await self.client.get("/blog/posts/")

        posts = response.json()

        assert status.HTTP_200_OK == response.status_code
        assert len(posts) >= 2

    async def test_get_post_not_found(self):
        post_id = -1
        response = await self.client.get(f"/blog/posts/{post_id}")
        expected = {
          "detail": f"Post {post_id} not found."
        }
        assert expected == response.json()

    async def test_get_post(self):
        post_id = 1
        response = await self.client.get(f"/blog/posts/{post_id}")
        expected = await Post.get(id=post_id)
        assert expected.model_dump(mode="json") == response.json()

    # And so on
Here's what the folder structure looks like:
apps/
└── blog
    ├── dependencies
    │   ├── __init__.py
    │   └── access.py
    ├── fixtures
    │   ├── blog_initial_permissions.yaml
    │   └── testing
    │       └── posts.yaml
    ├── models
    │   ├── __init__.py
    │   ├── post.py
    │   ├── statistical.py
    │   └── schemas
    │       ├── __init__.py
    │       └── post.py
    ├── routers
    │     ├── __init__.py
    │     ├── post.py
    │     └── statistical.py
    └── tests
        └── test_post_crud_operations.py

Signals package

Signals allow us to intervene before or after an action in our SQL table. There is a signal handling class that will help you easily add signals to your model. Let's implement an example signal with the Post model.

# TODO(Eliam): Work in progress
Here's what the folder structure looks like:
apps/
└── blog
    ├── dependencies
    │   ├── __init__.py
    │   └── access.py
    ├── fixtures
    │   ├── blog_initial_permissions.yaml
    │   └── testing
    │       └── posts.yaml
    ├── models
    │   ├── __init__.py
    │   ├── post.py
    │   ├── statistical.py
    │   └── schemas
    │       ├── __init__.py
    │       └── post.py
    ├── routers
    │   ├── __init__.py
    │   ├── post.py
    │   └── statistical.py
    ├── signals
    │   ├── __init__.py
    │   ├── after_create.py
    │   └── before_create.py
    └── tests
        ├── test_signals.py
        └── test_post_crud_operations.py

About

Starter kit for building robust APIs using the FastAPI framework. It provides a foundational structure that includes built-in authentication, a permission management system, and other useful features. The goal is to offer developers a streamlined starting point, simplifying the initial setup and promoting a better development experience.

Resources

License

Stars

Watchers

Forks

Packages

No packages published