Skip to content

Commit 8711ae0

Browse files
author
Rafał Safin
committed
readme - update about and step by step section
Signed-off-by: Rafał Safin <rafal.safin@rafsaf.pl>
1 parent 5a19c5e commit 8711ae0

File tree

1 file changed

+85
-85
lines changed

1 file changed

+85
-85
lines changed

README.md

Lines changed: 85 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ cd your_project_name
6464
poetry install
6565
```
6666

67-
Note, be sure to use `python3.12` with this template with either poetry or standard venv & pip, if you need to stick to some earlier python version, you should adapt it yourself (remove new versions specific syntax for example `str | int` for python < 3.10 or `tomllib` for python < 3.11)
67+
Note, be sure to use `python3.12` with this template with either poetry or standard venv & pip, if you need to stick to some earlier python version, you should adapt it yourself (remove new versions specific syntax for example `str | int` for python < 3.10)
6868

6969
### 3. Setup database and migrations
7070

@@ -84,11 +84,11 @@ uvicorn app.main:app --reload
8484

8585
```
8686

87-
You should then use `git init` to initialize git repository and access OpenAPI spec at http://localhost:8000/ by default. To customize docs url, cors and allowed hosts settings, read section about it.
87+
You should then use `git init` (if needed) to initialize git repository and access OpenAPI spec at http://localhost:8000/ by default. To customize docs url, cors and allowed hosts settings, read [section about it](#docs-url-cors-and-allowed-hosts).
8888

8989
### 5. Activate pre-commit
9090

91-
[pre-commit](https://pre-commit.com/) is de facto standard now for pre push activities like isort or black or its replacement ruff.
91+
[pre-commit](https://pre-commit.com/) is de facto standard now for pre push activities like isort or black or its nowadays replacement ruff.
9292

9393
Refer to `.pre-commit-config.yaml` file to see my current opinionated choices.
9494

@@ -119,6 +119,14 @@ This project is heavily based on the official template https://github.com/tiango
119119

120120
`2.0` style SQLAlchemy API is good enough so there is no need to write everything in `crud` and waste our time... The `core` folder was also rewritten. There is great base for writting tests in `tests`, but I didn't want to write hundreds of them, I noticed that usually after changes in the structure of the project, auto tests are useless and you have to write them from scratch anyway (delete old ones...), hence less than more. Similarly with the `User` model, it is very modest, with just `id` (uuid), `email` and `password_hash`, because it will be adapted to the project anyway.
121121

122+
2024 update:
123+
124+
The template was adpoted to my current style and knowledge, the test based expanded to cover more, added mypy, ruff and test setup was completly rewritten to have three things:
125+
126+
- run test in paraller in many processes for speed
127+
- transactions rollback after every test
128+
- create test databases instead of having another in docker-compose.yml
129+
122130
<br>
123131

124132
## Step by step example - POST and GET endpoints
@@ -132,48 +140,25 @@ I always enjoy to have some kind of an example in templates (even if I don't lik
132140

133141
### 1. Create SQLAlchemy model
134142

135-
We will add Pet model to `app/models.py`. To keep things clear, below is full result of models.py file.
143+
We will add `Pet` model to `app/models.py`.
136144

137145
```python
138146
# app/models.py
139147

140-
import uuid
141-
142-
from sqlalchemy import ForeignKey, Integer, String
143-
from sqlalchemy.dialects.postgresql import UUID
144-
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
145-
146-
147-
class Base(DeclarativeBase):
148-
pass
149-
150-
151-
class User(Base):
152-
__tablename__ = "user_model"
153-
154-
id: Mapped[str] = mapped_column(
155-
UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
156-
)
157-
email: Mapped[str] = mapped_column(
158-
String(254), nullable=False, unique=True, index=True
159-
)
160-
hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
161-
148+
...
162149

163150
class Pet(Base):
164151
__tablename__ = "pet"
165152

166-
id: Mapped[int] = mapped_column(Integer, primary_key=True)
153+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
167154
user_id: Mapped[str] = mapped_column(
168-
ForeignKey("user_model.id", ondelete="CASCADE"),
155+
ForeignKey("user_account.user_id", ondelete="CASCADE"),
169156
)
170157
pet_name: Mapped[str] = mapped_column(String(50), nullable=False)
171158

172-
173-
174159
```
175160

176-
Note, we are using super powerful SQLAlchemy feature here - Mapped and mapped_column were first introduced in SQLAlchemy 2.0 on Feb 26, if this syntax is new for you, read carefully "what's new" part of documentation https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html.
161+
Note, we are using super powerful SQLAlchemy feature here - Mapped and mapped_column were first introduced in SQLAlchemy 2.0, if this syntax is new for you, read carefully "what's new" part of documentation https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html.
177162

178163
<br>
179164

@@ -199,15 +184,13 @@ alembic upgrade head
199184
# INFO [alembic.runtime.migration] Running upgrade d1252175c146 -> 44b7b689ea5f, create_pet_model
200185
```
201186

202-
PS. Note, alembic is configured in a way that it work with async setup and also detects specific column changes.
187+
PS. Note, alembic is configured in a way that it work with async setup and also detects specific column changes if using `--autogenerate` flag.
203188

204189
<br>
205190

206191
### 3. Create request and response schemas
207192

208-
I personally lately (after seeing clear benefits at work in Samsung) prefer less files than a lot of them for things like schemas.
209-
210-
Thats why there are only 2 files: `requests.py` and `responses.py` in `schemas` folder and I would keep it that way even for few dozen of endpoints. Not to mention this is opinionated.
193+
There are only 2 files: `requests.py` and `responses.py` in `schemas` folder and I would keep it that way even for few dozen of endpoints. Not to mention this is opinionated.
211194

212195
```python
213196
# app/schemas/requests.py
@@ -238,9 +221,9 @@ class PetResponse(BaseResponse):
238221
### 4. Create endpoints
239222

240223
```python
241-
# /app/api/endpoints/pets.py
224+
# app/api/endpoints/pets.py
242225

243-
from fastapi import APIRouter, Depends
226+
from fastapi import APIRouter, Depends, status
244227
from sqlalchemy import select
245228
from sqlalchemy.ext.asyncio import AsyncSession
246229

@@ -252,46 +235,54 @@ from app.schemas.responses import PetResponse
252235
router = APIRouter()
253236

254237

255-
@router.post("/create", response_model=PetResponse, status_code=201)
238+
@router.post(
239+
"/create",
240+
response_model=PetResponse,
241+
status_code=status.HTTP_201_CREATED,
242+
description="Creates new pet. Only for logged users.",
243+
)
256244
async def create_new_pet(
257-
new_pet: PetCreateRequest,
245+
data: PetCreateRequest,
258246
session: AsyncSession = Depends(deps.get_session),
259247
current_user: User = Depends(deps.get_current_user),
260-
):
261-
"""Creates new pet. Only for logged users."""
248+
) -> Pet:
249+
new_pet = Pet(user_id=current_user.user_id, pet_name=data.pet_name)
262250

263-
pet = Pet(user_id=current_user.id, pet_name=new_pet.pet_name)
264-
265-
session.add(pet)
251+
session.add(new_pet)
266252
await session.commit()
267-
return pet
253+
254+
return new_pet
268255

269256

270-
@router.get("/me", response_model=list[PetResponse], status_code=200)
257+
@router.get(
258+
"/me",
259+
response_model=list[PetResponse],
260+
status_code=status.HTTP_200_OK,
261+
description="Get list of pets for currently logged user.",
262+
)
271263
async def get_all_my_pets(
272264
session: AsyncSession = Depends(deps.get_session),
273265
current_user: User = Depends(deps.get_current_user),
274-
):
275-
"""Get list of pets for currently logged user."""
266+
) -> list[Pet]:
267+
pets = await session.scalars(
268+
select(Pet).where(Pet.user_id == current_user.user_id).order_by(Pet.pet_name)
269+
)
276270

277-
stmt = select(Pet).where(Pet.user_id == current_user.id).order_by(Pet.pet_name)
278-
pets = await session.execute(stmt)
279-
return pets.scalars().all()
271+
return list(pets.all())
280272

281273
```
282274

283275
Also, we need to add newly created endpoints to router.
284276

285277
```python
286-
# /app/api/api.py
278+
# app/api/api.py
287279

288-
from fastapi import APIRouter
280+
...
289281

290282
from app.api.endpoints import auth, pets, users
291283

292-
api_router = APIRouter()
293-
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
294-
api_router.include_router(users.router, prefix="/users", tags=["users"])
284+
...
285+
295286
api_router.include_router(pets.router, prefix="/pets", tags=["pets"])
296287

297288
```
@@ -300,9 +291,12 @@ api_router.include_router(pets.router, prefix="/pets", tags=["pets"])
300291

301292
### 5. Write tests
302293

294+
We will write two really simple tests in combined file inside newly created `app/tests/test_pets` folder.
295+
303296
```python
304-
# /app/tests/test_pets.py
297+
# app/tests/test_pets/test_pets.py
305298

299+
from fastapi import status
306300
from httpx import AsyncClient
307301
from sqlalchemy.ext.asyncio import AsyncSession
308302

@@ -311,24 +305,29 @@ from app.models import Pet, User
311305

312306

313307
async def test_create_new_pet(
314-
client: AsyncClient, default_user_headers, default_user: User
315-
):
308+
client: AsyncClient, default_user_headers: dict[str, str], default_user: User
309+
) -> None:
316310
response = await client.post(
317311
app.url_path_for("create_new_pet"),
318312
headers=default_user_headers,
319313
json={"pet_name": "Tadeusz"},
320314
)
321-
assert response.status_code == 201
315+
assert response.status_code == status.HTTP_201_CREATED
316+
322317
result = response.json()
323-
assert result["user_id"] == default_user.id
318+
assert result["user_id"] == default_user.user_id
324319
assert result["pet_name"] == "Tadeusz"
325320

326321

327322
async def test_get_all_my_pets(
328-
client: AsyncClient, default_user_headers, default_user: User, session: AsyncSession
329-
):
330-
pet1 = Pet(user_id=default_user.id, pet_name="Pet_1")
331-
pet2 = Pet(user_id=default_user.id, pet_name="Pet_2")
323+
client: AsyncClient,
324+
default_user_headers: dict[str, str],
325+
default_user: User,
326+
session: AsyncSession,
327+
) -> None:
328+
pet1 = Pet(user_id=default_user.user_id, pet_name="Pet_1")
329+
pet2 = Pet(user_id=default_user.user_id, pet_name="Pet_2")
330+
332331
session.add(pet1)
333332
session.add(pet2)
334333
await session.commit()
@@ -337,7 +336,7 @@ async def test_get_all_my_pets(
337336
app.url_path_for("get_all_my_pets"),
338337
headers=default_user_headers,
339338
)
340-
assert response.status_code == 200
339+
assert response.status_code == status.HTTP_200_OK
341340

342341
assert response.json() == [
343342
{
@@ -352,6 +351,7 @@ async def test_get_all_my_pets(
352351
},
353352
]
354353

354+
355355
```
356356

357357
## Design
@@ -368,29 +368,29 @@ There are some **opinionated** default settings in `/app/main.py` for documentat
368368

369369
1. Docs
370370

371-
```python
372-
app = FastAPI(
373-
title=config.settings.PROJECT_NAME,
374-
version=config.settings.VERSION,
375-
description=config.settings.DESCRIPTION,
376-
openapi_url="/openapi.json",
377-
docs_url="/",
378-
)
379-
```
371+
```python
372+
app = FastAPI(
373+
title="minimal fastapi postgres template",
374+
version="6.0.0",
375+
description="https://github.com/rafsaf/minimal-fastapi-postgres-template",
376+
openapi_url="/openapi.json",
377+
docs_url="/",
378+
)
379+
```
380380

381-
Docs page is simpy `/` (by default in FastAPI it is `/docs`). Title, version and description are taken directly from `config` and then directly from `pyproject.toml` file. You can change it completely for the project, remove or use environment variables `PROJECT_NAME`, `VERSION`, `DESCRIPTION`.
381+
Docs page is simpy `/` (by default in FastAPI it is `/docs`). You can change it completely for the project, just as title, version, etc.
382382

383383
2. CORS
384384

385-
```python
386-
app.add_middleware(
387-
CORSMiddleware,
388-
allow_origins=[str(origin) for origin in config.settings.BACKEND_CORS_ORIGINS],
389-
allow_credentials=True,
390-
allow_methods=["*"],
391-
allow_headers=["*"],
392-
)
393-
```
385+
```python
386+
app.add_middleware(
387+
CORSMiddleware,
388+
allow_origins=[str(origin) for origin in config.settings.BACKEND_CORS_ORIGINS],
389+
allow_credentials=True,
390+
allow_methods=["*"],
391+
allow_headers=["*"],
392+
)
393+
```
394394

395395
If you are not sure what are CORS for, follow https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS. React and most frontend frameworks nowadays operate on `http://localhost:3000` thats why it's included in `BACKEND_CORS_ORIGINS` in .env file, before going production be sure to include your frontend domain here, like `https://my-fontend-app.example.com`.
396396

0 commit comments

Comments
 (0)