From 579313f88737216c245c6ad5e0bf08e9708b99bd Mon Sep 17 00:00:00 2001 From: Paolo Dina Date: Sun, 7 Feb 2021 09:34:40 +0100 Subject: [PATCH] Ormar backend support (#470) * Add db adapter for ormar (wip) * finish ormar support enough to pass tests * remove idea folder * update ormar version in tool.flit.metadata.requires-extra * Add documentation about ormar * Apply isort and black formatting * Restore python 3.7 in Pipfile * Update build.yml * Add missing test for ormar update * changes after review Co-authored-by: Paolo Dina Co-authored-by: collerek --- .github/workflows/build.yml | 1 - .gitignore | 3 + Makefile | 2 +- Pipfile | 1 + Pipfile.lock | 72 +++++---- README.md | 2 + docs/configuration/databases/ormar.md | 53 +++++++ docs/configuration/full_example.md | 6 + docs/configuration/model.md | 2 + docs/installation.md | 6 + docs/src/db_ormar.py | 56 +++++++ docs/src/full_ormar.py | 105 +++++++++++++ fastapi_users/db/__init__.py | 9 ++ fastapi_users/db/ormar.py | 114 ++++++++++++++ fastapi_users/user.py | 2 +- mkdocs.yml | 1 + pyproject.toml | 3 + tests/test_db_ormar.py | 214 ++++++++++++++++++++++++++ 18 files changed, 617 insertions(+), 35 deletions(-) create mode 100644 docs/configuration/databases/ormar.md create mode 100644 docs/src/db_ormar.py create mode 100644 docs/src/full_ormar.py create mode 100644 fastapi_users/db/ormar.py create mode 100644 tests/test_db_ormar.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dfba48a1..b0002fd8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,6 @@ jobs: pipenv run flit build pipenv run flit install --python $(which python) python test_build.py - release: runs-on: ubuntu-latest needs: test diff --git a/.gitignore b/.gitignore index 0f749416..b949f48f 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,6 @@ ENV/ # OS files .DS_Store + +# .idea +.idea/ diff --git a/Makefile b/Makefile index 80eb46cc..c6155c9c 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ format: isort-src isort-docs test: docker stop $(MONGODB_CONTAINER_NAME) || true docker run -d --rm --name $(MONGODB_CONTAINER_NAME) -p 27017:27017 mongo:4.2 - $(PIPENV_RUN) pytest --cov=fastapi_users/ --cov-report=term-missing + $(PIPENV_RUN) pytest --cov=fastapi_users/ --cov-report=term-missing --cov-fail-under=100 docker stop $(MONGODB_CONTAINER_NAME) docs-serve: diff --git a/Pipfile b/Pipfile index f7797fd9..e5e4690d 100644 --- a/Pipfile +++ b/Pipfile @@ -39,6 +39,7 @@ pyjwt = "==2.0.1" python-multipart = "==0.0.5" motor = ">=2.2.0,<3.0.0" tortoise-orm = ">=0.15.18,<0.17.0" +ormar = ">=0.9.0,<0.10.0" makefun = ">=1.9.2,<1.10" typing-extensions = ">=3.7.4.3" Deprecated=">=1.2.10,<2.0.0" diff --git a/Pipfile.lock b/Pipfile.lock index 8be17776..a4034ed5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "cbdc1773e28ea28606179bf6f7a52a708473a35bbe9422b3b8f9518b6b442d39" + "sha256": "223d6925c8b7a859f11904334aa32bce15fca57090a2ae065283da2cf362a719" }, "pipfile-spec": 6, "requires": { @@ -68,43 +68,43 @@ }, "cffi": { "hashes": [ - "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b", - "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb", - "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a", - "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e", - "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d", - "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca", - "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293", - "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668", - "sha256:7ef7d4ced6b325e92eb4d3502946c78c5367bc416398d387b39591532536734e", - "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b", - "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d", - "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f", - "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b", - "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362", - "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775", - "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d", - "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01", - "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06", - "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b", - "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3", - "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c", - "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c", - "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26", - "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375", - "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909", "sha256:00a1ba5e2e95684448de9b89888ccd02c98d512064b4cb987d48f4b40aa0421e", + "sha256:00e28066507bfc3fe865a31f325c8391a1ac2916219340f87dfad602c3e48e5d", + "sha256:045d792900a75e8b1e1b0ab6787dd733a8190ffcf80e8c8ceb2fb10a29ff238a", + "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec", + "sha256:105abaf8a6075dc96c1fe5ae7aae073f4696f2905fde6aeada4c9d2926752362", + "sha256:155136b51fd733fa94e1c2ea5211dcd4c8879869008fc811648f16541bf99668", + "sha256:1a465cbe98a7fd391d47dce4b8f7e5b921e6cd805ef421d04f5f66ba8f06086c", + "sha256:1d2c4994f515e5b485fd6d3a73d05526aa0fcf248eb135996b088d25dfa1865b", + "sha256:2c24d61263f511551f740d1a065eb0212db1dbbbbd241db758f5244281590c06", + "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698", "sha256:594234691ac0e9b770aee9fcdb8fa02c22e43e5c619456efd0d6c2bf276f3eb2", - "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b", + "sha256:5cf4be6c304ad0b6602f5c4e90e2f59b47653ac1ed9c662ed379fe48a8f26b0c", + "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7", "sha256:6bc25fc545a6b3d57b5f8618e59fc13d3a3a68431e8ca5fd4c13241cd70d0009", "sha256:798caa2a2384b1cbe8a2a139d80734c9db54f9cc155c99d7cc92441a23871c03", - "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d", + "sha256:7c6b1dece89874d9541fc974917b631406233ea0440d0bdfbb8e03bf39a49b3b", + "sha256:7ef7d4ced6b325e92eb4d3502946c78c5367bc416398d387b39591532536734e", + "sha256:840793c68105fe031f34d6a086eaea153a0cd5c491cde82a74b420edd0a2b909", "sha256:8d6603078baf4e11edc4168a514c5ce5b3ba6e3e9c374298cb88437957960a53", - "sha256:0638c3ae1a0edfb77c6765d487fee624d2b1ee1bdfeffc1f0b58c64d149e7eec", - "sha256:64081b3f8f6f3c3de6191ec89d7dc6c86a8a43911f7ecb422c60e90c70be41c7", - "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd", "sha256:9cc46bc107224ff5b6d04369e7c595acb700c3613ad7bcf2e2012f62ece80c35", - "sha256:51a8b381b16ddd370178a65360ebe15fbc1c71cf6f584613a7ea08bfad946698" + "sha256:9f7a31251289b2ab6d4012f6e83e58bc3b96bd151f5b5262467f4bb6b34a7c26", + "sha256:9ffb888f19d54a4d4dfd4b3f29bc2c16aa4972f1c2ab9c4ab09b8ab8685b9c2b", + "sha256:a5ed8c05548b54b998b9498753fb9cadbfd92ee88e884641377d8a8b291bcc01", + "sha256:a7711edca4dcef1a75257b50a2fbfe92a65187c47dab5a0f1b9b332c5919a3fb", + "sha256:af5c59122a011049aad5dd87424b8e65a80e4a6477419c0c1015f73fb5ea0293", + "sha256:b18e0a9ef57d2b41f5c68beefa32317d286c3d6ac0484efd10d6e07491bb95dd", + "sha256:b4e248d1087abf9f4c10f3c398896c87ce82a9856494a7155823eb45a892395d", + "sha256:ba4e9e0ae13fc41c6b23299545e5ef73055213e466bd107953e4a013a5ddd7e3", + "sha256:c6332685306b6417a91b1ff9fae889b3ba65c2292d64bd9245c093b1b284809d", + "sha256:d5ff0621c88ce83a28a10d2ce719b2ee85635e85c515f12bac99a95306da4b2e", + "sha256:d9efd8b7a3ef378dd61a1e77367f1924375befc2eba06168b6ebfa903a5e59ca", + "sha256:df5169c4396adc04f9b0a05f13c074df878b6052430e03f50e68adf3a57aa28d", + "sha256:ebb253464a5d0482b191274f1c8bf00e33f7e0b9c66405fbffc61ed2c839c775", + "sha256:ec80dc47f54e6e9a78181ce05feb71a0353854cc26999db963695f950b5fb375", + "sha256:f032b34669220030f905152045dfa27741ce1a6db3324a5bc0b96b6c7420c87b", + "sha256:f60567825f791c6f8a592f3c6e3bd93dd2934e3f9dac189308426bd76b00ef3b", + "sha256:f803eaa94c2fcda012c047e62bc7a51b0bdabda1cad7a92a522694ea2d76e49f" ], "version": "==1.14.4" }, @@ -183,6 +183,14 @@ "index": "pypi", "version": "==2.3.1" }, + "ormar": { + "hashes": [ + "sha256:4ce6958cbbb536e2bbbd6adf43da52d6a8730d80f4cc9f5a63f8739c140f8d1c", + "sha256:9ff8d4f5d967f23b8dd4195f308afab3bb59910d8e2118f5c78ca9a4e8545839" + ], + "index": "pypi", + "version": "==0.9.2" + }, "passlib": { "extras": [ "bcrypt" diff --git a/README.md b/README.md index 20cd6c0b..caf4e2d5 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ Add quickly a registration and authentication system to your [FastAPI](https://f * [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] [Tortoise ORM](https://tortoise-orm.readthedocs.io/en/latest/) backend included + * [X] [ormar](https://collerek.github.io/ormar/) backend included + * [X] Multiple customizable authentication backends * [X] JWT authentication backend included * [X] Cookie authentication backend included diff --git a/docs/configuration/databases/ormar.md b/docs/configuration/databases/ormar.md new file mode 100644 index 00000000..a9d9fc48 --- /dev/null +++ b/docs/configuration/databases/ormar.md @@ -0,0 +1,53 @@ +# Ormar + +**FastAPI Users** provides the necessary tools to work with ormar. + +## Installation + +Install the database driver that corresponds to your DBMS: + +```sh +pip install asyncpg psycopg2 +``` + +```sh +pip install aiomysql pymysql +``` + +```sh +pip install aiosqlite +``` + +For the sake of this tutorial from now on, we'll use a simple SQLite databse. + +## Setup User table + +Let's declare our User ORM model. + +```py hl_lines="29-33" +{!./src/db_ormar.py!} +``` + +As you can see, **FastAPI Users** provides an abstract model that will +include base fields for our User table. You can of course add you own fields +there to fit to your needs! + +## 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="40" +{!./src/db_ormar.py!} +``` + +Notice that we pass a reference to your [`UserDB` model](../model.md). + +!!! warning + In production, it's strongly recommended to setup a migration system to + update your SQL schemas. See + [Alembic](https://alembic.sqlalchemy.org/en/latest/). + +## Next steps + +We will now configure an [authentication method](../authentication/index.md). diff --git a/docs/configuration/full_example.md b/docs/configuration/full_example.md index 04f3bb66..c597ccad 100644 --- a/docs/configuration/full_example.md +++ b/docs/configuration/full_example.md @@ -24,6 +24,12 @@ Here is a full working example with JWT authentication to help get you started. {!./src/full_tortoise.py!} ``` +## Ormar + +```py +{!./src/full_ormar.py!} +``` + ## What now? You're ready to go! Be sure to check the [Usage](../usage/routes.md) section to understand how yo work with **FastAPI Users**. diff --git a/docs/configuration/model.md b/docs/configuration/model.md index 8aca184e..3db92db2 100644 --- a/docs/configuration/model.md +++ b/docs/configuration/model.md @@ -67,3 +67,5 @@ Depending on your database backend, the database configuration will differ a bit [I'm using MongoDB](databases/mongodb.md) [I'm using Tortoise ORM](databases/tortoise.md) + +[I'm using ormar](databases/ormar.md) diff --git a/docs/installation.md b/docs/installation.md index c1a8f3bf..e28c7293 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -20,6 +20,12 @@ pip install fastapi-users[mongodb] pip install fastapi-users[tortoise-orm] ``` +## With ormar support + +```sh +pip install fastapi-users[ormar] +``` + --- That's it! Now, let's have a look at our [User model](./configuration/model.md). diff --git a/docs/src/db_ormar.py b/docs/src/db_ormar.py new file mode 100644 index 00000000..c13b6dc1 --- /dev/null +++ b/docs/src/db_ormar.py @@ -0,0 +1,56 @@ +import databases +import sqlalchemy +from fastapi import FastAPI +from fastapi_users import models +from fastapi_users.db import OrmarBaseUserModel, OrmarUserDatabase + + +class User(models.BaseUser): + pass + + +class UserCreate(models.BaseUserCreate): + pass + + +class UserUpdate(User, models.BaseUserUpdate): + pass + + +class UserDB(User, models.BaseUserDB): + pass + + +DATABASE_URL = "sqlite:///test.db" +metadata = sqlalchemy.MetaData() +database = databases.Database(DATABASE_URL) + + +class UserModel(OrmarBaseUserModel): + class Meta: + tablename = "users" + metadata = metadata + database = database + + +engine = sqlalchemy.create_engine(DATABASE_URL) +metadata.create_all(engine) + + +user_db = OrmarUserDatabase(UserDB, UserModel) +app = FastAPI() +app.state.database = database + + +@app.on_event("startup") +async def startup() -> None: + database_ = app.state.database + if not database_.is_connected: + await database_.connect() + + +@app.on_event("shutdown") +async def shutdown() -> None: + database_ = app.state.database + if database_.is_connected: + await database_.disconnect() diff --git a/docs/src/full_ormar.py b/docs/src/full_ormar.py new file mode 100644 index 00000000..5b1a2555 --- /dev/null +++ b/docs/src/full_ormar.py @@ -0,0 +1,105 @@ +import databases +import sqlalchemy +from fastapi import FastAPI, Request +from fastapi_users import FastAPIUsers, models +from fastapi_users.authentication import JWTAuthentication +from fastapi_users.db import OrmarBaseUserModel, OrmarUserDatabase + +DATABASE_URL = "sqlite:///test.db" +SECRET = "SECRET" +metadata = sqlalchemy.MetaData() +database = databases.Database(DATABASE_URL) + + +class User(models.BaseUser): + pass + + +class UserCreate(models.BaseUserCreate): + pass + + +class UserUpdate(User, models.BaseUserUpdate): + pass + + +class UserDB(User, models.BaseUserDB): + pass + + +class UserModel(OrmarBaseUserModel): + class Meta: + tablename = "users" + metadata = metadata + database = database + + +engine = sqlalchemy.create_engine(DATABASE_URL) +metadata.create_all(engine) + + +user_db = OrmarUserDatabase(UserDB, UserModel) + + +def on_after_register(user: UserDB, request: Request): + print(f"User {user.id} has registered.") + + +def on_after_forgot_password(user: UserDB, token: str, request: Request): + print(f"User {user.id} has forgot their password. Reset token: {token}") + + +def after_verification_request(user: UserDB, token: str, request: Request): + print(f"Verification requested for user {user.id}. Verification token: {token}") + + +jwt_authentication = JWTAuthentication( + secret=SECRET, lifetime_seconds=3600, tokenUrl="/auth/jwt/login" +) + +app = FastAPI() +fastapi_users = FastAPIUsers( + user_db, + [jwt_authentication], + User, + UserCreate, + UserUpdate, + UserDB, +) +app.include_router( + fastapi_users.get_auth_router(jwt_authentication), prefix="/auth/jwt", tags=["auth"] +) +app.include_router( + fastapi_users.get_register_router(on_after_register), prefix="/auth", tags=["auth"] +) +app.include_router( + fastapi_users.get_reset_password_router( + SECRET, after_forgot_password=on_after_forgot_password + ), + prefix="/auth", + tags=["auth"], +) +app.include_router( + fastapi_users.get_verify_router( + SECRET, after_verification_request=after_verification_request + ), + prefix="/auth", + tags=["auth"], +) +app.include_router(fastapi_users.get_users_router(), prefix="/users", tags=["users"]) + +app.state.database = database + + +@app.on_event("startup") +async def startup() -> None: + database_ = app.state.database + if not database_.is_connected: + await database_.connect() + + +@app.on_event("shutdown") +async def shutdown() -> None: + database_ = app.state.database + if database_.is_connected: + await database_.disconnect() diff --git a/fastapi_users/db/__init__.py b/fastapi_users/db/__init__.py index 092b2a0b..5988e77e 100644 --- a/fastapi_users/db/__init__.py +++ b/fastapi_users/db/__init__.py @@ -22,3 +22,12 @@ try: ) except ImportError: # pragma: no cover pass + +try: + from fastapi_users.db.ormar import ( # noqa: F401 + OrmarBaseOAuthAccountModel, + OrmarBaseUserModel, + OrmarUserDatabase, + ) +except ImportError: # pragma: no cover + pass diff --git a/fastapi_users/db/ormar.py b/fastapi_users/db/ormar.py new file mode 100644 index 00000000..6b675c4c --- /dev/null +++ b/fastapi_users/db/ormar.py @@ -0,0 +1,114 @@ +import datetime +from typing import Any, List, Optional, Type + +import ormar +from ormar.exceptions import NoMatch +from pydantic import UUID4 + +from fastapi_users.db.base import BaseUserDatabase +from fastapi_users.models import UD, BaseOAuthAccount + + +class OrmarBaseUserModel(ormar.Model): + class Meta: + tablename = "users" + abstract = True + + id = ormar.UUID(primary_key=True, uuid_format="string") + email = ormar.String(index=True, unique=True, nullable=False, max_length=255) + hashed_password = ormar.String(nullable=False, max_length=255) + is_active = ormar.Boolean(default=True, nullable=False) + is_superuser = ormar.Boolean(default=False, nullable=False) + is_verified = ormar.Boolean(default=False, nullable=False) + + +class OrmarBaseOAuthAccountModel(ormar.Model): + class Meta: + tablename = "oauth_accounts" + abstract = True + + id = ormar.UUID(primary_key=True, uuid_format="string") + oauth_name = ormar.String(nullable=False, max_length=255) + access_token = ormar.String(nullable=False, max_length=255) + expires_at = ormar.Integer(nullable=True) + refresh_token = ormar.String(nullable=True, max_length=255) + account_id = ormar.String(index=True, nullable=False, max_length=255) + account_email = ormar.String(nullable=False, max_length=255) + + +class OrmarUserDatabase(BaseUserDatabase[UD]): + """ + Database adapter for ormar. + + :param user_db_model: Pydantic model of a DB representation of a user. + :param model: ormar ORM model. + :param oauth_account_model: Optional ormar ORM model of a OAuth account. + """ + + model: Type[OrmarBaseUserModel] + oauth_account_model: Optional[Type[OrmarBaseOAuthAccountModel]] + + def __init__( + self, + user_db_model: Type[UD], + model: Type[OrmarBaseUserModel], + oauth_account_model: Optional[Type[OrmarBaseOAuthAccountModel]] = None, + ): + super().__init__(user_db_model) + self.model = model + self.oauth_account_model = oauth_account_model + + async def get(self, id: UUID4) -> Optional[UD]: + return await self._get_user(id=id) + + async def get_by_email(self, email: str) -> Optional[UD]: + return await self._get_user(email__iexact=email) + + async def get_by_oauth_account(self, oauth: str, account_id: str) -> Optional[UD]: + return await self._get_user( + oauth_accounts__oauth_name=oauth, oauth_accounts__account_id=account_id + ) + + async def create(self, user: UD) -> UD: + oauth_accounts = getattr(user, "oauth_accounts", []) + model = await self.model(**user.dict(exclude={"oauth_accounts"})).save() + if oauth_accounts and self.oauth_account_model: + await self._create_oauth_models(model=model, oauth_accounts=oauth_accounts) + user = await self._get_user(id=user.id) + return user + + async def update(self, user: UD) -> UD: + oauth_accounts = getattr(user, "oauth_accounts", []) + model = await self._get_db_user(id=user.id) + await model.update(**user.dict(exclude={"oauth_accounts"})) + if oauth_accounts and self.oauth_account_model: + await model.oauth_accounts.clear(keep_reversed=False) + await self._create_oauth_models(model=model, oauth_accounts=oauth_accounts) + user = await self._get_user(id=user.id) + return user + + async def delete(self, user: UD) -> None: + await self.model.objects.delete(id=user.id) + + async def _create_oauth_models( + self, model: ormar.Model, oauth_accounts: List[BaseOAuthAccount] + ): + await self.oauth_account_model.objects.bulk_create( + [ + self.oauth_account_model(user=model, **oacc.dict()) + for oacc in oauth_accounts + ] + ) + + async def _get_db_user(self, **kwargs: Any) -> Optional[ormar.Model]: + query = self.model.objects.filter(**kwargs) + if self.oauth_account_model is not None: + query = query.select_related("oauth_accounts") + return await query.get() + + async def _get_user(self, **kwargs: Any) -> Optional[UD]: + try: + user = await self._get_db_user(**kwargs) + except NoMatch: + return None + return self.user_db_model(**user.dict()) diff --git a/fastapi_users/user.py b/fastapi_users/user.py index 161dadb8..615c62d6 100644 --- a/fastapi_users/user.py +++ b/fastapi_users/user.py @@ -2,7 +2,7 @@ from typing import Awaitable, Type try: from typing import Protocol -except ImportError: +except ImportError: # pragma: no cover from typing_extensions import Protocol # type: ignore from pydantic import EmailStr diff --git a/mkdocs.yml b/mkdocs.yml index a7f7b8be..bdcfbb73 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,6 +34,7 @@ nav: - configuration/databases/sqlalchemy.md - configuration/databases/mongodb.md - configuration/databases/tortoise.md + - configuration/databases/ormar.md - Authentication: - Introduction: configuration/authentication/index.md - configuration/authentication/jwt.md diff --git a/pyproject.toml b/pyproject.toml index 1c04e303..795db5e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ mongodb = [ tortoise-orm = [ "tortoise-orm >=0.16.0,<0.17.0" ] +ormar = [ + "ormar >=0.9.0,<0.10.0" +] oauth = [ "httpx-oauth >=0.3,<0.4" ] diff --git a/tests/test_db_ormar.py b/tests/test_db_ormar.py new file mode 100644 index 00000000..57a59f90 --- /dev/null +++ b/tests/test_db_ormar.py @@ -0,0 +1,214 @@ +import uuid +from sqlite3 import IntegrityError +from typing import AsyncGenerator + +import databases +import ormar +import pytest +import sqlalchemy +from ormar.exceptions import NoMatch + +from fastapi_users.db.ormar import ( + OrmarBaseOAuthAccountModel, + OrmarBaseUserModel, + OrmarUserDatabase, +) +from fastapi_users.password import get_password_hash +from tests.conftest import UserDB, UserDBOAuth + +DATABASE_URL = "sqlite:///./test-ormar-user.db" +metadata = sqlalchemy.MetaData() +database = databases.Database(DATABASE_URL) + + +class User(OrmarBaseUserModel): + class Meta: + metadata = metadata + database = database + + first_name = ormar.String(nullable=True, max_length=255) + + +class OAuthAccount(OrmarBaseOAuthAccountModel): + class Meta: + metadata = metadata + database = database + + user = ormar.ForeignKey(User, related_name="oauth_accounts") + + +@pytest.fixture +async def ormar_user_db() -> AsyncGenerator[OrmarUserDatabase, None]: + engine = sqlalchemy.create_engine( + DATABASE_URL, connect_args={"check_same_thread": False} + ) + metadata.create_all(engine) + + await database.connect() + + yield OrmarUserDatabase(user_db_model=UserDB, model=User) + + metadata.drop_all(engine) + await database.disconnect() + + +@pytest.fixture +async def ormar_user_db_oauth() -> AsyncGenerator[OrmarUserDatabase, None]: + engine = sqlalchemy.create_engine( + DATABASE_URL, connect_args={"check_same_thread": False} + ) + metadata.create_all(engine) + + await database.connect() + + yield OrmarUserDatabase( + user_db_model=UserDBOAuth, model=User, oauth_account_model=OAuthAccount + ) + + metadata.drop_all(engine) + await database.disconnect() + + +@pytest.mark.asyncio +@pytest.mark.db +async def test_queries(ormar_user_db: OrmarUserDatabase[UserDB]): + user = UserDB( + email="lancelot@camelot.bt", + hashed_password=get_password_hash("guinevere"), + ) + + # Create + user_db = await ormar_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 ormar_user_db.update(user_db) + + # Exception when updating a user with a not existing id + id_backup = user_db.id + user_db.id = uuid.uuid4() + with pytest.raises(NoMatch): + await ormar_user_db.update(user_db) + user_db.id = id_backup + + # Get by id + id_user = await ormar_user_db.get(user.id) + assert id_user is not None + assert id_user.id == user_db.id + assert id_user.is_superuser is True + + # Get by email + email_user = await ormar_user_db.get_by_email(str(user.email)) + assert email_user is not None + assert email_user.id == user_db.id + + # Get by uppercased email + email_user = await ormar_user_db.get_by_email("Lancelot@camelot.bt") + assert email_user is not None + assert email_user.id == user_db.id + + # Exception when inserting existing email + with pytest.raises(IntegrityError): + await ormar_user_db.create(user) + + # Exception when inserting non-nullable fields + with pytest.raises(ValueError): + wrong_user = UserDB(hashed_password="aaa") + await ormar_user_db.create(wrong_user) + + # Unknown user + unknown_user = await ormar_user_db.get_by_email("galahad@camelot.bt") + assert unknown_user is None + + # Delete user + await ormar_user_db.delete(user) + deleted_user = await ormar_user_db.get(user.id) + assert deleted_user is None + + +@pytest.mark.asyncio +@pytest.mark.db +async def test_queries_custom_fields(ormar_user_db: OrmarUserDatabase[UserDB]): + """It should output custom fields in query result.""" + user = UserDB( + email="lancelot@camelot.bt", + hashed_password=get_password_hash("guinevere"), + first_name="Lancelot", + ) + await ormar_user_db.create(user) + + id_user = await ormar_user_db.get(user.id) + assert id_user is not None + assert id_user.id == user.id + assert id_user.first_name == user.first_name + + +@pytest.mark.asyncio +@pytest.mark.db +async def test_queries_oauth( + ormar_user_db_oauth: OrmarUserDatabase[UserDBOAuth], + oauth_account1, + oauth_account2, + oauth_account3, +): + user = UserDBOAuth( + email="lancelot@camelot.bt", + hashed_password=get_password_hash("guinevere"), + oauth_accounts=[oauth_account1, oauth_account2], + ) + + # Create + user_db = await ormar_user_db_oauth.create(user) + assert user_db.id is not None + assert hasattr(user_db, "oauth_accounts") + assert len(user_db.oauth_accounts) == 2 + + # Update + oauth_to_check_id = user_db.oauth_accounts[0].id + user_db.oauth_accounts[0].access_token = "NEW_TOKEN" + await ormar_user_db_oauth.update(user_db) + + # Get by id + id_user = await ormar_user_db_oauth.get(user.id) + assert id_user is not None + assert id_user.id == user_db.id + updated_oauth = next( + (oauth for oauth in id_user.oauth_accounts if oauth.id == oauth_to_check_id), + None, + ) + assert updated_oauth.access_token == "NEW_TOKEN" + + # Add new oauth + id_user.oauth_accounts.append(oauth_account3) + await ormar_user_db_oauth.update(id_user) + id_user_updated = await ormar_user_db_oauth.get(user.id) + assert len(id_user_updated.oauth_accounts) == 3 + + # Remove oauth2 and update + id_user.oauth_accounts = [ + oauth for oauth in id_user.oauth_accounts if oauth.id != oauth_account2.id + ] + await ormar_user_db_oauth.update(id_user) + id_user_updated = await ormar_user_db_oauth.get(user.id) + assert len(id_user_updated.oauth_accounts) == 2 + + # Get by email + email_user = await ormar_user_db_oauth.get_by_email(str(user.email)) + assert email_user is not None + assert email_user.id == user_db.id + assert len(email_user.oauth_accounts) == 2 + + # Get by OAuth account + oauth_user = await ormar_user_db_oauth.get_by_oauth_account( + oauth_account1.oauth_name, oauth_account1.account_id + ) + assert oauth_user is not None + assert oauth_user.id == user.id + + # Unknown OAuth account + unknown_oauth_user = await ormar_user_db_oauth.get_by_oauth_account("foo", "bar") + assert unknown_oauth_user is None