From 81117394568cda6cfa915ceb3d7798f963f8cf27 Mon Sep 17 00:00:00 2001 From: arashi87 Date: Fri, 7 Apr 2023 19:07:36 +0800 Subject: [PATCH 1/6] fix(Config): use `find_dotenv` to instead of hard code env file path --- api/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/config.py b/api/config.py index a2f8e2c..a0151a5 100644 --- a/api/config.py +++ b/api/config.py @@ -1,12 +1,13 @@ import sys from typing import Any, Dict, Optional +from dotenv import find_dotenv from pydantic import BaseSettings, PostgresDsn, validator class Settings(BaseSettings): class Config: - env_file = ".env" + env_file = find_dotenv(usecwd=True) env_file_encoding = "utf-8" """Application configuration""" From b892af233336ca8a00a117cddfb448a5b96d41c5 Mon Sep 17 00:00:00 2001 From: arashi87 Date: Fri, 7 Apr 2023 19:39:29 +0800 Subject: [PATCH 2/6] feat(article): init article route, and implement create operation --- api/app.py | 3 ++- api/endpoints/article.py | 35 ++++++++++++++++++++++++++++++ api/models/article.py | 12 +++++++++++ api/models/user.py | 2 ++ api/repositories/__init__.py | 3 +++ api/repositories/article.py | 15 +++++++++++++ api/schemas/__init__.py | 1 + api/schemas/article.py | 42 ++++++++++++++++++++++++++++++++++++ 8 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 api/endpoints/article.py create mode 100644 api/models/article.py create mode 100644 api/repositories/article.py create mode 100644 api/schemas/article.py diff --git a/api/app.py b/api/app.py index da00501..4e3de98 100644 --- a/api/app.py +++ b/api/app.py @@ -1,5 +1,5 @@ from config import settings -from endpoints import auth, health, user +from endpoints import article, auth, health, user from fastapi import APIRouter, Depends, FastAPI from fastapi.requests import Request from fastapi.responses import Response @@ -16,6 +16,7 @@ ROUTER.include_router(health.router, prefix="/health", tags=["health"]) ROUTER.include_router(user.router, prefix="/user", tags=["user"]) ROUTER.include_router(auth.router, prefix="/auth", tags=["auth"]) +ROUTER.include_router(article.router, prefix="/article", tags=["article"]) # Startup event diff --git a/api/endpoints/article.py b/api/endpoints/article.py new file mode 100644 index 0000000..d526d07 --- /dev/null +++ b/api/endpoints/article.py @@ -0,0 +1,35 @@ +import schemas +from deps import get_current_user, get_db +from fastapi import APIRouter, Depends, status +from models.article import Article +from repositories import article_repo +from sqlalchemy.ext.asyncio import AsyncSession + +router = APIRouter() + +CREATE_ARTICLE = { + 201: { + "description": "Article created successfully", + "content": {"json": {"detail": "OK"}}, + }, + 500: { + "description": "Internal server error", + "content": {"json": {"detail": "Internal server error"}}, + }, +} + + +@router.post( + "/", + status_code=status.HTTP_201_CREATED, + responses=CREATE_ARTICLE, + response_model=schemas.Article, + name="article:create_article", +) +async def create_article( + payload: schemas.ArticleCreate, + db: AsyncSession = Depends(get_db), + current_user: schemas.User = Depends(get_current_user), +): + article: Article = await article_repo.create(db, payload, owner_id=current_user.id) + return article diff --git a/api/models/article.py b/api/models/article.py new file mode 100644 index 0000000..2366167 --- /dev/null +++ b/api/models/article.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from .base import Base + + +class Article(Base): + title = Column(String, nullable=False) + description = Column(String, nullable=False) + body = Column(String, nullable=False) + owner_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE")) + owner = relationship("User", back_populates="articles") diff --git a/api/models/user.py b/api/models/user.py index ba5120e..09d4384 100644 --- a/api/models/user.py +++ b/api/models/user.py @@ -1,4 +1,5 @@ from sqlalchemy import Column, String +from sqlalchemy.orm import relationship from .base import Base @@ -7,3 +8,4 @@ class User(Base): name = Column(String, nullable=False) email = Column(String, nullable=False, unique=True) password = Column(String, nullable=False) + articles = relationship("Article", back_populates="owner") diff --git a/api/repositories/__init__.py b/api/repositories/__init__.py index 0cf2e00..a27257e 100644 --- a/api/repositories/__init__.py +++ b/api/repositories/__init__.py @@ -1,5 +1,8 @@ +from models.article import Article from models.user import User +from .article import ArticleRepository from .user import UserRepository user_repo = UserRepository(User) +article_repo = ArticleRepository(Article) diff --git a/api/repositories/article.py b/api/repositories/article.py new file mode 100644 index 0000000..4055a52 --- /dev/null +++ b/api/repositories/article.py @@ -0,0 +1,15 @@ +from schemas.article import Article, ArticleCreate, ArticleUpdate +from sqlalchemy.ext.asyncio import AsyncSession + +from .base import BaseRepository + + +class ArticleRepository(BaseRepository[Article, ArticleCreate, ArticleUpdate]): + async def create( + self, db: AsyncSession, article: ArticleCreate, owner_id: int + ) -> Article: + db_obj = self.model(**article.dict(), owner_id=owner_id) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj diff --git a/api/schemas/__init__.py b/api/schemas/__init__.py index 14b73f3..0e0e380 100644 --- a/api/schemas/__init__.py +++ b/api/schemas/__init__.py @@ -1,3 +1,4 @@ +from .article import Article, ArticleCreate from .auth import AuthToken, AuthTokenData from .msg import Msg from .user import User, UserCreate, UserUpdate, UserWithoutPassword diff --git a/api/schemas/article.py b/api/schemas/article.py new file mode 100644 index 0000000..7037780 --- /dev/null +++ b/api/schemas/article.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel + + +# Shared properties +class ArticleBase(BaseModel): + title: str + description: str + body: str + + +""" +CRUD schemas for Article +""" + + +# Properties to receive on article creation +class ArticleCreate(ArticleBase): + pass + + +# Properties to receive on article update +class ArticleUpdate(ArticleBase): + pass + + +""" +Database schemas for Article +""" + + +# Shared properties +class ArticleDB(ArticleBase): + id: int + owner_id: int + + class Config: + orm_mode = True + + +# Properties to return to client +class Article(ArticleDB): + pass From 1fb7ef342aa0de04f70965be5f757bc2f091923b Mon Sep 17 00:00:00 2001 From: arashi87 Date: Fri, 7 Apr 2023 19:40:23 +0800 Subject: [PATCH 3/6] test(AssertRequest): add custom header support --- api/tests/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/api/tests/__init__.py b/api/tests/__init__.py index 927239e..3d80857 100644 --- a/api/tests/__init__.py +++ b/api/tests/__init__.py @@ -1,3 +1,4 @@ +from copy import deepcopy from dataclasses import dataclass from typing import Any, Callable, Dict, Mapping, Optional @@ -41,12 +42,16 @@ async def __call__( claims: Optional[Dict[str, Any]] = None, assert_func: Callable = None, data: Optional[Mapping[str, Any]] = None, + header: Optional[Mapping[str, str]] = None, *args, **kwargs, ): - header = self.header - if claims is not None: - header = {"Authorization": f"Bearer {create_access_token(claims=claims)}"} + if header is None: + header = deepcopy(self.header) + if claims is not None: + header = { + "Authorization": f"Bearer {create_access_token(claims=claims)}" + } async with AsyncClient(app=app, base_url="https://localhost") as ac: resp: Response = await ac.request( From 8e33ae2dd6fc307b73777c13de4b937892f2f584 Mon Sep 17 00:00:00 2001 From: arashi87 Date: Fri, 7 Apr 2023 19:40:51 +0800 Subject: [PATCH 4/6] test(article): add article create endpoint testing --- api/tests/test_article.py | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 api/tests/test_article.py diff --git a/api/tests/test_article.py b/api/tests/test_article.py new file mode 100644 index 0000000..84c82fe --- /dev/null +++ b/api/tests/test_article.py @@ -0,0 +1,40 @@ +from app import APP +from fastapi import FastAPI +from tests import DEFAULT_USER, RequestBody, ResponseBody, assert_request + +""" Test create article endpoint +@router post /article/ +@status_code 201 +@response_model schemas.Article +@name article:create_article +@dependencies get_current_user, get_db +""" + + +async def test_create_article_success(app: FastAPI): + req = RequestBody( + url=app.url_path_for(name="article:create_article"), + body={"title": "test", "description": "test", "body": "test"}, + ) + resp = ResponseBody( + status_code=201, + body={ + "title": "test", + "description": "test", + "body": "test", + "id": 1, + "owner_id": DEFAULT_USER.id, + }, + ) + await assert_request(app=APP, method="POST", req_body=req, resp_body=resp) + + +async def test_create_article_auth_failed(app: FastAPI): + req = RequestBody( + url=app.url_path_for(name="article:create_article"), + body={"title": "test", "description": "test", "body": "test"}, + ) + resp = ResponseBody(status_code=401, body={"detail": "Not authenticated"}) + await assert_request( + app=APP, method="POST", req_body=req, resp_body=resp, header={} + ) From e04c80993057350e58838a983f91e3ed2a977cc5 Mon Sep 17 00:00:00 2001 From: arashi87 Date: Fri, 7 Apr 2023 19:41:46 +0800 Subject: [PATCH 5/6] feat(alembic): import base model to instead one by one import --- api/migrate/env.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/migrate/env.py b/api/migrate/env.py index 78efe06..3b2c3d1 100644 --- a/api/migrate/env.py +++ b/api/migrate/env.py @@ -4,6 +4,8 @@ from sqlalchemy import engine_from_config, pool from api.config import settings +from api.models.article import Article +from api.models.base import Base from api.models.user import User # this is the Alembic Config object, which provides @@ -20,7 +22,7 @@ # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -target_metadata = [User.__table__.metadata] +target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: From 68d7d56f76e6891dcf8e7e3687facadc9a7239b1 Mon Sep 17 00:00:00 2001 From: arashi87 Date: Fri, 7 Apr 2023 19:42:41 +0800 Subject: [PATCH 6/6] feat(article): create article revision history --- .../5ed609dfd923_create_article_model.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 api/migrate/versions/5ed609dfd923_create_article_model.py diff --git a/api/migrate/versions/5ed609dfd923_create_article_model.py b/api/migrate/versions/5ed609dfd923_create_article_model.py new file mode 100644 index 0000000..965ce61 --- /dev/null +++ b/api/migrate/versions/5ed609dfd923_create_article_model.py @@ -0,0 +1,39 @@ +"""create article model + +Revision ID: 5ed609dfd923 +Revises: 8b70fd3f48fa +Create Date: 2023-03-30 17:44:18.720196 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5ed609dfd923" +down_revision = "8b70fd3f48fa" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "article", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("deleted", sa.Boolean(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("update_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=False), + sa.Column("body", sa.String(), nullable=False), + sa.Column("owner_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["owner_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("article") + # ### end Alembic commands ###