From ab0b187f20133753797ed55cf4b9b11b65c9ec8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Sun, 27 Oct 2019 16:34:30 +0100 Subject: [PATCH] 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 --- .github/workflows/build.yml | 6 +++ Makefile | 3 ++ Pipfile | 1 + Pipfile.lock | 45 +++++++++++++++++- README.md | 5 +- docs/configuration/databases/mongodb.md | 26 +++++++++- docs/configuration/full_example.md | 2 +- docs/installation.md | 8 ++-- docs/src/db_mongodb.py | 14 ++++++ docs/src/full_mongodb.py | 37 +++++++++++++++ fastapi_users/db/__init__.py | 1 + fastapi_users/db/mongodb.py | 40 ++++++++++++++++ fastapi_users/db/sqlalchemy.py | 6 +-- pyproject.toml | 12 +++-- tests/test_db_mongodb.py | 63 +++++++++++++++++++++++++ 15 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 docs/src/db_mongodb.py create mode 100644 docs/src/full_mongodb.py create mode 100644 fastapi_users/db/mongodb.py create mode 100644 tests/test_db_mongodb.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d51a3380..8e451324 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/Makefile b/Makefile index 784de431..7573e4b6 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/Pipfile b/Pipfile index 391d0651..5117795f 100644 --- a/Pipfile +++ b/Pipfile @@ -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" diff --git a/Pipfile.lock b/Pipfile.lock index 92050d1d..bcaf7a0c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -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" diff --git a/README.md b/README.md index 28f5fd9b..607a4173 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/configuration/databases/mongodb.md b/docs/configuration/databases/mongodb.md index ca07ef21..a57b5787 100644 --- a/docs/configuration/databases/mongodb.md +++ b/docs/configuration/databases/mongodb.md @@ -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 diff --git a/docs/configuration/full_example.md b/docs/configuration/full_example.md index f39074f7..a17c97c3 100644 --- a/docs/configuration/full_example.md +++ b/docs/configuration/full_example.md @@ -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? diff --git a/docs/installation.md b/docs/installation.md index f4bbdb6c..5bb20147 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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] ``` --- diff --git a/docs/src/db_mongodb.py b/docs/src/db_mongodb.py new file mode 100644 index 00000000..6ee9eb90 --- /dev/null +++ b/docs/src/db_mongodb.py @@ -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) diff --git a/docs/src/full_mongodb.py b/docs/src/full_mongodb.py new file mode 100644 index 00000000..ca46e4bf --- /dev/null +++ b/docs/src/full_mongodb.py @@ -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}") diff --git a/fastapi_users/db/__init__.py b/fastapi_users/db/__init__.py index af515040..d226c238 100644 --- a/fastapi_users/db/__init__.py +++ b/fastapi_users/db/__init__.py @@ -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, diff --git a/fastapi_users/db/mongodb.py b/fastapi_users/db/mongodb.py new file mode 100644 index 00000000..44646033 --- /dev/null +++ b/fastapi_users/db/mongodb.py @@ -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 diff --git a/fastapi_users/db/sqlalchemy.py b/fastapi_users/db/sqlalchemy.py index c8eabda0..6bfabbf9 100644 --- a/fastapi_users/db/sqlalchemy.py +++ b/fastapi_users/db/sqlalchemy.py @@ -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)) diff --git a/pyproject.toml b/pyproject.toml index 1adb4108..f3f5738e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/" diff --git a/tests/test_db_mongodb.py b/tests/test_db_mongodb.py new file mode 100644 index 00000000..ac59b332 --- /dev/null +++ b/tests/test_db_mongodb.py @@ -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