docs(api): add internal API contract and expose response schemas in swagger
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
85
backend/app/schemas/internal.py
Normal file
85
backend/app/schemas/internal.py
Normal file
@@ -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
|
||||
189
docs/INTERNAL_API_HANDOFF.md
Normal file
189
docs/INTERNAL_API_HANDOFF.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Internal API Handoff
|
||||
|
||||
## Base URL
|
||||
- Local: `http://127.0.0.1:8000`
|
||||
- Prod: 由部署環境提供
|
||||
|
||||
## Auth Headers(每支 `/internal/*` 都必帶)
|
||||
- `X-Client-Key: <client_key>`
|
||||
- `X-API-Key: <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`
|
||||
Reference in New Issue
Block a user