feat: added initial config

This commit is contained in:
2024-10-10 15:51:41 +02:00
parent 86e82a4d94
commit 5e990a615e
26 changed files with 694 additions and 4 deletions

12
.gitignore vendored
View File

@@ -60,6 +60,10 @@ cover/
local_settings.py
db.sqlite3
db.sqlite3-journal
dev.sqlite3
TEST_DB/
files/
DOCKER/
# Flask stuff:
instance/
@@ -77,6 +81,7 @@ target/
# Jupyter Notebook
.ipynb_checkpoints
.virtual_documents
# IPython
profile_default/
@@ -106,10 +111,8 @@ ipython_config.py
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
@@ -160,3 +163,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# MacOS
.DS_Store

35
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,35 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run",
"type": "python",
"request": "launch",
"module": "uvicorn",
"args": ["app.main:app", "--reload", "--port", "9000"],
"jinja": true,
"justMyCode": true,
"env": {
"PYDEVD_DISABLE_FILE_VALIDATION": "1"
}
},
{
"name": "Migrate",
"type": "python",
"request": "launch",
"module": "alembic",
"args": ["upgrade", "head"],
"jinja": true,
"justMyCode": true
},
{
"name": "Make Migrations",
"type": "python",
"request": "launch",
"module": "alembic",
"args": ["revision", "--autogenerate"],
"jinja": true,
"justMyCode": true
}
]
}

42
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python3",
"python.globalModuleInstallation": false,
"python.terminal.activateEnvironment": true,
"python.analysis.typeCheckingMode": "basic",
"[python]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"editor.defaultFormatter": "ms-python.black-formatter"
},
"black-formatter.args": ["--line-length", "100"],
"isort.args": ["--profile", "black"],
// Editor General
"files.insertFinalNewline": true,
"editor.fontSize": 15,
"editor.formatOnSave": true,
"editor.rulers": [100],
"editor.minimap.enabled": true,
"files.exclude": {
"**/.git": true,
"**/.pytest_cache": true,
"**/.venv": true,
"**/.svn": true,
"**/.hg": true,
"**/db.sqlite3": true,
"**/.DS_Store": true,
"**/*.pyc": true,
"**/__pycache__/": true
},
"search.exclude": {
"**/.git": true,
"**/.venv": true,
"**/tmp": true,
"htmlcov/*": true,
"docs/*": true,
".venv/*": true
},
"python.testing.pytestArgs": ["app"],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir=/build/wheels \
-r requirements.txt \
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y curl git
COPY --from=builder /build/wheels /wheels
RUN pip install --no-cache /wheels/*
# Remove the wheels directory after installation to save space
RUN rm -rf /wheels
# Python setup
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ARG VERSION=unknown
ENV VERSION=${VERSION}
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-w", "6" , "-b", "0.0.0.0:9000","app.main:app"]
EXPOSE 9000
HEALTHCHECK --interval=30s --timeout=10s --retries=5 \
CMD curl --fail http://localhost:9000/openapi.json || exit 1

View File

@@ -1 +1,3 @@
# apilog
# apilog
Tiny logging API server, for taking logs via HTTP POST requests.

61
alembic.ini Normal file
View File

@@ -0,0 +1,61 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
[post_write_hooks]
; This will work from alembic version 1.12 https://alembic.sqlalchemy.org/en/latest/autogenerate.html#basic-post-processor-configuration
; hooks = black
; black.type = exec
; black.executable = black
; black.options = -l 100
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

43
alembic/env.py Normal file
View File

@@ -0,0 +1,43 @@
from logging.config import fileConfig
from dotenv import load_dotenv
from sqlalchemy import create_engine
from alembic import context
load_dotenv()
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Our models
from app.services.db.models import Base
target_metadata = Base.metadata
def run_migrations() -> None:
from creyPY.fastapi.db.session import SQLALCHEMY_DATABASE_URL, name
with create_engine(SQLALCHEMY_DATABASE_URL + name, pool_pre_ping=True).connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
run_migrations()

26
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,38 @@
"""empty message
Revision ID: 95201f00f6b9
Revises: e253d9799d38
Create Date: 2024-10-10 15:45:50.089915
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "95201f00f6b9"
down_revision: Union[str, None] = "e253d9799d38"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"application",
sa.Column("name", sa.String(length=512), nullable=False),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True
),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("created_by_id", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_application"),
sa.UniqueConstraint("name", name="uq_application_name"),
)
def downgrade() -> None:
op.drop_table("application")

View File

@@ -0,0 +1,38 @@
"""empty message
Revision ID: e253d9799d38
Revises:
Create Date: 2024-10-10 15:23:32.339647
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "e253d9799d38"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"apikey",
sa.Column("note", sa.String(length=512), nullable=False),
sa.Column("id", sa.UUID(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=True
),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("created_by_id", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id", name="pk_apikey"),
sa.UniqueConstraint("note", name="uq_apikey_note"),
)
def downgrade() -> None:
op.drop_table("apikey")

0
app/__init__.py Normal file
View File

86
app/main.py Normal file
View File

@@ -0,0 +1,86 @@
import logging
import os
from contextlib import asynccontextmanager
from creyPY.fastapi.app import generate_unique_id
from dotenv import load_dotenv
from fastapi import FastAPI, Security
from fastapi.middleware.cors import CORSMiddleware
from fastapi_pagination import add_pagination
from app.services.auth import verify
load_dotenv()
ENV = os.getenv("ENV", "local").lower()
VERSION = os.getenv("VERSION", "Alpha")
@asynccontextmanager
async def lifespan(app: FastAPI):
from app.setup import setup
setup()
# Create initial API key
from creyPY.fastapi.db.session import get_db
from sqlalchemy.orm import Session
from app.models.auth import APIKey
db: Session = next(get_db())
key_obj = db.query(APIKey).filter(APIKey.note == "local_key").one_or_none()
if not key_obj:
db.add(APIKey(note="local_key")) # type: ignore
db.commit()
key_obj = db.query(APIKey).filter(APIKey.note == "local_key").one()
print(f"Local API key: {key_obj.id}")
yield
# App Setup
app = FastAPI(
title="ApiLog API",
description="Tiny service for ingesting logs via POST and querying them via GET.",
version=VERSION,
docs_url="/",
redoc_url=None,
debug=ENV != "prod",
swagger_ui_parameters={
"docExpansion": "list",
"displayOperationId": True,
"defaultModelsExpandDepth": 5,
"defaultModelExpandDepth": 5,
"filter": True,
"displayRequestDuration": True,
"defaultModelRendering": "model",
"persistAuthorization": True,
},
generate_unique_id_function=generate_unique_id,
dependencies=[Security(verify)],
lifespan=lifespan,
)
origins = [
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:4200",
]
# CORS Setup
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# App Routers
from app.routes.app import router as app_router
app.include_router(app_router)
# Pagination
add_pagination(app)

6
app/models/app.py Normal file
View File

@@ -0,0 +1,6 @@
from creyPY.fastapi.models.base import Base
from sqlalchemy import Column, String
class Application(Base):
name = Column(String(512), nullable=False, unique=True)

6
app/models/auth.py Normal file
View File

@@ -0,0 +1,6 @@
from creyPY.fastapi.models.base import Base
from sqlalchemy import Column, String
class APIKey(Base):
note = Column(String(512), nullable=False, unique=True)

0
app/models/entry.py Normal file
View File

65
app/routes/app.py Normal file
View File

@@ -0,0 +1,65 @@
from creyPY.fastapi.crud import (
create_obj_from_data,
)
from creyPY.fastapi.db.session import get_db
from fastapi import APIRouter, Depends, Security, HTTPException
from sqlalchemy.orm import Session
from app.services.auth import verify
from app.schema.app import AppIN, AppOUT
from app.models.app import Application
from fastapi_pagination.ext.sqlalchemy import paginate
from creyPY.fastapi.pagination import Page
from uuid import UUID
router = APIRouter(prefix="/app", tags=["apps"])
@router.post("/", status_code=201)
async def create_app(
data: AppIN,
sub: str = Security(verify),
db: Session = Depends(get_db),
) -> AppOUT:
obj = create_obj_from_data(
data,
Application,
db,
additonal_data={"created_by_id": sub},
)
return AppOUT.model_validate(obj)
@router.delete("/{app_id}", status_code=204)
async def delete_app(
app_id: UUID,
sub: str = Security(verify),
db: Session = Depends(get_db),
) -> None:
obj = db.query(Application).filter_by(id=app_id, created_by_id=sub).one_or_none()
if obj is None:
raise HTTPException(status_code=404, detail="Item not found")
db.delete(obj)
db.commit()
return None
@router.get("/")
async def get_apps(
sub: str = Security(verify),
db: Session = Depends(get_db),
) -> Page[AppOUT]:
the_select = db.query(Application).filter_by(created_by_id=sub)
return paginate(the_select)
@router.get("/{app_id}")
async def get_app(
app_id: UUID,
sub: str = Security(verify),
db: Session = Depends(get_db),
) -> AppOUT:
obj = db.query(Application).filter_by(id=app_id, created_by_id=sub).one_or_none()
if obj is None:
raise HTTPException(status_code=404, detail="Item not found")
return AppOUT.model_validate(obj)

9
app/schema/app.py Normal file
View File

@@ -0,0 +1,9 @@
from app.schema.common import BaseSchemaModelIN, BaseSchemaModelOUT
class AppIN(BaseSchemaModelIN):
name: str
class AppOUT(BaseSchemaModelOUT, AppIN):
pass

10
app/schema/common.py Normal file
View File

@@ -0,0 +1,10 @@
from creyPY.fastapi.schemas.base import BaseSchemaModelOUT as TemplateOUT
from pydantic import BaseModel, ConfigDict
class BaseSchemaModelIN(BaseModel):
model_config = ConfigDict(from_attributes=True)
class BaseSchemaModelOUT(BaseSchemaModelIN, TemplateOUT):
pass

24
app/services/auth.py Normal file
View File

@@ -0,0 +1,24 @@
from uuid import UUID
from creyPY.fastapi.db.session import get_db
from dotenv import load_dotenv
from fastapi import Depends, HTTPException, Request, Security
from fastapi.security import APIKeyQuery
from sqlalchemy.orm import Session
from app.models.auth import APIKey
load_dotenv()
async def verify(
request: Request,
api_key_query: str = Security(APIKeyQuery(name="api-key", auto_error=False)),
db: Session = Depends(get_db),
) -> str:
if api_key_query:
key_info = db.query(APIKey).filter_by(id=UUID(api_key_query)).one_or_none()
if key_info is None:
raise HTTPException(status_code=401, detail="Invalid API key.")
return f"API-KEY: {key_info.note}"
raise HTTPException(status_code=401, detail="No API key.")

View File

@@ -0,0 +1,6 @@
from creyPY.fastapi.models.base import Base # noqa, isort:skip
# custom models from all apps
from app.models.entry import * # noqa, isort:skip
from app.models.auth import * # noqa, isort:skip
from app.models.app import * # noqa, isort:skip

View File

@@ -0,0 +1,8 @@
from sqlalchemy_utils import create_database, database_exists
def create_if_not_exists(db_name: str):
from creyPY.fastapi.db.session import SQLALCHEMY_DATABASE_URL
if not database_exists(SQLALCHEMY_DATABASE_URL + db_name):
create_database(SQLALCHEMY_DATABASE_URL + db_name)

20
app/setup.py Normal file
View File

@@ -0,0 +1,20 @@
import os
from creyPY.fastapi.db.session import SQLALCHEMY_DATABASE_URL, name
from alembic import command
from alembic.config import Config
from app.services.db.session import create_if_not_exists
def setup(db_name=name):
# Create Database
create_if_not_exists(db_name)
# Make alembic migrations
config = Config()
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL + db_name)
config.set_main_option(
"script_location", os.path.join(os.path.dirname(os.path.dirname(__file__)), "alembic")
)
command.upgrade(config, "head")

51
app/test_main.py Normal file
View File

@@ -0,0 +1,51 @@
from creyPY.fastapi.db.session import SQLALCHEMY_DATABASE_URL, get_db
from creyPY.fastapi.models.base import Base
from creyPY.fastapi.testing import GenericClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy_utils import create_database, database_exists, drop_database
from app.services.auth import verify
from .main import app
CURRENT_USER = "api-key|testing"
class TestAPI:
def setup_class(self):
self.engine = create_engine(SQLALCHEMY_DATABASE_URL + "test", pool_pre_ping=True)
if database_exists(self.engine.url):
drop_database(self.engine.url)
create_database(self.engine.url)
Base.metadata.create_all(self.engine)
def get_db_test():
db = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)()
try:
yield db
finally:
db.close()
def get_test_sub():
global CURRENT_USER
return CURRENT_USER
app.dependency_overrides[get_db] = get_db_test
app.dependency_overrides[verify] = get_test_sub
self.c = GenericClient(app)
def teardown_class(self):
drop_database(self.engine.url)
def test_swagger_gen(self):
re = self.c.get("/openapi.json")
assert re["info"]["title"] == "ApiLog API"
def test_health_check(self):
self.c.get("/", parse_json=False)
def test_application_api(self):
self.c.obj_lifecycle({"name": "Testing"}, "/app/")

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
---
x-restart-policy: &restart_policy
restart: unless-stopped
services:
# apilog_worker:
# build:
# context: .
# dockerfile: Dockerfile
# <<: *restart_policy
# container_name: api_worker
# environment:
# - POSTGRES_HOST=apilog_db
# - POSTGRES_PORT=5432
# - POSTGRES_USER=root
# - POSTGRES_PASSWORD=password
# - POSTGRES_DB=apilog
# depends_on:
# apilog_db:
# condition: service_healthy
apilog_db:
image: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: root
POSTGRES_DB: fastapi
<<: *restart_policy
container_name: apilog_db
healthcheck:
test: ["CMD-SHELL", "pg_isready -U root"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"

9
pytest.ini Normal file
View File

@@ -0,0 +1,9 @@
[pytest]
filterwarnings =
default:::app.*
ignore
addopts = -sl
log_cli = 1
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)s] %(message)s (%(filename)s:%(lineno)s)
log_cli_date_format=%H:%M:%S

34
requirements.txt Normal file
View File

@@ -0,0 +1,34 @@
annotated-types==0.7.0
anyio==4.6.0
certifi==2024.8.30
creyPY==1.2.5
fastapi==0.115.0
fastapi-pagination==0.12.31
h11==0.14.0
httpcore==1.0.6
httpx==0.27.2
idna==3.10
psycopg==3.2.3
psycopg-binary==3.2.3
psycopg-pool==3.2.3
pydantic==2.9.2
pydantic_core==2.23.4
python-dotenv==1.0.1
sniffio==1.3.1
SQLAlchemy==2.0.35
starlette==0.38.6
typing_extensions==4.12.2
Mako==1.3.5 # Alembic
MarkupSafe==3.0.1 # Alembic
alembic==1.13.3 # Alembic
SQLAlchemy-Utils==0.41.2 # SQLAlchemy
click==8.1.7 # Uvicorn
uvicorn==0.31.1 # Uvicorn
iniconfig==2.0.0 # pytest
packaging==24.1 # pytest
pluggy==1.5.0 # pytest
pytest==8.3.3 # pytest