# Configure Pydantic schemas and User model

Now it is time to get more serious stuff. We need proper User model, proper Pydantic schemas and simple endpoint to test our work. I have adopted, changed the code from Designing a robust User Model (opens new window)

Let's get started. First of all let's update our User model with extra columns:

from backend.app.main import db


class User(db.Model):
    __tablename__ = "users"

    id = db.Column(db.BigInteger(), primary_key=True)
    username = db.Column(db.Unicode(), unique=True, nullable=False)
    email = db.Column(db.String(), unique=True, nullable=False)
    email_verified = db.Column(db.Boolean(), nullable=True, server_default="True")
    salt = db.Column(db.Unicode(), nullable=False)
    password = db.Column(db.Unicode(), nullable=False)
    is_active = db.Column(db.Boolean(), nullable=False, server_default="True")
    is_superuser = db.Column(db.Boolean(), nullable=False, server_default="False")
    created_at = db.Column(db.DateTime(), nullable=False)
    updated_at = db.Column(db.DateTime(), nullable=False)

Create migration file:

❯ poetry run alembic revision --autogenerate -m 'update users table'
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.ddl.postgresql] Detected sequence named 'users_id_seq' as owned by integer column 'users(id)', assuming SERIAL and omitting
INFO  [alembic.autogenerate.compare] Detected removed table 'users'
  Generating /home/shako/REPOS/Learning_FastAPI/Djackets/backend/migrations/versions/0508f9ca0879_update_users_table.py ...  done

Run the migration:

poetry run alembic upgrade head
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 43774c187998 -> 0508f9ca0879, update users table

Next lets define our shared schemas in app/schemas.py file:

# Define core Pydantic schemas here
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, validator


class CoreModel(BaseModel):
    """
    Any common logic to be shared by all models goes here
    """
    pass


class DateTimeModelMixin(BaseModel):
    created_at: Optional[datetime]
    updated_at: Optional[datetime]
    
    @validator("created_at", "updated_at", pre=True, always=True)
    def default_datetime(cls, value: datetime) -> datetime:
        return value or datetime.now()


class IDModelMixin(BaseModel):
    id: int

Our plan here is to have something code and shared Pydantic models to be used in different schemas. DateTimeModelMixin has 2 two properties which will be populated automatically. Basically, we are going to add created_at and updated_at with datetime.now().

Now it is time to add our User schemas:

import string
from pydantic import EmailStr, constr, validator
from backend.app.schemas import CoreModel, DateTimeModelMixin, IDModelMixin
from typing import Optional


# simple check for valid username
def validate_username(username: str) -> str:
    allowed = string.ascii_letters + string.digits + "-" + "_"
    assert all(char in allowed for char in username), "Invalid characters in username."
    assert len(username) >= 3, "Username must be 3 characters or more."
    return username


class UserBase(CoreModel):
    """
    Leaving off password and salt from base model
    """
    email: Optional[EmailStr]
    username: Optional[str]
    email_verified: bool = False
    is_active: bool = True
    is_superuser: bool = False


class UserCreate(CoreModel):
    """
    Email, username, and password are required for registering a new user
    """
    email: EmailStr
    password: constr(min_length=7, max_length=100)
    username: str

    @validator("username", pre=True)
    def username_is_valid(cls, username: str) -> str:
        return validate_username(username)


class UserInDB(IDModelMixin, DateTimeModelMixin, UserBase):
    """
    Add in id, created_at, updated_at, and user's password and salt
    """
    password: constr(min_length=7, max_length=100)
    salt: str


class UserPublic(DateTimeModelMixin, UserBase):
    pass


# TODO: UserUpdate for profile update can be here

# TODO: UserPasswordUpdate for password update can be here

Why we need UserPublic? It is basically for response model, we do not want to return password, salt, etc back to the user.

Now let's add our first view(or controller) to test. I am going to remove v1.py file from users/api/ and add controller.py instead:

from fastapi import APIRouter, status, Body
from fastapi.responses import JSONResponse
from ..schemas import UserCreate, UserInDB, UserPublic

router = APIRouter()


@router.post(
    "/create",
    tags=["user registration"],
    description="Register the User",
    response_model=UserPublic,
)
async def user_create(user: UserCreate):
    return user

What we have so far? We have indicated that we want as input UserCreate model which is from Pydantic, we also have response_model which is a neat thing to indicate what the frontend side will get back after sending POST request to our endpoint.

Wait, we need to register our router. Open the app/main.py file and update:

import sys

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from .core.config import settings
from .database import db

sys.path.append('..') # fixing parent folder import error

from backend.users.api.controller import router as user_router


def get_application():
    _app = FastAPI(title=settings.PROJECT_NAME)

    _app.add_middleware(
        CORSMiddleware,
        allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    db.init_app(_app)

    _app.include_router(user_router, prefix='/users') # this is the new added
    
    return _app


app = get_application()

Fire up the server:

❯ fastapi run
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [1322] using statreload
INFO:     Started server process [1325]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

You can go to http://127.0.0.1:8000/docs and see our new endpoint and even test it there.

For simplicity I am going to run cURL here:

curl -X POST http://127.0.0.1:8000/users/create -d '{"email": "example@gmail.com", "password": "123456789", "username": "example"}' | jq

{
  "email": "example@gmail.com",
  "username": "example",
  "email_verified": false,
  "is_active": true,
  "is_superuser": false,
  "created_at": "2021-05-14T16:15:36.070429",
  "updated_at": "2021-05-14T16:15:36.070445"
}

As you see the request data is what we have defined in UserCreate schema, and the response data was automatically populated for us from UserPublic schema.

That is it for now. So we have setup the User model, User schemas and we have our simple endpoint for just returning back the UserPublic data. But It is fully functional and even the validation is in place:

curl -X POST http://127.0.0.1:8000/users/create -d '{"email": "example@gmail.com", "password": "12345", "username": "example"}' | jq

{
  "detail": [
    {
      "loc": [
        "body",
        "password"
      ],
      "msg": "ensure this value has at least 7 characters",
      "type": "value_error.any_str.min_length",
      "ctx": {
        "limit_value": 7
      }
    }
  ]
}

Great the next thing is to improve our User registration and to add password salt/hashing.

The code changes for this episode -> episode-4 (opens new window)

# NEXT -> Configure User registration - password hashing