Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
jmaupetit committed Mar 27, 2024
1 parent 5e62436 commit babc3f9
Show file tree
Hide file tree
Showing 11 changed files with 529 additions and 10 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to

## [Unreleased]

## Added

- Integrate PostgreSQL database persistency in an asynchronous context

## [0.1.0] - 2024-03-26

### Added
Expand Down
34 changes: 31 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,27 @@ default: help
# -- Docker/compose
bootstrap: ## bootstrap the project for development
bootstrap: \
build
build \
migrate-api \
create-api-test-db
.PHONY: bootstrap

build: ## build the app container(s)
$(COMPOSE) build
.PHONY: build

logs: ## display OCPI server logs (follow mode)
down: ## stop and remove all containers
@$(COMPOSE) down
.PHONY: down

logs: ## display all services logs (follow mode)
@$(COMPOSE) logs -f
.PHONY: logs

logs-api: ## display API server logs (follow mode)
@$(COMPOSE) logs -f api
.PHONY: logs-api

run: ## run the whole stack
$(COMPOSE) up -d
.PHONY: run
Expand All @@ -38,7 +48,25 @@ stop: ## stop all servers
@$(COMPOSE) stop
.PHONY: stop

# -- OCPI
# -- Provisioning
create-api-test-db: ## create API test database
@$(COMPOSE) exec postgresql bash -c 'psql "postgresql://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@$${QUALICHARGE_DB_HOST}:$${QUALICHARGE_DB_PORT}/postgres" -c "create database \"$${QUALICHARGE_TEST_DB_NAME}\";"' || echo "Duly noted, skipping database creation."
.PHONY: create-api-test-db

drop-api-test-db: ## drop API test database
@$(COMPOSE) exec postgresql bash -c 'psql "postgresql://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@$${QUALICHARGE_DB_HOST}:$${QUALICHARGE_DB_PORT}/postgres" -c "drop database \"$${QUALICHARGE_TEST_DB_NAME}\";"' || echo "Duly noted, skipping database deletion."
.PHONY: drop-api-test-db

migrate-api: ## run alembic database migrations for the api service
@echo "Running api service database engine…"
@$(COMPOSE) up -d --wait postgresql
@echo "Create api service database…"
@$(COMPOSE) exec postgresql bash -c 'psql "postgresql://$${POSTGRES_USER}:$${POSTGRES_PASSWORD}@$${QUALICHARGE_DB_HOST}:$${QUALICHARGE_DB_PORT}/postgres" -c "create database \"$${QUALICHARGE_DB_NAME}\";"' || echo "Duly noted, skipping database creation."
@echo "Running migrations for api service…"
@bin/alembic upgrade head
.PHONY: migrate-api

# -- API
lint: ## lint api python sources
lint: \
lint-black \
Expand Down
2 changes: 1 addition & 1 deletion bin/pipenv
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

set -eo pipefail

docker compose run --rm -u "pipenv:pipenv" api pipenv "$@"
docker compose run --rm --no-deps -u "pipenv:pipenv" api pipenv "$@"
docker compose build api
18 changes: 17 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
version: '3.8'
version: "3.8"

services:
postgresql:
image: postgres:14
env_file:
- env.d/postgresql
- env.d/api
healthcheck:
test:
- "CMD-SHELL"
- "pg_isready"
- "-d"
- "$${QUALICHARGE_DB_NAME}"
interval: 10s
timeout: 5s
retries: 5

api:
build:
Expand All @@ -16,3 +30,5 @@ services:
- env.d/api
volumes:
- ./src/api:/app
depends_on:
- postgresql
9 changes: 9 additions & 0 deletions env.d/api
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
PORT=8000
QUALICHARGE_ALLOWED_HOSTS=["http://localhost:8010"]
QUALICHARGE_DB_ENGINE=postgresql
QUALICHARGE_DB_HOST=postgresql
QUALICHARGE_DB_NAME=qualicharge-api
QUALICHARGE_DB_PASSWORD=pass
QUALICHARGE_DB_PORT=5432
QUALICHARGE_DB_USER=qualicharge
QUALICHARGE_DEBUG=1
QUALICHARGE_TEST_DB_NAME=test-qualicharge-api
3 changes: 3 additions & 0 deletions env.d/postgresql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POSTGRES_DB=qualicharge-api
POSTGRES_USER=qualicharge
POSTGRES_PASSWORD=pass
8 changes: 6 additions & 2 deletions src/api/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@ verify_ssl = true
name = "pypi"

[packages]
alembic = "==1.13.1"
fastapi = "==0.110.0"
psycopg2-binary = "==2.9.9"
pydantic-settings = "==2.2.1"
setuptools = "==69.2.0"
uvicorn = {extras = ["standard"], version = "==0.29.0"}
sqlmodel = "==0.0.16"
uvicorn = {extras = ["standard"] }

[dev-packages]
black = "==24.3.0"
honcho = "==1.1.0"
httpx = "==0.27.0"
mypy = "==1.9.0"
polyfactory = "==2.15.0"
pytest = "==8.1.1"
pytest-httpx = "==0.30.0"
ruff = "==0.3.4"

[requires]
Expand Down
335 changes: 333 additions & 2 deletions src/api/Pipfile.lock

Large diffs are not rendered by default.

14 changes: 13 additions & 1 deletion src/api/qualicharge/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
"""QualiCharge API root."""

from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from ..conf import settings
from ..db import get_engine
from .v1 import app as v1

app = FastAPI()

@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application life span."""
engine = get_engine()
yield
engine.dispose()


app = FastAPI(lifespan=lifespan)


app.add_middleware(
Expand Down
35 changes: 35 additions & 0 deletions src/api/qualicharge/conf.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""QualiCharge API settings."""

from pathlib import Path
from typing import List

from pydantic_settings import BaseSettings, SettingsConfigDict
Expand All @@ -15,6 +16,40 @@ class Settings(BaseSettings):
"http://localhost:8010",
]

# API Core Root path
# (used at least by everything that is alembic-configuration-related)
ROOT_PATH: Path = Path(__file__).parent

# Alembic
ALEMBIC_CFG_PATH: Path = ROOT_PATH / "alembic.ini"

# Database
DB_ENGINE: str = "postgresql"
DB_HOST: str = "postgresql"
DB_NAME: str = "warren-api"
DB_USER: str = "fun"
DB_PASSWORD: str = "pass"
DB_PORT: int = 5432
TEST_DB_NAME: str = "test-warren-api"

@property
def DATABASE_URL(self) -> str:
"""Get the database URL as required by SQLAlchemy."""
return (
f"{self.DB_ENGINE}://"
f"{self.DB_USER}:{self.DB_PASSWORD}@"
f"{self.DB_HOST}/{self.DB_NAME}"
)

@property
def TEST_DATABASE_URL(self) -> str:
"""Get the database URL as required by SQLAlchemy."""
return (
f"{self.DB_ENGINE}://"
f"{self.DB_USER}:{self.DB_PASSWORD}@"
f"{self.DB_HOST}/{self.TEST_DB_NAME}"
)

model_config = SettingsConfigDict(
case_sensitive=True, env_nested_delimiter="__", env_prefix="QUALICHARGE_"
)
Expand Down
77 changes: 77 additions & 0 deletions src/api/qualicharge/db.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""QualiCharge database connection."""

import logging
from typing import Optional

from sqlalchemy import Engine as SAEngine
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from sqlmodel import Session as SMSession
from sqlmodel import create_engine

from .conf import settings

logger = logging.getLogger(__name__)


class Singleton(type):
"""Singleton pattern metaclass."""

_instances: dict = {}

def __call__(cls, *args, **kwargs):
"""Store instances in a private class property."""
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]


class Engine(metaclass=Singleton):
"""Database engine singleton."""

_engine: Optional[SAEngine] = None

def get_engine(self, url, echo=False) -> SAEngine:
"""Get created engine or create a new one."""
if self._engine is None:
logger.debug("Create a new engine")
self._engine = create_engine(url, echo=echo)
logger.debug("Getting database engine %s", self._engine)
return self._engine


class Session(metaclass=Singleton):
"""Database session singleton."""

_session: Optional[SMSession] = None

def get_session(self, engine) -> SMSession:
"""Get active session or create a new one."""
if self._session is None:
logger.debug("Create new session")
self._session = SMSession(bind=engine)
logger.debug("Getting database session %s", self._session)
return self._session


def get_engine() -> SAEngine:
"""Get database engine."""
return Engine().get_engine(url=settings.DATABASE_URL, echo=settings.DEBUG)


def get_session() -> SMSession:
"""Get database session."""
session = Session().get_session(get_engine())
logger.debug("Getting session %s", session)
return session


def is_alive() -> bool:
"""Check if database connection is alive."""
session = get_session()
try:
session.execute(text("SELECT 1 as is_alive"))
return True
except OperationalError as err:
logger.debug("Exception: %s", err)
return False

0 comments on commit babc3f9

Please sign in to comment.