In this tutorial, you'll learn how to use the Masonite ORM with FastAPI.
Contents
Objectives
By the end of this tutorial, you should be able to:
- Integrate the Masonite ORM with FastAPI
- Use the Masonite ORM to interact with Postgres, MySQL, and SQLite
- Declare relationships in your database application with the Masonite ORM
- Test a FastAPI application with pytest
Why Use the Masonite ORM
The Masonite ORM is a clean, easy-to-use, object relational mapping library built for the Masonite web framework. The Masonite ORM builds on the Orator ORM, an Active Record ORM, which is heavily inspired by Laravel's Eloquent ORM.
Masonite ORM was developed to be a replacement to Orator ORM as Orator no longer receives updates and bug fixes.
Although it's designed to be used in a Masonite web project, you can use the Masonite ORM with other Python web frameworks or projects.
FastAPI
FastAPI is a modern, high-performance, batteries-included Python web framework that's perfect for building RESTful APIs. It can handle both synchronous and asynchronous requests and has built-in support for data validation, JSON serialization, authentication and authorization, and OpenAPI.
For more on FastAPI, review our FastAPI summary page.
What We're Building
We're going to be building a simple blog application with the following models:
- Users
- Posts
- Comments
Users will have a one-to-many relationship with Posts while Posts will also have a one-to-many relationship with Comments.
API endpoints:
/api/v1/users
- get details for all users/api/v1/users/<user_id>
- get a single user's details/api/v1/posts
- get all posts/api/v1/posts/<post_id>
- get a single post/api/v1/posts/<post_id>/comments
- get all comments from a single post
Project Setup
Create a directory to hold your project called "fastapi-masonite":
$ mkdir fastapi-masonite
$ cd fastapi-masonite
Create a virtual environment and activate it:
$ python3.10 -m venv .env
$ source .env/bin/activate
(.env)$
Feel free to swap out virtualenv and Pip for Poetry or Pipenv. For more, review Modern Python Environments.
Create a requirements.txt file and add the following requirements to it:
fastapi==0.89.1
uvicorn==0.20.0
Uvicorn is an ASGI (Asynchronous Server Gateway Interface) compatible server that will be used for starting up FastAPI.
Install the requirements:
(.env)$ pip install -r requirements.txt
Create a main.py file in the root folder of your project and add the following lines:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def say_hello():
return {"msg": "Hello World"}
Run the FastAPI server with the following command:
(.env)$ uvicorn main:app --reload
Open your web browser of choice and navigate to http://127.0.0.1:8000. You should see the following JSON response:
{
"msg": "Hello World"
}
Masonite ORM
Add the following requirements to the requirements.txt file:
masonite-orm==2.18.6
psycopg2-binary==2.9.5
Install the new dependencies:
(.env)$ pip install -r requirements.txt
Create the following folders:
models
databases/migrations
config
The "models" folder will contain our model files, the "databases/migrations" folder will contain our migration files, and the "config" folder will hold our Masonite Database configuration file.
Database Config
Inside the "config" folder, create a database.py file. This file is required for the Masonite ORM as this is where we declare our database configurations.
For more info, visit the docs.
Within the database.py file, we need to add the DATABASE
variable plus some connection information, import the ConnectionResolver
from masonite-orm.connections
, and register the connection details:
# config/database.py
from masoniteorm.connections import ConnectionResolver
DATABASES = {
"default": "postgres",
"mysql": {
"host": "127.0.0.1",
"driver": "mysql",
"database": "masonite",
"user": "root",
"password": "",
"port": 3306,
"log_queries": False,
"options": {
#
}
},
"postgres": {
"host": "127.0.0.1",
"driver": "postgres",
"database": "test",
"user": "test",
"password": "test",
"port": 5432,
"log_queries": False,
"options": {
#
}
},
"sqlite": {
"driver": "sqlite",
"database": "db.sqlite3",
}
}
DB = ConnectionResolver().set_connection_details(DATABASES)
Here, we defined three different database settings:
- MySQL
- Postgres
- SQLite
We set the default connection to Postgres.
Note: Make sure you have a Postgres database up and running. If you want to use MySQL, change the default connection to
mysql
.
Masonite Models
To create a new boilerplate Masonite model, run the following masonite-orm
command from the project root folder in your terminal:
(.env)$ masonite-orm model User --directory models
You should see a success message:
Model created: models/User.py
So, this command should have created a User.py file in the "models" directory with the following content:
""" User Model """
from masoniteorm.models import Model
class User(Model):
"""User Model"""
pass
If you receive a
FileNotFoundError
, check to make sure that the "models" folder exists.
Run the same commands for the Posts and Comments models:
(.env)$ masonite-orm model Post --directory models
> Model created: models/Post.py
(.env)$ masonite-orm model Comment --directory models
> Model created: models/Comment.py
Next, we can create the initial migrations:
(.env)$ masonite-orm migration migration_for_user_table --create users
We added the --create
flag to tell Masonite that the migration file to be created is for our users
table and the database table should be created when the migration is run.
In the "databases/migration" folder, a new file should have been created:
<timestamp>_migration_for_user_table.py
Content:
"""MigrationForUserTable Migration."""
from masoniteorm.migrations import Migration
class MigrationForUserTable(Migration):
def up(self):
"""
Run the migrations.
"""
with self.schema.create("users") as table:
table.increments("id")
table.timestamps()
def down(self):
"""
Revert the migrations.
"""
self.schema.drop("users")
Create the remaining migration files:
(.env)$ masonite-orm migration migration_for_post_table --create posts
> Migration file created: databases/migrations/2022_05_04_084820_migration_for_post_table.py
(.env)$ masonite-orm migration migration_for_comment_table --create comments
> Migration file created: databases/migrations/2022_05_04_084833_migration_for_comment_table.py
Next, let's populate the fields for each of our database tables.
Database Tables
The users
table should have the following fields:
- Name
- Email (unique)
- Address (Optional)
- Phone Number (Optional)
- Sex (Optional)
Change the migration file associated with the Users model to:
"""MigrationForUserTable Migration."""
from masoniteorm.migrations import Migration
class MigrationForUserTable(Migration):
def up(self):
"""
Run the migrations.
"""
with self.schema.create("users") as table:
table.increments("id")
table.string("name")
table.string("email").unique()
table.text("address").nullable()
table.string("phone_number", 11).nullable()
table.enum("sex", ["male", "female"]).nullable()
table.timestamps()
def down(self):
"""
Revert the migrations.
"""
self.schema.drop("users")
For more on the table methods and column types, review Schema & Migrations from the docs.
Next, update the fields for the Posts and Comments models, taking note of the fields.
Posts:
"""MigrationForPostTable Migration."""
from masoniteorm.migrations import Migration
class MigrationForPostTable(Migration):
def up(self):
"""
Run the migrations.
"""
with self.schema.create("posts") as table:
table.increments("id")
table.integer("user_id").unsigned()
table.foreign("user_id").references("id").on("users")
table.string("title")
table.text("body")
table.timestamps()
def down(self):
"""
Revert the migrations.
"""
self.schema.drop("posts")
Comments:
"""MigrationForCommentTable Migration."""
from masoniteorm.migrations import Migration
class MigrationForCommentTable(Migration):
def up(self):
"""
Run the migrations.
"""
with self.schema.create("comments") as table:
table.increments("id")
table.integer("user_id").unsigned().nullable()
table.foreign("user_id").references("id").on("users")
table.integer("post_id").unsigned().nullable()
table.foreign("post_id").references("id").on("posts")
table.text("body")
table.timestamps()
def down(self):
"""
Revert the migrations.
"""
self.schema.drop("comments")
Take note of:
table.integer("user_id").unsigned()
table.foreign("user_id").references("id").on("users")
The lines above create a foreign key from the posts
/comments
table to the users
table. The user_id
column references the id
column on the users
table
To apply the migrations, run the following command in your terminal:
(.env)$ masonite-orm migrate
You should see success messages concerning each of your migrations:
Migrating: 2022_05_04_084807_migration_for_user_table
Migrated: 2022_05_04_084807_migration_for_user_table (0.08s)
Migrating: 2022_05_04_084820_migration_for_post_table
Migrated: 2022_05_04_084820_migration_for_post_table (0.04s)
Migrating: 2022_05_04_084833_migration_for_comment_table
Migrated: 2022_05_04_084833_migration_for_comment_table (0.02s)
Thus far, we've added and referenced the foreign keys in our table, which have been created in the database. We still need to tell Masonite what type of relationship each model has to one another, though.
Table Relationships
To define a one-to-many relationship, we need to import in has_many
from masoniteorm.relationships
within models/User.py and add it as decorators to our functions:
# models/User.py
from masoniteorm.models import Model
from masoniteorm.relationships import has_many
class User(Model):
"""User Model"""
@has_many("id", "user_id")
def posts(self):
from .Post import Post
return Post
@has_many("id", "user_id")
def comments(self):
from .Comment import Comment
return Comment
Do note that the has_many
takes two arguments which are:
- The name of the primary key column on the main table which will be referenced in another table
- The name of the column which will serve as a reference to the foreign key
In the users
table, the id
is the primary key column while the user_id
is the column in the posts
table which references the users
table record.
Do the same for models/Post.py:
# models/Post.py
from masoniteorm.models import Model
from masoniteorm.relationships import has_many
class Post(Model):
"""Post Model"""
@has_many("id", "post_id")
def comments(self):
from .Comment import Comment
return Comment
With the database configured, let's wire up our API with FastAPI.
FastAPI RESTful API
Pydantic
FastAPI relies heavily on Pydantic for manipulating (reading and returning) data.
In the root folder, create a new Python file called schema.py:
# schema.py
from pydantic import BaseModel
from typing import Optional
class UserBase(BaseModel):
name: str
email: str
address: Optional[str] = None
phone_number: Optional[str] = None
sex: Optional[str] = None
class UserCreate(UserBase):
email: str
class UserResult(UserBase):
id: int
class Config:
orm_mode = True
Here, we defined a base model for a User object, and then added two Pydantic Models, one to read data and the other for returning data from the API. We used the Optional
type for nullable values.
You can read more about Pydantic Models here.
In the UserResult
Pydantic class, we added a Config
class and set orm_mode
to True
. This tells Pydantic not just to read the data as a dict but also as an object with attributes. So, you'll be able to do either:
user_id = user["id"] # as a dict
user_id = user.id # as an attribute
Next, add Models for Post and Comment objects:
# schema.py
from pydantic import BaseModel
from typing import Optional
class UserBase(BaseModel):
name: str
email: str
address: Optional[str] = None
phone_number: Optional[str] = None
sex: Optional[str] = None
class UserCreate(UserBase):
email: str
class UserResult(UserBase):
id: int
class Config:
orm_mode = True
class PostBase(BaseModel):
user_id: int
title: str
body: str
class PostCreate(PostBase):
pass
class PostResult(PostBase):
id: int
class Config:
orm_mode = True
class CommentBase(BaseModel):
user_id: int
body: str
class CommentCreate(CommentBase):
pass
class CommentResult(CommentBase):
id: int
post_id: int
class Config:
orm_mode = True
API Endpoints
Now, let's add the API endpoints. In the main.py file, import in the Pydantic schema and the Masonite models:
import schema
from models.Post import Post
from models.User import User
from models.Comment import Comment
To get all users, we can use Masonite's .all method call on the User model on the collection instance returned:
@app.get("/api/v1/users", response_model=List[schema.UserResult])
def get_all_users():
users = User.all()
return users.all()
Make sure to import typing.List
:
from typing import List
To add a user, add the following POST endpoint:
@app.post("/api/v1/users", response_model=schema.UserResult)
def add_user(user_data: schema.UserCreate):
user = User.where("email", user_data.email).get()
if user:
raise HTTPException(status_code=400, detail="User already exists")
user = User()
user.email = user_data.email
user.name = user_data.name
user.address = user_data.address
user.sex = user_data.sex
user.phone_number = user_data.phone_number
user.save() # saves user details to the database.
return user
Import HTTPException:
from fastapi import FastAPI, HTTPException
Retrieve a single user:
@app.get("/api/v1/users/{user_id}", response_model=schema.UserResult)
def get_single_user(user_id: int):
user = User.find(user_id)
return user
Post endpoints:
@app.get("/api/v1/posts", response_model=List[schema.PostResult])
def get_all_posts():
all_posts = Post.all()
return all_posts.all()
@app.get("/api/v1/posts/{post_id}", response_model=schema.PostResult)
def get_single_post(post_id: int):
post = Post.find(post_id)
return post
@app.post("/api/v1/posts", response_model=schema.PostResult)
def add_new_post(post_data: schema.PostCreate):
user = User.find(post_data.user_id)
if not user:
raise HTTPException(status_code=400, detail="User not found")
post = Post()
post.title = post_data.title
post.body = post_data.body
post.user_id = post_data.user_id
post.save()
user.attach("posts", post)
return post
We saved the data from the API into the posts
table on the database, and then in order to link the Post to the User, we attached it so that when we called user.posts()
, we got all the user's posts.
Comment endpoints:
@app.post("/api/v1/{post_id}/comments", response_model=schema.CommentResult)
def add_new_comment(post_id: int, comment_data: schema.CommentCreate):
post = Post.find(post_id)
if not post:
raise HTTPException(status_code=400, detail="Post not found")
user = User.find(comment_data.user_id)
if not user:
raise HTTPException(status_code=400, detail="User not found")
comment = Comment()
comment.body = comment_data.body
comment.user_id = comment_data.user_id
comment.post_id = post_id
comment.save()
user.attach("comments", comment)
post.attach("comments", comment)
return comment
@app.get("/api/v1/posts/{post_id}/comments", response_model=List[schema.CommentResult])
def get_post_comments(post_id):
post = Post.find(post_id)
return post.comments.all()
@app.get("/api/v1/users/{user_id}/comments", response_model=List[schema.CommentResult])
def get_user_comments(user_id):
user = User.find(user_id)
return user.comments.all()
Start up the FastAPI server if it's not already running:
(.env)$ uvicorn main:app --reload
Navigate to http://localhost:8000/docs to view the Swagger/OpenAPI documentation of all the endpoints. Test each endpoint out to see the response.
Tests
Since we're good citizens, we'll add some tests.
Fixtures
Let's write tests for our code above. Since we'll be using pytest, go ahead and add the dependency to the requirements.txt file:
pytest==7.2.1
We would also need the HTTPX library since FastAPI's TestClient is based on it. Add it to the requirements file as well:
httpx==0.23.3
Install:
(.env)$ pip install -r requirements.txt
Next, let's create a separate config file for our tests to use so we don't overwrite data in our main development database. Inside the "config" folder, create a new file called test_config.py:
# config/test_config.py
from masoniteorm.connections import ConnectionResolver
DATABASES = {
"default": "sqlite",
"sqlite": {
"driver": "sqlite",
"database": "db.sqlite3",
}
}
DB = ConnectionResolver().set_connection_details(DATABASES)
Notice that it's similar to what we have in the config/database.py file. The only difference is that we set our default
to be sqlite
as we want to use SQLite for testing.
In order to set our test suites to always make use of our config in the test_config.py configuration instead of the default database.py file, we can make use of pytest's autouse fixture.
Create a new folder called "tests", and within that new folder create a conftest.py file:
import pytest
from masoniteorm.migrations import Migration
@pytest.fixture(autouse=True)
def setup_database():
config_path = "config/test_config.py"
migrator = Migration(config_path=config_path)
migrator.create_table_if_not_exists()
migrator.refresh()
Here, we set Masonite's migration configuration path to the config/test_config.py file, created the migration
table if it has not already been created before, and then refreshed all the migrations. So, every test will start with a clean copy of the database.
Now, let's define some fixtures for a user, post, and comment:
import pytest
from masoniteorm.migrations import Migration
from models.Comment import Comment
from models.Post import Post
from models.User import User
@pytest.fixture(autouse=True)
def setup_database():
config_path = "config/test_config.py"
migrator = Migration(config_path=config_path)
migrator.create_table_if_not_exists()
migrator.refresh()
@pytest.fixture(scope="function")
def user():
user = User()
user.name = "John Doe"
user.address = "United States of Nigeria"
user.phone_number = 123456789
user.sex = "male"
user.email = "[email protected]"
user.save()
return user
@pytest.fixture(scope="function")
def post(user):
post = Post()
post.title = "Test Title"
post.body = "this is the post body and can be as long as possible"
post.user_id = user.id
post.save()
user.attach("posts", post)
return post
@pytest.fixture(scope="function")
def comment(user, post):
comment = Comment()
comment.body = "This is a comment body"
comment.user_id = user.id
comment.post_id = post.id
comment.save()
user.attach("comments", comment)
post.attach("comments", comment)
return comment
With that, we can now start writing some tests.
Test Specs
Create a new test file in the "tests" folder called test_views.py.
Start by adding the following to instantiate a TestClient:
from fastapi.testclient import TestClient
from main import app # => FastAPI app created in our main.py file
client = TestClient(app)
Now, we'll add tests to:
- Save a user
- Get all users
- Get a single user using the user's ID
Code:
from fastapi.testclient import TestClient
from main import app # => FastAPI app created in our main.py file
from models.User import User
client = TestClient(app)
def test_create_new_user():
assert len(User.all()) == 0 # Asserting that there's no user in the database
payload = {
"name": "My name",
"email": "[email protected]",
"address": "My full Address",
"sex": "male",
"phone_number": 123456789
}
response = client.post("/api/v1/users", json=payload)
assert response.status_code == 200
assert len(User.all()) == 1
def test_get_all_user_details(user):
response = client.get("/api/v1/users")
assert response.status_code == 200
result = response.json()
assert type(result) is list
assert len(result) == 1
assert result[0]["name"] == user.name
assert result[0]["email"] == user.email
assert result[0]["id"] == user.id
# Test to get a single user
def test_get_single_user(user):
response = client.get(f"/api/v1/users/{user.id}")
assert response.status_code == 200
result = response.json()
assert type(result) is dict
assert result["name"] == user.name
assert result["email"] == user.email
assert result["id"] == user.id
Run the tests to ensure they pass:
(.env)$ python -m pytest
Try writing tests for the post and comment views as well.
Conclusion
In this tutorial, we covered how to use the Masonite ORM together with FastAPI. The Masonite ORM is a relatively new ORM library with an active community. If you have experience with the Orator ORM (or any other Python-based ORM, for that matter), the Masonite ORM should be a breeze to use.