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: matrix:
python_version: [3.7] python_version: [3.7]
services:
mongo:
image: mvertes/alpine-mongo
ports:
- 27017:27017
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- name: Set up Python 3.7 - name: Set up Python 3.7

View File

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

View File

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

45
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "91f160a7c296aed283da008704715c542e10164b461da5e0c6a930d131382046" "sha256": "4d68606cc0933e1ea598e1b935ae02465814e98d344e52e94c02bc234219d7d0"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -62,10 +62,12 @@
"sha256:7cfcfda59ef1f95b9f729c56fe8a4041899f96b72685d36ef16a3440a0f85da8", "sha256:7cfcfda59ef1f95b9f729c56fe8a4041899f96b72685d36ef16a3440a0f85da8",
"sha256:819f8d5197c2684524637f940445c06e003c4a541f9983fd30d6deaa2a5487d8", "sha256:819f8d5197c2684524637f940445c06e003c4a541f9983fd30d6deaa2a5487d8",
"sha256:825ecffd9574557590e3225560a8a9d751f6ffe4a49e3c40918c9969b93395fa", "sha256:825ecffd9574557590e3225560a8a9d751f6ffe4a49e3c40918c9969b93395fa",
"sha256:8a2bcae2258d00fcfc96a9bde4a6177bc4274fe033f79311c5dd3d3148c26518",
"sha256:9009e917d8f5ef780c2626e29b6bc126f4cb2a4d43ca67aa2b40f2a5d6385e78", "sha256:9009e917d8f5ef780c2626e29b6bc126f4cb2a4d43ca67aa2b40f2a5d6385e78",
"sha256:9c77564a51d4d914ed5af096cd9843d90c45b784b511723bd46a8a9d09cf16fc", "sha256:9c77564a51d4d914ed5af096cd9843d90c45b784b511723bd46a8a9d09cf16fc",
"sha256:a19089fa74ed19c4fe96502a291cfdb89223a9705b1d73b3005df4256976142e", "sha256:a19089fa74ed19c4fe96502a291cfdb89223a9705b1d73b3005df4256976142e",
"sha256:a40ed527bffa2b7ebe07acc5a3f782da072e262ca994b4f2085100b5a444bbb2", "sha256:a40ed527bffa2b7ebe07acc5a3f782da072e262ca994b4f2085100b5a444bbb2",
"sha256:b8f09f21544b9899defb09afbdaeb200e6a87a2b8e604892940044cf94444644",
"sha256:bb75ba21d5716abc41af16eac1145ab2e471deedde1f22c6f99bd9f995504df0", "sha256:bb75ba21d5716abc41af16eac1145ab2e471deedde1f22c6f99bd9f995504df0",
"sha256:e22a00c0c81ffcecaf07c2bfb3672fa372c50e2bd1024ffee0da191c1b27fc71", "sha256:e22a00c0c81ffcecaf07c2bfb3672fa372c50e2bd1024ffee0da191c1b27fc71",
"sha256:e55b5a746fb77f10c83e8af081979351722f6ea48facea79d470b3731c7b2891", "sha256:e55b5a746fb77f10c83e8af081979351722f6ea48facea79d470b3731c7b2891",
@ -110,6 +112,14 @@
], ],
"version": "==2.8" "version": "==2.8"
}, },
"motor": {
"hashes": [
"sha256:462fbb824f4289481c158227a2579d6adaf1ec7c70cf7ebe60ed6ceb321e5869",
"sha256:d035c09ab422bc50bf3efb134f7405694cae76268545bd21e14fb22e2638f84e"
],
"index": "pypi",
"version": "==2.0.0"
},
"passlib": { "passlib": {
"extras": [ "extras": [
"bcrypt" "bcrypt"
@ -146,6 +156,39 @@
"index": "pypi", "index": "pypi",
"version": "==1.7.1" "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": { "python-multipart": {
"hashes": [ "hashes": [
"sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43" "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] Extensible base user model
* [X] Ready-to-use register, login, forgot and reset password routes. * [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] Customizable database backend
* [X] SQLAlchemy backend included * [X] SQLAlchemy async backend included thanks to [encode/databases](https://www.encode.io/databases/)
* [ ] MongoDB backend included ([#4](https://github.com/frankie567/fastapi-users/issues/4)) * [X] MongoDB async backend included thanks to [mongodb/motor](https://github.com/mongodb/motor)
* [X] Customizable authentication backend * [X] Customizable authentication backend
* [X] JWT authentication backend included * [X] JWT authentication backend included

View File

@ -1,6 +1,30 @@
# MongoDB # 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 ## 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" ```py tab="MongoDB"
# Coming soon {!./src/full_mongodb.py!}
``` ```
## What now? ## 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: You can add **FastAPI Users** to your FastAPI project in a few easy steps. First of all, install the dependency:
## With SQLAlchemy support
```sh ```sh
pip install fastapi-users pip install fastapi-users[sqlalchemy]
``` ```
...or if you're already in the future: ## With MongoDB support
```sh ```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.base import BaseUserDatabase # noqa: F401
from fastapi_users.db.mongodb import MongoDBUserDatabase # noqa: F401
from fastapi_users.db.sqlalchemy import ( # noqa: F401 from fastapi_users.db.sqlalchemy import ( # noqa: F401
SQLAlchemyBaseUserTable, SQLAlchemyBaseUserTable,
SQLAlchemyUserDatabase, 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 databases import Database
from sqlalchemy import Boolean, Column, String, Table from sqlalchemy import Boolean, Column, String, Table
@ -38,11 +38,11 @@ class SQLAlchemyUserDatabase(BaseUserDatabase):
query = self.users.select() query = self.users.select()
return cast(List[BaseUserDB], await self.database.fetch_all(query)) 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) query = self.users.select().where(self.users.c.id == id)
return cast(BaseUserDB, await self.database.fetch_one(query)) 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) query = self.users.select().where(self.users.c.email == email)
return cast(BaseUserDB, await self.database.fetch_one(query)) return cast(BaseUserDB, await self.database.fetch_one(query))

View File

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