From bcc88a8b14074d5cd355045f0f8a78094fa6c3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Voron?= Date: Sun, 6 Oct 2019 11:00:24 +0200 Subject: [PATCH] Implement working SQLAlchemy DB adapter --- .gitignore | 1 + Pipfile | 4 +++ Pipfile.lock | 44 +++++++++++++++++++++++++- fastapi_users/db/__init__.py | 2 +- fastapi_users/db/sqlalchemy.py | 44 ++++++++++++++++++++++++++ fastapi_users/models.py | 9 ++++-- fastapi_users/router.py | 6 ++-- tests/test_db_sqlalchemy.py | 56 ++++++++++++++++++++++++++++++++++ tests/test_router.py | 49 +++++++++++++++-------------- 9 files changed, 182 insertions(+), 33 deletions(-) create mode 100644 fastapi_users/db/sqlalchemy.py create mode 100644 tests/test_db_sqlalchemy.py diff --git a/.gitignore b/.gitignore index 6353ce87..a17a2822 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ coverage.xml .pytest_cache/ junit/ junit.xml +test.db # Translations *.mo diff --git a/Pipfile b/Pipfile index 4e2dc29e..79c1b45e 100644 --- a/Pipfile +++ b/Pipfile @@ -8,11 +8,15 @@ flake8 = "*" pytest = "*" requests = "*" isort = "*" +databases = {extras = ["sqlite"],version = "*"} +pytest-asyncio = "*" [packages] fastapi = "*" passlib = {extras = ["bcrypt"],version = "*"} email-validator = "*" +sqlalchemy = "*" +databases = "*" [requires] python_version = "3.7" diff --git a/Pipfile.lock b/Pipfile.lock index aba5cb3a..b606c97a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4879f37c108087df2ecbcee271a3d58de6bfd9d58013d3f9bf0127ef7e7acf92" + "sha256": "a2653ab0f39cfc4780097259fdeef1f152ec4472b5b3b5cc80cf08997d7a1e81" }, "pipfile-spec": 6, "requires": { @@ -70,6 +70,13 @@ ], "version": "==1.12.3" }, + "databases": { + "hashes": [ + "sha256:1e3b21a237d8b8a8774da1237fa75e951e23bf8e943516df8fe2443f1968287f" + ], + "index": "pypi", + "version": "==0.2.5" + }, "dnspython": { "hashes": [ "sha256:36c5e8e38d4369a08b6780b7f27d790a292b2b08eea01607865bf0936c558e01", @@ -134,6 +141,13 @@ ], "version": "==1.12.0" }, + "sqlalchemy": { + "hashes": [ + "sha256:272a835758908412e75e87f75dd0179a51422715c125ce42109632910526b1fd" + ], + "index": "pypi", + "version": "==1.3.9" + }, "starlette": { "hashes": [ "sha256:f600bf9d0beeeeebcb143e6d0c4f8858c2b05067d5a4feb446ba7400ba5e5dc5" @@ -142,6 +156,12 @@ } }, "develop": { + "aiosqlite": { + "hashes": [ + "sha256:ad84fbd7516ca7065d799504fc41d6845c938e5306d1b7dd960caaeda12e22a9" + ], + "version": "==0.10.0" + }, "atomicwrites": { "hashes": [ "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", @@ -170,6 +190,13 @@ ], "version": "==3.0.4" }, + "databases": { + "hashes": [ + "sha256:1e3b21a237d8b8a8774da1237fa75e951e23bf8e943516df8fe2443f1968287f" + ], + "index": "pypi", + "version": "==0.2.5" + }, "entrypoints": { "hashes": [ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", @@ -272,6 +299,14 @@ "index": "pypi", "version": "==5.2.0" }, + "pytest-asyncio": { + "hashes": [ + "sha256:9fac5100fd716cbecf6ef89233e8590a4ad61d729d1732e0a96b84182df1daaf", + "sha256:d734718e25cfc32d2bf78d346e99d33724deeba774cc4afdf491530c6184b63b" + ], + "index": "pypi", + "version": "==0.10.0" + }, "requests": { "hashes": [ "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", @@ -287,6 +322,13 @@ ], "version": "==1.12.0" }, + "sqlalchemy": { + "hashes": [ + "sha256:272a835758908412e75e87f75dd0179a51422715c125ce42109632910526b1fd" + ], + "index": "pypi", + "version": "==1.3.9" + }, "urllib3": { "hashes": [ "sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", diff --git a/fastapi_users/db/__init__.py b/fastapi_users/db/__init__.py index a3e41e3f..476ebe03 100644 --- a/fastapi_users/db/__init__.py +++ b/fastapi_users/db/__init__.py @@ -1,6 +1,6 @@ from typing import List -from ..models import UserDB +from fastapi_users.models import UserDB class UserDBInterface: diff --git a/fastapi_users/db/sqlalchemy.py b/fastapi_users/db/sqlalchemy.py new file mode 100644 index 00000000..f6b42953 --- /dev/null +++ b/fastapi_users/db/sqlalchemy.py @@ -0,0 +1,44 @@ +from typing import List + +from databases import Database +from sqlalchemy import Boolean, Column, String +from sqlalchemy.ext.declarative import declarative_base + +from fastapi_users.db import UserDBInterface +from fastapi_users.models import UserDB + +Base = declarative_base() + + +class User(Base): + __tablename__ = 'user' + + id = Column(String, primary_key=True) + email = Column(String, unique=True, index=True) + hashed_password = Column(String) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + + +users = User.__table__ + + +class SQLAlchemyUserDB(UserDBInterface): + + database: Database + + def __init__(self, database): + self.database = database + + async def list(self) -> List[UserDB]: + query = users.select() + return await self.database.fetch_all(query) + + async def get_by_email(self, email: str) -> UserDB: + query = users.select().where(User.email == email) + return await self.database.fetch_one(query) + + async def create(self, user: UserDB) -> UserDB: + query = users.insert().values(**user.dict()) + await self.database.execute(query) + return user diff --git a/fastapi_users/models.py b/fastapi_users/models.py index a2ac3768..4b2b90ea 100644 --- a/fastapi_users/models.py +++ b/fastapi_users/models.py @@ -1,17 +1,20 @@ import uuid from typing import Optional +import pydantic from pydantic import BaseModel from pydantic.types import EmailStr class UserBase(BaseModel): - id: str = uuid.uuid4 + id: str = None email: Optional[EmailStr] = None is_active: Optional[bool] = True is_superuser: Optional[bool] = False - first_name: Optional[str] = None - last_name: Optional[str] = None + + @pydantic.validator('id', pre=True, always=True) + def default_id(cls, v): + return v or str(uuid.uuid4()) class UserCreate(UserBase): diff --git a/fastapi_users/router.py b/fastapi_users/router.py index 0879b045..c586bd87 100644 --- a/fastapi_users/router.py +++ b/fastapi_users/router.py @@ -1,8 +1,8 @@ from fastapi import APIRouter -from .db import UserDBInterface -from .models import UserCreate, UserDB -from .password import get_password_hash +from fastapi_users.db import UserDBInterface +from fastapi_users.models import UserCreate, UserDB +from fastapi_users.password import get_password_hash class UserRouter: diff --git a/tests/test_db_sqlalchemy.py b/tests/test_db_sqlalchemy.py new file mode 100644 index 00000000..bbf97d51 --- /dev/null +++ b/tests/test_db_sqlalchemy.py @@ -0,0 +1,56 @@ +from databases import Database +import pytest +import sqlalchemy +import sqlite3 + +from fastapi_users.db.sqlalchemy import Base, SQLAlchemyUserDB +from fastapi_users.models import UserDB + + +@pytest.fixture +async def sqlalchemy_user_db() -> SQLAlchemyUserDB: + DATABASE_URL = 'sqlite:///./test.db' + database = Database(DATABASE_URL) + + engine = sqlalchemy.create_engine( + DATABASE_URL, connect_args={'check_same_thread': False} + ) + Base.metadata.create_all(engine) + + await database.connect() + + yield SQLAlchemyUserDB(database) + + Base.metadata.drop_all(engine) + + +@pytest.fixture +def user() -> UserDB: + return UserDB( + email='king.arthur@camelot.bt', + hashed_password='abc', + ) + + +@pytest.mark.asyncio +async def test_queries(user, sqlalchemy_user_db): + # Create + user_db = await sqlalchemy_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 + + # List + users = await sqlalchemy_user_db.list() + assert len(users) == 1 + first_user = users[0] + assert first_user.id == user_db.id + + # Get by email + email_user = await sqlalchemy_user_db.get_by_email(user.email) + assert email_user.id == user_db.id + + # Exception on existing email + with pytest.raises(sqlite3.IntegrityError): + await sqlalchemy_user_db.create(user) diff --git a/tests/test_router.py b/tests/test_router.py index c606d31a..ae95554d 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -25,32 +25,31 @@ def test_app_client() -> TestClient: return TestClient(app) -def test_register_empty_body(test_app_client: TestClient): - response = test_app_client.post('/register', json={}) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY +class TestRegister: + def test_empty_body(self, test_app_client: TestClient): + response = test_app_client.post('/register', json={}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY -def test_register_missing_password(test_app_client: TestClient): - json = { - 'email': 'king.arthur@camelot.bt', - } - response = test_app_client.post('/register', json=json) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + def test_missing_password(self, test_app_client: TestClient): + json = { + 'email': 'king.arthur@camelot.bt', + } + response = test_app_client.post('/register', json=json) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + def test_wrong_email(self, test_app_client: TestClient): + json = { + 'email': 'king.arthur', + 'password': 'guinevere', + } + response = test_app_client.post('/register', json=json) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY -def test_register_wrong_email(test_app_client: TestClient): - json = { - 'email': 'king.arthur', - 'password': 'guinevere', - } - response = test_app_client.post('/register', json=json) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -def test_register_valid_body(test_app_client: TestClient): - json = { - 'email': 'king.arthur@camelot.bt', - 'password': 'guinevere', - } - response = test_app_client.post('/register', json=json) - assert response.status_code == status.HTTP_200_OK + def test_valid_body(self, test_app_client: TestClient): + json = { + 'email': 'king.arthur@camelot.bt', + 'password': 'guinevere', + } + response = test_app_client.post('/register', json=json) + assert response.status_code == status.HTTP_200_OK