Implement MongoDB database adapter (#29)

* Implement MongoDB adapter using motor

* Add mongo container to build pipeline

* Tidy up dependencies

* Update documentation for MongoDB

* Export MongoDB adapter from db package

* Pass black format

* Update README
This commit is contained in:
François Voron
2019-10-27 16:34:30 +01:00
committed by GitHub
parent 3875632c80
commit ab0b187f20
15 changed files with 255 additions and 14 deletions

View File

@ -10,6 +10,12 @@ jobs:
matrix:
python_version: [3.7]
services:
mongo:
image: mvertes/alpine-mongo
ports:
- 27017:27017
steps:
- uses: actions/checkout@v1
- name: Set up Python 3.7

View File

@ -1,4 +1,5 @@
PIPENV_RUN := pipenv run
MONGODB_CONTAINER_NAME := fastapi-users-test-mongo
isort-src:
$(PIPENV_RUN) isort -rc ./fastapi_users
@ -10,7 +11,9 @@ format: isort-src isort-docs
$(PIPENV_RUN) black .
test:
docker run -d --rm --name $(MONGODB_CONTAINER_NAME) -p 27017:27017 mvertes/alpine-mongo
$(PIPENV_RUN) pytest --cov=fastapi_users/
docker stop $(MONGODB_CONTAINER_NAME)
docs-serve:
$(PIPENV_RUN) mkdocs serve

View File

@ -33,6 +33,7 @@ sqlalchemy = "==1.3.10"
databases = "==0.2.5"
pyjwt = "==1.7.1"
python-multipart = "==0.0.5"
motor = "==2.0.0"
[requires]
python_version = "3.7"

45
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "91f160a7c296aed283da008704715c542e10164b461da5e0c6a930d131382046"
"sha256": "4d68606cc0933e1ea598e1b935ae02465814e98d344e52e94c02bc234219d7d0"
},
"pipfile-spec": 6,
"requires": {
@ -62,10 +62,12 @@
"sha256:7cfcfda59ef1f95b9f729c56fe8a4041899f96b72685d36ef16a3440a0f85da8",
"sha256:819f8d5197c2684524637f940445c06e003c4a541f9983fd30d6deaa2a5487d8",
"sha256:825ecffd9574557590e3225560a8a9d751f6ffe4a49e3c40918c9969b93395fa",
"sha256:8a2bcae2258d00fcfc96a9bde4a6177bc4274fe033f79311c5dd3d3148c26518",
"sha256:9009e917d8f5ef780c2626e29b6bc126f4cb2a4d43ca67aa2b40f2a5d6385e78",
"sha256:9c77564a51d4d914ed5af096cd9843d90c45b784b511723bd46a8a9d09cf16fc",
"sha256:a19089fa74ed19c4fe96502a291cfdb89223a9705b1d73b3005df4256976142e",
"sha256:a40ed527bffa2b7ebe07acc5a3f782da072e262ca994b4f2085100b5a444bbb2",
"sha256:b8f09f21544b9899defb09afbdaeb200e6a87a2b8e604892940044cf94444644",
"sha256:bb75ba21d5716abc41af16eac1145ab2e471deedde1f22c6f99bd9f995504df0",
"sha256:e22a00c0c81ffcecaf07c2bfb3672fa372c50e2bd1024ffee0da191c1b27fc71",
"sha256:e55b5a746fb77f10c83e8af081979351722f6ea48facea79d470b3731c7b2891",
@ -110,6 +112,14 @@
],
"version": "==2.8"
},
"motor": {
"hashes": [
"sha256:462fbb824f4289481c158227a2579d6adaf1ec7c70cf7ebe60ed6ceb321e5869",
"sha256:d035c09ab422bc50bf3efb134f7405694cae76268545bd21e14fb22e2638f84e"
],
"index": "pypi",
"version": "==2.0.0"
},
"passlib": {
"extras": [
"bcrypt"
@ -146,6 +156,39 @@
"index": "pypi",
"version": "==1.7.1"
},
"pymongo": {
"hashes": [
"sha256:09f8196e1cb081713aa3face08d1806dc0a5dd64cb9f67fefc568519253a7ff2",
"sha256:1be549c0ce2ba8242c149156ae2064b12a5d4704448d49f630b4910606efd474",
"sha256:1f9fe869e289210250cba4ea20fbd169905b1793e1cd2737f423e107061afa98",
"sha256:3653cea82d1e35edd0a2355150daf8a27ebf12cf55182d5ad1046bfa288f5140",
"sha256:4249c6ba45587b959292a727532826c5032d59171f923f7f823788f413c2a5a3",
"sha256:4ff8f5e7c0a78983c1ee07894fff1b21c0e0ad3a122d9786cc3745fd60e4a2ce",
"sha256:56b29c638ab924716b48a3e94e3d7ac00b04acec1daa8190c36d61fc714c3629",
"sha256:56ec9358bbfe5ae3b25e785f8a14619d6799c855a44734c9098bb457174019bf",
"sha256:5dca250cbf1183c3e7b7b18c882c2b2199bfb20c74c4c68dbf11596808a296da",
"sha256:61101d1cc92881fac1f9ac7e99b033062f4c210178dc33193c8f5567feecb069",
"sha256:86624c0205a403fb4fbfedef79c5b4ab27e21fd018fdb6a27cf03b3c32a9e2b9",
"sha256:88ac09e1b197c3b4531e43054d49c022a3ea1281431b2f4980abafa35d2a5ce2",
"sha256:8b0339809b12ea292d468524dd1777f1a9637d9bdc0353a9261b88f82537d606",
"sha256:93dbf7388f6bf9af48dbb32f265b75b3dbc743a7a2ce98e44c88c049c58d85d3",
"sha256:9b705daec636c560dd2d63935f428a6b3cddfe903fffc0f349e0e91007c893d6",
"sha256:a090a819fe6fefadc2901d3911c07c76c0935ec5c790a50e9f3c3c47bacd5978",
"sha256:a102b346f1921237eaa9a31ee89eda57ad3c3973d79be3a456d92524e7df8fec",
"sha256:a13363869f2f36291d6367069c65d51d7b8d1b2fb410266b0b6b1f3c90d6deb0",
"sha256:a409a43c76da50881b70cc9ee70a1744f882848e8e93a68fb434254379777fa3",
"sha256:a76475834a978058425b0163f1bad35a5f70e45929a543075633c3fc1df564c5",
"sha256:ad474e93525baa6c58d75d63a73143af24c9f93c8e26e8d382f32c4da637901a",
"sha256:b268c7fa03ac77a8662fab3b2ab0be4beecb82f60f4c24b584e69565691a107f",
"sha256:cca4e1ab5ba0cd7877d3938167ee8ae9c2986cc0e10d3dcc3243d664d3a83fec",
"sha256:cef61de3f0f4441ec40266ff2ab42e5c16eaba1dc1fc6e1036f274621c52adc1",
"sha256:e28153b5d5ca33d4ba0c3bbc0e1ff161b9016e5e5f3f8ca10d6fa49106eb9e04",
"sha256:f30d7b37804daf0bab1143abc71666c630d7e270f5c14c5a7c300a6699c21108",
"sha256:f70f0133301cccf9bfd68fd20f67184ef991be578b646e78441106f9e27cc44d",
"sha256:fa75c21c1d82f20cce62f6fc4a68c2b0f33572ab406df1b17cd77a947d0b2993"
],
"version": "==3.9.0"
},
"python-multipart": {
"hashes": [
"sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"

View File

@ -27,9 +27,10 @@ Add quickly a registration and authentication system to your [FastAPI](https://f
* [X] Extensible base user model
* [X] Ready-to-use register, login, forgot and reset password routes.
* [X] Dependency callables to inject current user in route.
* [X] Customizable database backend
* [X] SQLAlchemy backend included
* [ ] MongoDB backend included ([#4](https://github.com/frankie567/fastapi-users/issues/4))
* [X] SQLAlchemy async backend included thanks to [encode/databases](https://www.encode.io/databases/)
* [X] MongoDB async backend included thanks to [mongodb/motor](https://github.com/mongodb/motor)
* [X] Customizable authentication backend
* [X] JWT authentication backend included

View File

@ -1,6 +1,30 @@
# MongoDB
**Coming soon**. Track the progress of this feature in [ticket #4](https://github.com/frankie567/fastapi-users/issues/4).
**FastAPI Users** provides the necessary tools to work with MongoDB databases thanks to [mongodb/motor](https://github.com/mongodb/motor) package for full async support.
## Setup database connection and collection
Let's create a MongoDB connection and instantiate a collection.
```py hl_lines="6 7 8 9"
{!./src/db_mongodb.py!}
```
You can choose any name for the database and the collection.
## Create the database adapter
The database adapter of **FastAPI Users** makes the link between your database configuration and the users logic. Create it like this.
```py hl_lines="15"
{!./src/db_mongodb.py!}
```
!!! info
The database adapter will automatically create a [unique index](https://docs.mongodb.com/manual/core/index-unique/) on `id` and `email`.
!!! warning
**FastAPI Users** will use its defined [`id` UUID-string](../model.md) as unique identifier for the user, rather than the builtin MongoDB `_id`.
## Next steps

View File

@ -7,7 +7,7 @@ Here is a full working example with JWT authentication to help get you started.
```
```py tab="MongoDB"
# Coming soon
{!./src/full_mongodb.py!}
```
## What now?

View File

@ -2,14 +2,16 @@
You can add **FastAPI Users** to your FastAPI project in a few easy steps. First of all, install the dependency:
## With SQLAlchemy support
```sh
pip install fastapi-users
pip install fastapi-users[sqlalchemy]
```
...or if you're already in the future:
## With MongoDB support
```sh
pipenv install fastapi-users
pip install fastapi-users[mongodb]
```
---

14
docs/src/db_mongodb.py Normal file
View File

@ -0,0 +1,14 @@
import motor.motor_asyncio
from fastapi import FastAPI
from fastapi_users.db import MongoDBUserDatabase
DATABASE_URL = "mongodb://localhost:27017"
client = motor.motor_asyncio.AsyncIOMotorClient(DATABASE_URL)
db = client["database_name"]
collection = db["users"]
app = FastAPI()
user_db = MongoDBUserDatabase(collection)

37
docs/src/full_mongodb.py Normal file
View File

@ -0,0 +1,37 @@
import motor.motor_asyncio
from fastapi import FastAPI
from fastapi_users import BaseUser, FastAPIUsers
from fastapi_users.authentication import JWTAuthentication
from fastapi_users.db import MongoDBUserDatabase
DATABASE_URL = "mongodb://localhost:27017"
SECRET = "SECRET"
client = motor.motor_asyncio.AsyncIOMotorClient(DATABASE_URL)
db = client["database_name"]
collection = db["users"]
user_db = MongoDBUserDatabase(collection)
class User(BaseUser):
pass
auth = JWTAuthentication(secret=SECRET, lifetime_seconds=3600)
app = FastAPI()
fastapi_users = FastAPIUsers(user_db, auth, User, SECRET)
app.include_router(fastapi_users.router, prefix="/users", tags=["users"])
@fastapi_users.on_after_register()
def on_after_register(user: User):
print(f"User {user.id} has registered.")
@fastapi_users.on_after_forgot_password()
def on_after_forgot_password(user: User, token: str):
print(f"User {user.id} has forgot their password. Reset token: {token}")

View File

@ -1,4 +1,5 @@
from fastapi_users.db.base import BaseUserDatabase # noqa: F401
from fastapi_users.db.mongodb import MongoDBUserDatabase # noqa: F401
from fastapi_users.db.sqlalchemy import ( # noqa: F401
SQLAlchemyBaseUserTable,
SQLAlchemyUserDatabase,

View File

@ -0,0 +1,40 @@
from typing import List, Optional
from motor.motor_asyncio import AsyncIOMotorCollection
from fastapi_users.db.base import BaseUserDatabase
from fastapi_users.models import BaseUserDB
class MongoDBUserDatabase(BaseUserDatabase):
"""
Database adapter for MongoDB.
:param collection: Collection instance from `motor`.
"""
collection: AsyncIOMotorCollection
def __init__(self, collection: AsyncIOMotorCollection):
self.collection = collection
self.collection.create_index("id", unique=True)
self.collection.create_index("email", unique=True)
async def list(self) -> List[BaseUserDB]:
return [BaseUserDB(**user) async for user in self.collection.find()]
async def get(self, id: str) -> Optional[BaseUserDB]:
user = await self.collection.find_one({"id": id})
return BaseUserDB(**user) if user else None
async def get_by_email(self, email: str) -> Optional[BaseUserDB]:
user = await self.collection.find_one({"email": email})
return BaseUserDB(**user) if user else None
async def create(self, user: BaseUserDB) -> BaseUserDB:
await self.collection.insert_one(user.dict())
return user
async def update(self, user: BaseUserDB) -> BaseUserDB:
await self.collection.replace_one({"id": user.id}, user.dict())
return user

View File

@ -1,4 +1,4 @@
from typing import List, cast
from typing import List, Optional, cast
from databases import Database
from sqlalchemy import Boolean, Column, String, Table
@ -38,11 +38,11 @@ class SQLAlchemyUserDatabase(BaseUserDatabase):
query = self.users.select()
return cast(List[BaseUserDB], await self.database.fetch_all(query))
async def get(self, id: str) -> BaseUserDB:
async def get(self, id: str) -> Optional[BaseUserDB]:
query = self.users.select().where(self.users.c.id == id)
return cast(BaseUserDB, await self.database.fetch_one(query))
async def get_by_email(self, email: str) -> BaseUserDB:
async def get_by_email(self, email: str) -> Optional[BaseUserDB]:
query = self.users.select().where(self.users.c.email == email)
return cast(BaseUserDB, await self.database.fetch_one(query))

View File

@ -13,7 +13,6 @@ classifiers = [
"Development Status :: 3 - Alpha",
"Framework :: AsyncIO",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3 :: Only",
@ -25,11 +24,18 @@ requires = [
"fastapi ==0.42.0",
"passlib[bcrypt] ==1.7.1",
"email-validator ==1.0.5",
"sqlalchemy ==1.3.10",
"databases ==0.2.5",
"pyjwt ==1.7.1",
"python-multipart ==0.0.5",
]
[tool.flit.metadata.requires-extra]
sqlalchemy = [
"sqlalchemy ==1.3.10",
"databases ==0.2.5",
]
mongodb = [
"motor ==2.0.0",
]
[tool.flit.metadata.urls]
Documentation = "https://frankie567.github.io/fastapi-users/"

63
tests/test_db_mongodb.py Normal file
View File

@ -0,0 +1,63 @@
from typing import AsyncGenerator
import motor.motor_asyncio
import pytest
import pymongo.errors
from fastapi_users.db.mongodb import MongoDBUserDatabase
from fastapi_users.models import BaseUserDB
from fastapi_users.password import get_password_hash
@pytest.fixture
async def mongodb_user_db() -> AsyncGenerator[MongoDBUserDatabase, None]:
client = motor.motor_asyncio.AsyncIOMotorClient("mongodb://localhost:27017")
db = client["test_database"]
collection = db["users"]
yield MongoDBUserDatabase(collection)
await collection.drop()
@pytest.mark.asyncio
async def test_queries(mongodb_user_db):
user = BaseUserDB(
id="111",
email="lancelot@camelot.bt",
hashed_password=get_password_hash("guinevere"),
)
# Create
user_db = await mongodb_user_db.create(user)
assert user_db.id is not None
assert user_db.is_active is True
assert user_db.is_superuser is False
assert user_db.email == user.email
# Update
user_db.is_superuser = True
await mongodb_user_db.update(user_db)
# Get by id
id_user = await mongodb_user_db.get(user.id)
assert id_user.id == user_db.id
assert id_user.is_superuser is True
# Get by email
email_user = await mongodb_user_db.get_by_email(user.email)
assert email_user.id == user_db.id
# List
users = await mongodb_user_db.list()
assert len(users) == 1
first_user = users[0]
assert first_user.id == user_db.id
# Exception when inserting existing email
with pytest.raises(pymongo.errors.DuplicateKeyError):
await mongodb_user_db.create(user)
# Unknown user
unknown_user = await mongodb_user_db.get_by_email("galahad@camelot.bt")
assert unknown_user is None