Skip to content

Commit 5f55392

Browse files
committed
Initial commit
0 parents  commit 5f55392

32 files changed

+978
-0
lines changed

.dockerignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.git/
2+
.github/
3+
.pytest_cache/
4+
*pycache*
5+
.idea/
6+
.vscode/
7+
.env

.github/workflows/test.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Test
2+
on:
3+
- push
4+
5+
jobs:
6+
test:
7+
name: Test on Python ${{ matrix.python-version }} @ ubuntu-latest
8+
runs-on: ubuntu-latest
9+
strategy:
10+
matrix:
11+
python-version:
12+
- "3.6"
13+
- "3.7"
14+
- "3.8"
15+
services:
16+
mongodb:
17+
image: mongo:latest
18+
ports:
19+
- 27017:27017
20+
steps:
21+
- uses: actions/checkout@master
22+
- name: Setup Python
23+
uses: actions/setup-python@v1
24+
with:
25+
python-version: ${{ matrix.python-version }}
26+
architecture: x64
27+
- name: Install requirements
28+
run: make install-all-requirements
29+
- name: Test
30+
run: make test

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.idea/
2+
.vscode/
3+
*pycache*/
4+
.env

Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM python:3.8
2+
3+
RUN useradd -ms /bin/bash user
4+
USER user
5+
6+
COPY . /home/user/app/
7+
WORKDIR /home/user/app
8+
RUN pip install --user -r requirements.txt
9+
10+
CMD make run

LICENSE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Copyright 2020 David Lorenzo
2+
3+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
4+
5+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
6+

Makefile

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.DEFAULT_GOAL := help
2+
3+
install-requirements: ## pip install requirements for app
4+
pip install -r requirements.txt
5+
6+
install-test-requirements: ## pip install requirements for tests
7+
pip install -r requirements-test.txt
8+
9+
install-all-requirements: ## pip install requirements for app & tests
10+
make install-requirements
11+
make install-test-requirements
12+
13+
test: ## run tests
14+
pytest -sv .
15+
16+
run: ## python run app
17+
python .
18+
19+
run-docker: ## start running through docker-compose
20+
docker-compose up
21+
22+
run-docker-background: ## start running through docker-compose, detached
23+
docker-compose up -d
24+
25+
teardown-docker: ## remove from docker through docker-compose
26+
docker-compose down
27+
28+
help: ## show this help.
29+
@fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//'

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# FastAPI + Pydantic + MongoDB REST API Example
2+
3+
Sample API using FastAPI, Pydantic models and settings, and MongoDB as database - non-async.
4+
5+
The API works with a single entity, "Person" (or "People" in plural) that gets stored on a single Mongo database and collection.
6+
7+
## Endpoints
8+
9+
- GET `/people` - list all available persons
10+
- GET `/people/{person_id}` - get a single person by its unique ID
11+
- POST `/people` - create a new person
12+
- PATCH `/people/{person_id}` - update an existing person
13+
- DELETE `/people/{person_id}` - delete an existing person
14+
15+
## Project structure (modules)
16+
17+
- `app.py`: initialization of FastAPI and all the routes used by the API. On APIs with more endpoints and different entities, would be better to split the routes in different modules by their context or entity.
18+
- `models`: definition of all model classes. As we are using MongoDB, we can use the same JSON schema for API request/response and storage. However, different classes for the same entity are required, depending on the context:
19+
- `person_update.py`: model used as PATCH request body. Includes all the fields that can be updated, set as optional.
20+
- `person_create.py`: model used as POST request body. Includes all the fields from the Update model, but all those fields that are required on Create, must be re-declared (in type and Field value).
21+
- `person_read.py`: model used as GET and POST response body. Includes all the fields from the Create model, plus the person_id (which comes from the _id field in Mongo document) and the age (calculated from the date of birth, if any).
22+
- `person_address.py`: part of the Person model, address attribute.
23+
- `common.py`: definition of the common BaseModel, from which all the model classes inherit, directly or indirectly.
24+
- `fields.py`: definition of Fields, which are the values of the models attributes. Their main purpose is to complete the OpenAPI documentation by providing a description and examples. Fields are declared outside the classes because of the re-declaration required between Update and Create models.
25+
- `database.py`: initialization of MongoDB client. Actually is very short as Mongo/pymongo do not require to pre-connecting to Mongo or setup the database/collection, but with other databases (like SQL-like using SQLAlchemy) this can get more complex.
26+
- `exceptions.py`: custom exceptions, that can be translated to JSON responses the API can return to clients (mainly if a Person does not exist or already exists).
27+
- `middlewares.py`: the Request Handler middleware catches the exceptions raised while processing requests, and tries to translate them into responses given to the clients.
28+
- `repositories.py`: methods that interact with the Mongo database to read or write Person data. These methods are directly called from the route handlers.
29+
- `settings.py`: load of application settings through environment variables or dotenv file, using Pydantic's BaseSettings classes.
30+
- `utils.py`: misc helper functions.
31+
- `tests`: acceptance+integration tests, ran directly against a running API and real Mongo database.
32+
33+
## Requirements
34+
35+
- Python >= 3.6
36+
- Requirements listed on [requirements.txt](requirements.txt)
37+
- Running MongoDB server
38+
39+
## make tools
40+
41+
```bash
42+
# Install requirements
43+
make install-requirements
44+
45+
# Run the app (available at http://localhost:5000/...)
46+
make run
47+
48+
# Install test requirements
49+
make install-test-requirements
50+
51+
# Run the tests
52+
make test
53+
```

__main__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from people_api import run
2+
3+
run()

docker-compose.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
version: '3'
2+
3+
services:
4+
people_api:
5+
build:
6+
context: .
7+
ports:
8+
- 5000:5000
9+
volumes:
10+
- /etc/localtime:/etc/localtime:ro
11+
- /etc/timezone:/etc/timezone:ro
12+
environment:
13+
- MONGO_URI=mongodb://mongodb:27017
14+
15+
mongodb:
16+
image: mongo:latest
17+
volumes:
18+
- /etc/localtime:/etc/localtime:ro
19+
- /etc/timezone:/etc/timezone:ro

people_api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .app import app, run

people_api/app.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""APP
2+
FastAPI app definition, initialization and definition of routes
3+
"""
4+
5+
# # Installed # #
6+
import uvicorn
7+
from fastapi import FastAPI
8+
from fastapi import status as statuscode
9+
10+
# # Package # #
11+
from .models import *
12+
from .repositories import PeopleRepository
13+
from .middlewares import request_handler
14+
from .settings import api_settings as settings
15+
16+
__all__ = ("app", "run")
17+
18+
19+
app = FastAPI(
20+
title=settings.title
21+
)
22+
app.middleware("http")(request_handler)
23+
24+
25+
@app.get(
26+
"/people",
27+
response_model=PeopleRead,
28+
description="List all the available persons",
29+
tags=["people"]
30+
)
31+
def _list_people():
32+
# TODO Filters
33+
return PeopleRepository.list()
34+
35+
36+
@app.get(
37+
"/people/{person_id}",
38+
response_model=PersonRead,
39+
description="Get a single person by its unique ID",
40+
tags=["people"]
41+
)
42+
def _get_person(person_id: str):
43+
return PeopleRepository.get(person_id)
44+
45+
46+
@app.post(
47+
"/people",
48+
description="Create a new person",
49+
response_model=PersonRead,
50+
status_code=statuscode.HTTP_201_CREATED,
51+
tags=["people"]
52+
)
53+
def _create_person(create: PersonCreate):
54+
return PeopleRepository.create(create)
55+
56+
57+
@app.patch(
58+
"/people/{person_id}",
59+
description="Update a single person by its unique ID, providing the fields to update",
60+
status_code=statuscode.HTTP_204_NO_CONTENT,
61+
tags=["people"]
62+
)
63+
def _update_person(person_id: str, update: PersonUpdate):
64+
PeopleRepository.update(person_id, update)
65+
66+
67+
@app.delete(
68+
"/people/{person_id}",
69+
description="Delete a single person by its unique ID",
70+
status_code=statuscode.HTTP_204_NO_CONTENT,
71+
tags=["people"]
72+
)
73+
def _delete_person(person_id: str):
74+
PeopleRepository.delete(person_id)
75+
76+
77+
def run():
78+
"""Run the API using Uvicorn"""
79+
uvicorn.run(
80+
app,
81+
host=settings.host,
82+
port=settings.port,
83+
log_level=settings.log_level.lower()
84+
)

people_api/database.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""DATABASE
2+
MongoDB database initialization
3+
"""
4+
5+
# # Installed # #
6+
from pymongo import MongoClient
7+
from pymongo.collection import Collection
8+
9+
# # Package # #
10+
from .settings import mongo_settings as settings
11+
12+
__all__ = ("client", "collection")
13+
14+
client = MongoClient(settings.uri)
15+
collection: Collection = client[settings.database][settings.collection]

people_api/exceptions.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""EXCEPTIONS
2+
Custom exceptions with responses
3+
"""
4+
5+
# # Installed # #
6+
from fastapi.responses import JSONResponse
7+
from fastapi import status as statuscode
8+
9+
__all__ = (
10+
"EntityError", "NotFound",
11+
"PersonNotFound"
12+
)
13+
14+
15+
class EntityError(Exception):
16+
"""Base errors for entities, uniquely identified"""
17+
message = "Entity error"
18+
code = statuscode.HTTP_500_INTERNAL_SERVER_ERROR
19+
20+
def __init__(self, identifier):
21+
self.id = identifier
22+
23+
@property
24+
def response(self):
25+
return JSONResponse(
26+
content={
27+
"id": self.id,
28+
"message": self.message
29+
},
30+
status_code=self.code
31+
)
32+
33+
34+
class NotFound(EntityError):
35+
"""The entity does not exist"""
36+
message = "The entity does not exist"
37+
code = statuscode.HTTP_404_NOT_FOUND
38+
39+
40+
class PersonNotFound(NotFound):
41+
"""The Person does not exist"""
42+
message = "The person does not exist"

people_api/middlewares.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""MIDDLEWARES
2+
Functions that run as something gets processed
3+
"""
4+
5+
# # Installed # #
6+
from fastapi import Request
7+
8+
# # Package # #
9+
from .exceptions import *
10+
11+
__all__ = ("request_handler",)
12+
13+
14+
async def request_handler(request: Request, call_next):
15+
"""Middleware used to process each request on FastAPI, to provide error handling (convert exceptions to responses).
16+
TODO: add logging and individual request traceability
17+
"""
18+
try:
19+
return await call_next(request)
20+
21+
except Exception as ex:
22+
if isinstance(ex, EntityError):
23+
return ex.response
24+
25+
raise ex

people_api/models/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .person_update import *
2+
from .person_create import *
3+
from .person_read import *
4+
from .person_address import *

people_api/models/common.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""MODELS - COMMON
2+
Common variables and base classes for the models
3+
"""
4+
5+
# # Installed # #
6+
import pydantic
7+
8+
__all__ = ("BaseModel",)
9+
10+
11+
class BaseModel(pydantic.BaseModel):
12+
"""All data models inherit from this class"""
13+
14+
@pydantic.root_validator(pre=True)
15+
def _min_properties(cls, data):
16+
"""At least one property is required"""
17+
if not data:
18+
raise ValueError("At least one property is required")
19+
return data
20+
21+
def dict(self, include_nulls=False, **kwargs):
22+
"""Override the super dict method by removing null keys from the dict, unless include_nulls=True"""
23+
kwargs["exclude_none"] = not include_nulls
24+
return super().dict(**kwargs)
25+
26+
class Config:
27+
extra = pydantic.Extra.forbid # forbid sending additional fields/properties
28+
anystr_strip_whitespace = True # strip whitespaces from strings

0 commit comments

Comments
 (0)