Compare commits
9 Commits
0.1.5
...
9aecbea68b
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aecbea68b | |||
| cd91b20278 | |||
| fd9853957a | |||
| 9c68f11d74 | |||
| 6848a66d88 | |||
| a9a49eba4e | |||
| 9ccde7ca37 | |||
| 88c3345647 | |||
| d9f3de9468 |
31
README.md
31
README.md
@@ -17,6 +17,7 @@ It combines FastAPI, React, and PostgreSQL in a Docker Compose stack with RBAC,
|
|||||||
- [Service Information](#service-information)
|
- [Service Information](#service-information)
|
||||||
- [Target Owner Notifications](#target-owner-notifications)
|
- [Target Owner Notifications](#target-owner-notifications)
|
||||||
- [API Overview](#api-overview)
|
- [API Overview](#api-overview)
|
||||||
|
- [API Error Format](#api-error-format)
|
||||||
- [`pg_stat_statements` Requirement](#pg_stat_statements-requirement)
|
- [`pg_stat_statements` Requirement](#pg_stat_statements-requirement)
|
||||||
- [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance)
|
- [Reverse Proxy / SSL Guidance](#reverse-proxy--ssl-guidance)
|
||||||
- [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test)
|
- [PostgreSQL Compatibility Smoke Test](#postgresql-compatibility-smoke-test)
|
||||||
@@ -319,6 +320,36 @@ Email alert routing is target-specific:
|
|||||||
- `GET /api/v1/service/info`
|
- `GET /api/v1/service/info`
|
||||||
- `POST /api/v1/service/info/check`
|
- `POST /api/v1/service/info/check`
|
||||||
|
|
||||||
|
## API Error Format
|
||||||
|
|
||||||
|
All 4xx/5xx responses use a consistent JSON payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "validation_error",
|
||||||
|
"message": "Request validation failed",
|
||||||
|
"details": [],
|
||||||
|
"request_id": "c8f0f888-2365-4b86-a5de-b3f0e9df4a4b"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common fields:
|
||||||
|
|
||||||
|
- `code`: stable machine-readable error code
|
||||||
|
- `message`: human-readable summary
|
||||||
|
- `details`: optional extra context (validation list, debug context, etc.)
|
||||||
|
- `request_id`: request correlation ID (also returned in `X-Request-ID` header)
|
||||||
|
|
||||||
|
Common error codes:
|
||||||
|
|
||||||
|
- `bad_request` (`400`)
|
||||||
|
- `unauthorized` (`401`)
|
||||||
|
- `forbidden` (`403`)
|
||||||
|
- `not_found` (`404`)
|
||||||
|
- `conflict` (`409`)
|
||||||
|
- `validation_error` (`422`)
|
||||||
|
- `internal_error` (`500`)
|
||||||
|
|
||||||
## `pg_stat_statements` Requirement
|
## `pg_stat_statements` Requirement
|
||||||
|
|
||||||
Query Insights requires `pg_stat_statements` on the monitored target:
|
Query Insights requires `pg_stat_statements` on the monitored target:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.12-slim AS base
|
FROM python:3.13-slim AS base
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
@@ -6,6 +6,10 @@ ENV PIP_NO_CACHE_DIR=1
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get upgrade -y \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN addgroup --system app && adduser --system --ingroup app app
|
RUN addgroup --system app && adduser --system --ingroup app app
|
||||||
|
|
||||||
COPY requirements.txt /app/requirements.txt
|
COPY requirements.txt /app/requirements.txt
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from jose import JWTError, jwt
|
import jwt
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -29,7 +29,7 @@ async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)) -> To
|
|||||||
async def refresh(payload: RefreshRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
async def refresh(payload: RefreshRequest, db: AsyncSession = Depends(get_db)) -> TokenResponse:
|
||||||
try:
|
try:
|
||||||
token_payload = jwt.decode(payload.refresh_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
token_payload = jwt.decode(payload.refresh_token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
except JWTError as exc:
|
except jwt.InvalidTokenError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
|
||||||
|
|
||||||
if token_payload.get("type") != "refresh":
|
if token_payload.get("type") != "refresh":
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from functools import lru_cache
|
|||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
NEXAPG_VERSION = "0.1.4"
|
NEXAPG_VERSION = "0.1.8"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from jose import JWTError, jwt
|
import jwt
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
@@ -20,7 +20,7 @@ async def get_current_user(
|
|||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
except JWTError as exc:
|
except jwt.InvalidTokenError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||||
|
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
|
|||||||
31
backend/app/core/errors.py
Normal file
31
backend/app/core/errors.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def error_payload(code: str, message: str, details: Any, request_id: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"details": details,
|
||||||
|
"request_id": request_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def http_status_to_code(status_code: int) -> str:
|
||||||
|
mapping = {
|
||||||
|
400: "bad_request",
|
||||||
|
401: "unauthorized",
|
||||||
|
403: "forbidden",
|
||||||
|
404: "not_found",
|
||||||
|
405: "method_not_allowed",
|
||||||
|
409: "conflict",
|
||||||
|
422: "validation_error",
|
||||||
|
429: "rate_limited",
|
||||||
|
500: "internal_error",
|
||||||
|
502: "bad_gateway",
|
||||||
|
503: "service_unavailable",
|
||||||
|
504: "gateway_timeout",
|
||||||
|
}
|
||||||
|
return mapping.get(status_code, f"http_{status_code}")
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from jose import jwt
|
import jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from uuid import uuid4
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
|
from app.core.errors import error_payload, http_status_to_code
|
||||||
from app.core.logging import configure_logging
|
from app.core.logging import configure_logging
|
||||||
from app.core.security import hash_password
|
from app.core.security import hash_password
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
@@ -57,4 +62,67 @@ app.add_middleware(
|
|||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def request_id_middleware(request: Request, call_next):
|
||||||
|
request_id = request.headers.get("x-request-id") or str(uuid4())
|
||||||
|
request.state.request_id = request_id
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def http_exception_handler(request: Request, exc: HTTPException | StarletteHTTPException):
|
||||||
|
request_id = getattr(request.state, "request_id", str(uuid4()))
|
||||||
|
code = http_status_to_code(exc.status_code)
|
||||||
|
message = "Request failed"
|
||||||
|
details = None
|
||||||
|
|
||||||
|
if isinstance(exc.detail, str):
|
||||||
|
message = exc.detail
|
||||||
|
elif isinstance(exc.detail, dict):
|
||||||
|
code = str(exc.detail.get("code", code))
|
||||||
|
message = str(exc.detail.get("message", message))
|
||||||
|
details = exc.detail.get("details")
|
||||||
|
elif isinstance(exc.detail, list):
|
||||||
|
message = "Request validation failed"
|
||||||
|
details = exc.detail
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=error_payload(code=code, message=message, details=details, request_id=request_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def request_validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
request_id = getattr(request.state, "request_id", str(uuid4()))
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content=error_payload(
|
||||||
|
code="validation_error",
|
||||||
|
message="Request validation failed",
|
||||||
|
details=exc.errors(),
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||||
|
request_id = getattr(request.state, "request_id", str(uuid4()))
|
||||||
|
logger.exception("unhandled_exception request_id=%s", request_id, exc_info=exc)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=error_payload(
|
||||||
|
code="internal_error",
|
||||||
|
message="Internal server error",
|
||||||
|
details=None,
|
||||||
|
request_id=request_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
fastapi==0.116.1
|
fastapi==0.129.0
|
||||||
|
starlette==0.52.1
|
||||||
uvicorn[standard]==0.35.0
|
uvicorn[standard]==0.35.0
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
sqlalchemy[asyncio]==2.0.44
|
sqlalchemy[asyncio]==2.0.44
|
||||||
@@ -7,7 +8,7 @@ alembic==1.16.5
|
|||||||
pydantic==2.11.7
|
pydantic==2.11.7
|
||||||
pydantic-settings==2.11.0
|
pydantic-settings==2.11.0
|
||||||
email-validator==2.2.0
|
email-validator==2.2.0
|
||||||
python-jose[cryptography]==3.5.0
|
PyJWT==2.11.0
|
||||||
passlib[argon2]==1.7.4
|
passlib[argon2]==1.7.4
|
||||||
cryptography==45.0.7
|
cryptography==46.0.5
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.22
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-bookworm-slim AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
@@ -7,7 +7,8 @@ ARG VITE_API_URL=/api/v1
|
|||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:1.29-bookworm
|
FROM nginx:1.29-alpine-slim
|
||||||
|
RUN apk upgrade --no-cache
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -35,8 +35,21 @@ export async function apiFetch(path, options = {}, tokens, onUnauthorized) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text();
|
const raw = await res.text();
|
||||||
throw new Error(txt || `HTTP ${res.status}`);
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = parsed?.message || raw || `HTTP ${res.status}`;
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = res.status;
|
||||||
|
err.code = parsed?.code || null;
|
||||||
|
err.details = parsed?.details || null;
|
||||||
|
err.requestId = parsed?.request_id || res.headers.get("x-request-id") || null;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
Reference in New Issue
Block a user