diff --git a/.env b/.env new file mode 100644 index 0000000..bf639e9 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +PYTHONDONTWRITEBYTECODE=1 +PYTHONUNBUFFERED=1 + +POSTGRES_DB=devdb +POSTGRES_TEST_DB=testdb +POSTGRES_HOST=db +POSTGRES_USER=user + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..ec2ea30 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length=120 \ No newline at end of file diff --git a/.gitignore b/.gitignore index b6e4761..6570d0c 100644 --- a/.gitignore +++ b/.gitignore @@ -102,7 +102,7 @@ celerybeat.pid *.sage.py # Environments -.env +#.env .venv env/ venv/ diff --git a/.secrets b/.secrets new file mode 100644 index 0000000..d06b563 --- /dev/null +++ b/.secrets @@ -0,0 +1 @@ +POSTGRES_PASS=secret \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..23087f6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Pull base image +FROM python:3.9-buster as builder + +# Set environment variables +WORKDIR /pipfiles +COPY Pipfile Pipfile +COPY Pipfile.lock Pipfile.lock + +# Install pipenv +RUN set -ex && pip install pipenv --upgrade + +# Upgrde pip, setuptools and wheel +RUN set -ex && pip install --upgrade pip setuptools wheel + +# Install dependencies +RUN set -ex && pipenv install --system --sequential --ignore-pipfile --dev + +FROM builder as final +WORKDIR /app +COPY ./the_app/ /app/ +COPY ./tests/ /app/ +COPY .env /app/ +COPY .secrets /app/ + +RUN set -ex && bash -c "eval $(grep 'PYTHONDONTWRITEBYTECODE' .env)" +RUN set -ex && bash -c "eval $(grep 'PYTHONUNBUFFERED' .env)" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3c5b5b1 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: help +help: ## Show this help + @egrep -h '\s##\s' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +.PHONY: build +build: ## Build project with compose + docker-compose build + +.PHONY: up +up: ## Run project with compose + docker-compose up + +.PHONY: down +down: ## Reset project containers with compose + docker-compose down + +.PHONY: lock +lock: ## Refresh pipfile.lock + pipenv lock --pre + +.PHONY: requirements +requirements: ## Refresh requirements.txt from pipfile.lock + pipenv lock -r > requirements.txt + +.PHONY: test +test: ## Run project tests + docker-compose run --rm web pytest + +.PHONY: lint +lint: ## Linter project code. + isort . + black --fast --line-length=120 . + mypy --ignore-missing-imports the_app + flake8 --config .flake8 . diff --git a/db/Dockerfile b/db/Dockerfile new file mode 100644 index 0000000..6d3c800 --- /dev/null +++ b/db/Dockerfile @@ -0,0 +1,5 @@ +# pull official base image +FROM postgres:13-alpine + +# run create.sql on init +ADD create.sql /docker-entrypoint-initdb.d diff --git a/db/create.sql b/db/create.sql new file mode 100644 index 0000000..1376f64 --- /dev/null +++ b/db/create.sql @@ -0,0 +1,4 @@ +DROP DATABASE IF EXISTS devdb; +CREATE DATABASE devdb; +DROP DATABASE IF EXISTS devdb; +CREATE DATABASE devdb; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..654f7c2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3' + +services: + app: + build: . + env_file: + - .secrets + - .env + command: bash -c " + uvicorn the_app.main:app + --host 0.0.0.0 --port 8080 + --lifespan=on --use-colors --loop uvloop --http httptools + --reload --reload-dir /app + " + volumes: + - .:/app + ports: + - 8080:8080 + depends_on: + - db + + db: + build: + context: ./db + dockerfile: Dockerfile + env_file: + - .secrets + - .env + ports: + - 5432:5432 + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASS} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_HOST_AUTH_METHOD=trust \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/the_app/__init__.py b/the_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/the_app/api/__init__.py b/the_app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/the_app/api/stuff.py b/the_app/api/stuff.py new file mode 100644 index 0000000..05df710 --- /dev/null +++ b/the_app/api/stuff.py @@ -0,0 +1,39 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from the_app.database import get_db +from the_app.models.stuff import Stuff +from the_app.schemas.stuff import StuffResponse, StuffSchema + +router = APIRouter() + + +@router.post("/", response_model=StuffResponse) +async def create_stuff(stuff: StuffSchema, db_session: AsyncSession = Depends(get_db)): + stuff_id = await Stuff.create(db_session, stuff) + return {**stuff.dict(), "id": stuff_id} + + +@router.delete("/") +async def delete_stuff(stuff_id: UUID, db_session: AsyncSession = Depends(get_db)): + return await Stuff.delete(db_session, stuff_id) + + +@router.get("/") +async def find_stuff( + name: str, + db_session: AsyncSession = Depends(get_db), +): + return await Stuff.find(db_session, name) + + +@router.patch("/") +async def update_config( + stuff: StuffSchema, + name: str, + db_session: AsyncSession = Depends(get_db), +): + instance_of_the_stuff = await Stuff.find(db_session, name) + return instance_of_the_stuff.update(db_session, stuff) diff --git a/the_app/config.py b/the_app/config.py new file mode 100644 index 0000000..7d9d342 --- /dev/null +++ b/the_app/config.py @@ -0,0 +1,41 @@ +import logging +import os +from functools import lru_cache + +from pydantic import BaseSettings + +log = logging.getLogger(__name__) + + +class Settings(BaseSettings): + """ + + BaseSettings, from Pydantic, validates the data so that when we create an instance of Settings, + environment and testing will have types of str and bool, respectively. + + Parameters: + pg_user (str): + pg_pass (str): + pg_database: (str): + pg_test_database: (str): + asyncpg_url: AnyUrl: + asyncpg_test_url: AnyUrl: + + Returns: + instance of Settings + + """ + + pg_user: str = os.getenv("POSTGRES_USER", "") + pg_pass: str = os.getenv("POSTGRES_PASS", "") + pg_host: str = os.getenv("POSTGRES_HOST", "") + pg_database: str = os.getenv("POSTGRES_DB", "") + pg_test_database: str = os.getenv("POSTGRES_TEST_DB", "") + asyncpg_url: str = f"postgresql+asyncpg://{pg_user}:{pg_pass}@{pg_host}:5432/{pg_database}" + asyncpg_test_url: str = f"postgresql+asyncpg://{pg_user}:{pg_pass}@{pg_host}:5432/{pg_test_database}" + + +@lru_cache() +def get_settings(): + log.info("Loading config settings from the environment...") + return Settings() diff --git a/the_app/database.py b/the_app/database.py new file mode 100644 index 0000000..ba88a9d --- /dev/null +++ b/the_app/database.py @@ -0,0 +1,30 @@ +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from the_app import config + +global_settings = config.get_settings() +url = global_settings.asyncpg_url + +engine = create_async_engine( + url, + echo=True, +) + +# expire_on_commit=False will prevent attributes from being expired +# after commit. +async_session = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) + + +# Dependency +async def get_db(): + session = async_session() + try: + yield session + await session.commit() + except SQLAlchemyError as ex: + await session.rollback() + raise ex + finally: + await session.close() diff --git a/the_app/main.py b/the_app/main.py new file mode 100644 index 0000000..2c19f78 --- /dev/null +++ b/the_app/main.py @@ -0,0 +1,25 @@ +import logging + +from fastapi import FastAPI + +from the_app.api.stuff import router as stuff_router +from the_app.database import engine +from the_app.models.base import Base + +log = logging.getLogger(__name__) + +app = FastAPI(title="Stuff And Nonsense", version="0.1") + +app.include_router(stuff_router, prefix="/v1") + + +@app.on_event("startup") +async def startup_event(): + log.info("Starting up...") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +@app.on_event("shutdown") +async def shutdown_event(): + log.info("Shutting down...") diff --git a/the_app/models/__init__.py b/the_app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/the_app/models/base.py b/the_app/models/base.py new file mode 100644 index 0000000..bf4d2ba --- /dev/null +++ b/the_app/models/base.py @@ -0,0 +1,23 @@ +from typing import Any + +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.declarative import as_declarative, declared_attr + + +@as_declarative() +class Base: + id: Any + __name__: str + # Generate __tablename__ automatically + @declared_attr + def __tablename__(cls) -> str: + return cls.__name__.lower() + + async def save(self, db_session: AsyncSession): + try: + db_session.add(self) + return await db_session.commit() + except SQLAlchemyError as ex: + print(f"Have to rollback, save failed: {ex}") + raise diff --git a/the_app/models/stuff.py b/the_app/models/stuff.py new file mode 100644 index 0000000..73171eb --- /dev/null +++ b/the_app/models/stuff.py @@ -0,0 +1,46 @@ +import uuid + +from sqlalchemy import Column, String, delete, select +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.ext.asyncio import AsyncSession + +from the_app.models.base import Base +from the_app.schemas.stuff import StuffSchema + + +class Stuff(Base): + __tablename__ = "stuff" + id = Column(UUID(as_uuid=True), unique=True, default=uuid.uuid4, autoincrement=True) + name = Column(String, nullable=False, primary_key=True, unique=True) + description = Column(String, nullable=False, unique=True) + + def __init__(self, name: str, description: str): + self.name = name + self.description = description + + @classmethod + async def create(cls, db_session: AsyncSession, schema: StuffSchema): + stuff = Stuff( + name=schema.name, + description=schema.description, + ) + await stuff.save(db_session) + return stuff.id + + async def update(self, db_session: AsyncSession, schema: StuffSchema): + self.name = schema.name + self.description = schema.description + return await self.save(db_session) + + @classmethod + async def find(cls, db_session: AsyncSession, name: str): + stmt = select(cls).where(cls.name == name) + result = await db_session.execute(stmt) + return result.scalars().first() + + @classmethod + async def delete(cls, db_session: AsyncSession, stuff_id: UUID): + stmt = delete(cls).where(cls.id == stuff_id) + await db_session.execute(stmt) + await db_session.commit() + return True diff --git a/the_app/schemas/__init__.py b/the_app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/the_app/schemas/stuff.py b/the_app/schemas/stuff.py new file mode 100644 index 0000000..793a0ff --- /dev/null +++ b/the_app/schemas/stuff.py @@ -0,0 +1,47 @@ +from uuid import UUID + +from pydantic import BaseModel, Field + + +class StuffSchema(BaseModel): + name: str = Field( + title="", + description="", + ) + description: str = Field( + title="", + description="", + ) + + class Config: + orm_mode = True + schema_extra = { + "example": { + "name": "Name for Some Stuff", + "description": "Some Stuff Description", + } + } + + +class StuffResponse(BaseModel): + id: UUID = Field( + title="Id", + description="", + ) + name: str = Field( + title="", + description="", + ) + description: str = Field( + title="", + description="", + ) + + class Config: + schema_extra = { + "example": { + "config_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Name for Some Stuff", + "description": "Some Stuff Description", + } + }