From 316d17027b32e1d4a09888d37cfb2a60b8bfceb3 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 31 Mar 2026 22:20:24 +0800 Subject: [PATCH] docs(api): add internal API contract and expose response schemas in swagger --- backend/app/api/internal.py | 5 +- backend/app/api/internal_catalog.py | 27 ++-- backend/app/schemas/internal.py | 85 +++++++++++++ docs/INTERNAL_API_HANDOFF.md | 189 ++++++++++++++++++++++++++++ 4 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 backend/app/schemas/internal.py create mode 100644 docs/INTERNAL_API_HANDOFF.md diff --git a/backend/app/api/internal.py b/backend/app/api/internal.py index e5e4f8b..f580d55 100644 --- a/backend/app/api/internal.py +++ b/backend/app/api/internal.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from app.core.config import get_settings from app.db.session import get_db from app.repositories.permissions_repo import PermissionsRepository +from app.schemas.internal import InternalUpsertUserBySubResponse from app.repositories.users_repo import UsersRepository from app.schemas.authentik_admin import AuthentikEnsureUserRequest, AuthentikEnsureUserResponse from app.schemas.permissions import PermissionSnapshotResponse @@ -15,11 +16,11 @@ from app.services.permission_service import PermissionService router = APIRouter(prefix="/internal", tags=["internal"], dependencies=[Depends(require_api_client)]) -@router.post("/users/upsert-by-sub") +@router.post("/users/upsert-by-sub", response_model=InternalUpsertUserBySubResponse) def upsert_user_by_sub( payload: UserUpsertBySubRequest, db: Session = Depends(get_db), -) -> dict[str, str | bool | None]: +) -> InternalUpsertUserBySubResponse: repo = UsersRepository(db) user = repo.upsert_by_sub( authentik_sub=payload.sub, diff --git a/backend/app/api/internal_catalog.py b/backend/app/api/internal_catalog.py index 33d2148..5d43536 100644 --- a/backend/app/api/internal_catalog.py +++ b/backend/app/api/internal_catalog.py @@ -7,28 +7,35 @@ from app.repositories.modules_repo import ModulesRepository from app.repositories.sites_repo import SitesRepository from app.repositories.systems_repo import SystemsRepository from app.repositories.users_repo import UsersRepository +from app.schemas.internal import ( + InternalCompanyListResponse, + InternalMemberListResponse, + InternalModuleListResponse, + InternalSiteListResponse, + InternalSystemListResponse, +) from app.security.api_client_auth import require_api_client router = APIRouter(prefix="/internal", tags=["internal"], dependencies=[Depends(require_api_client)]) -@router.get("/systems") +@router.get("/systems", response_model=InternalSystemListResponse) def internal_list_systems( db: Session = Depends(get_db), limit: int = Query(default=200, ge=1, le=1000), offset: int = Query(default=0, ge=0), -) -> dict: +) -> InternalSystemListResponse: repo = SystemsRepository(db) items, total = repo.list(limit=limit, offset=offset) return {"items": [{"id": i.id, "system_key": i.system_key, "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset} -@router.get("/modules") +@router.get("/modules", response_model=InternalModuleListResponse) def internal_list_modules( db: Session = Depends(get_db), limit: int = Query(default=500, ge=1, le=2000), offset: int = Query(default=0, ge=0), -) -> dict: +) -> InternalModuleListResponse: modules_repo = ModulesRepository(db) items, total = modules_repo.list(limit=limit, offset=offset) return { @@ -48,25 +55,25 @@ def internal_list_modules( } -@router.get("/companies") +@router.get("/companies", response_model=InternalCompanyListResponse) def internal_list_companies( db: Session = Depends(get_db), keyword: str | None = Query(default=None), limit: int = Query(default=500, ge=1, le=2000), offset: int = Query(default=0, ge=0), -) -> dict: +) -> InternalCompanyListResponse: repo = CompaniesRepository(db) items, total = repo.list(keyword=keyword, limit=limit, offset=offset) return {"items": [{"id": i.id, "company_key": i.company_key, "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset} -@router.get("/sites") +@router.get("/sites", response_model=InternalSiteListResponse) def internal_list_sites( db: Session = Depends(get_db), company_key: str | None = Query(default=None), limit: int = Query(default=500, ge=1, le=2000), offset: int = Query(default=0, ge=0), -) -> dict: +) -> InternalSiteListResponse: companies_repo = CompaniesRepository(db) sites_repo = SitesRepository(db) company_id = None @@ -80,13 +87,13 @@ def internal_list_sites( return {"items": [{"id": i.id, "site_key": i.site_key, "company_key": mapping.get(i.company_id), "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset} -@router.get("/members") +@router.get("/members", response_model=InternalMemberListResponse) def internal_list_members( db: Session = Depends(get_db), keyword: str | None = Query(default=None), limit: int = Query(default=500, ge=1, le=2000), offset: int = Query(default=0, ge=0), -) -> dict: +) -> InternalMemberListResponse: repo = UsersRepository(db) items, total = repo.list(keyword=keyword, limit=limit, offset=offset) return { diff --git a/backend/app/schemas/internal.py b/backend/app/schemas/internal.py new file mode 100644 index 0000000..81bfb1b --- /dev/null +++ b/backend/app/schemas/internal.py @@ -0,0 +1,85 @@ +from pydantic import BaseModel + + +class InternalSystemItem(BaseModel): + id: str + system_key: str + name: str + status: str + + +class InternalSystemListResponse(BaseModel): + items: list[InternalSystemItem] + total: int + limit: int + offset: int + + +class InternalModuleItem(BaseModel): + id: str + module_key: str + system_key: str + name: str + status: str + + +class InternalModuleListResponse(BaseModel): + items: list[InternalModuleItem] + total: int + limit: int + offset: int + + +class InternalCompanyItem(BaseModel): + id: str + company_key: str + name: str + status: str + + +class InternalCompanyListResponse(BaseModel): + items: list[InternalCompanyItem] + total: int + limit: int + offset: int + + +class InternalSiteItem(BaseModel): + id: str + site_key: str + company_key: str | None = None + name: str + status: str + + +class InternalSiteListResponse(BaseModel): + items: list[InternalSiteItem] + total: int + limit: int + offset: int + + +class InternalMemberItem(BaseModel): + id: str + authentik_sub: str + username: str | None = None + email: str | None = None + display_name: str | None = None + is_active: bool + + +class InternalMemberListResponse(BaseModel): + items: list[InternalMemberItem] + total: int + limit: int + offset: int + + +class InternalUpsertUserBySubResponse(BaseModel): + id: str + sub: str + authentik_user_id: int | None = None + username: str | None = None + email: str | None = None + display_name: str | None = None + is_active: bool diff --git a/docs/INTERNAL_API_HANDOFF.md b/docs/INTERNAL_API_HANDOFF.md new file mode 100644 index 0000000..0acd261 --- /dev/null +++ b/docs/INTERNAL_API_HANDOFF.md @@ -0,0 +1,189 @@ +# Internal API Handoff + +## Base URL +- Local: `http://127.0.0.1:8000` +- Prod: 由部署環境提供 + +## Auth Headers(每支 `/internal/*` 都必帶) +- `X-Client-Key: ` +- `X-API-Key: ` + +## Common Error Response +```json +{ + "detail": "error_code" +} +``` + +常見 `detail`: +- `invalid_client`(401) +- `invalid_api_key`(401) +- `client_expired`(401) +- `origin_not_allowed`(403) +- `ip_not_allowed`(403) +- `path_not_allowed`(403) + +## Endpoints + +### GET `/internal/systems` +Response: +```json +{ + "items": [ + { "id": "uuid", "system_key": "ST20260331X1234", "name": "Marketing", "status": "active" } + ], + "total": 1, + "limit": 200, + "offset": 0 +} +``` + +### GET `/internal/modules` +Response: +```json +{ + "items": [ + { + "id": "uuid", + "module_key": "MD20260331X5678", + "system_key": "ST20260331X1234", + "name": "Campaign", + "status": "active" + } + ], + "total": 1, + "limit": 500, + "offset": 0 +} +``` + +### GET `/internal/companies` +Query: +- `keyword`(optional) +- `limit`(default 500) +- `offset`(default 0) + +Response: +```json +{ + "items": [ + { "id": "uuid", "company_key": "CP20260331X9999", "name": "OSE", "status": "active" } + ], + "total": 1, + "limit": 500, + "offset": 0 +} +``` + +### GET `/internal/sites` +Query: +- `company_key`(optional) +- `limit`(default 500) +- `offset`(default 0) + +Response: +```json +{ + "items": [ + { + "id": "uuid", + "site_key": "ST20260331X1111", + "company_key": "CP20260331X9999", + "name": "main-site", + "status": "active" + } + ], + "total": 1, + "limit": 500, + "offset": 0 +} +``` + +### GET `/internal/members` +Query: +- `keyword`(optional) +- `limit`(default 500) +- `offset`(default 0) + +Response: +```json +{ + "items": [ + { + "id": "uuid", + "authentik_sub": "authentik-uid", + "username": "chris", + "email": "chris@ose.tw", + "display_name": "Chris", + "is_active": true + } + ], + "total": 1, + "limit": 500, + "offset": 0 +} +``` + +### POST `/internal/users/upsert-by-sub` +Request: +```json +{ + "sub": "authentik-uid", + "username": "chris", + "email": "chris@ose.tw", + "display_name": "Chris", + "is_active": true +} +``` + +Response: +```json +{ + "id": "uuid", + "sub": "authentik-uid", + "authentik_user_id": 123, + "username": "chris", + "email": "chris@ose.tw", + "display_name": "Chris", + "is_active": true +} +``` + +### GET `/internal/permissions/{authentik_sub}/snapshot` +Response: +```json +{ + "authentik_sub": "authentik-uid", + "permissions": [ + { + "scope_type": "site", + "scope_id": "ST20260331X1111", + "system": "ST20260331X1234", + "module": "MD20260331X5678", + "actions": ["view", "edit"] + } + ] +} +``` + +### POST `/internal/authentik/users/ensure` +Request: +```json +{ + "sub": "authentik-uid", + "email": "user@example.com", + "username": "user1", + "display_name": "User One", + "is_active": true +} +``` + +Response: +```json +{ + "authentik_user_id": 123, + "action": "created" +} +``` + +`action` 可能值:`created` / `updated`