Add versioned HTTP gateway routing by business domain
This commit is contained in:
@@ -0,0 +1 @@
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:5174
|
||||
@@ -2,26 +2,86 @@
|
||||
|
||||
面向开发者和 coding agent 的广告云埋点与广告推送后端。使用 FastAPI 开发,依赖和虚拟环境统一由 **uv** 管理。
|
||||
|
||||
当前为开发脚手架,提供健康检查与 API 文档;埋点与广告推送功能待实现。
|
||||
## HTTP Gateway
|
||||
|
||||
## 开发
|
||||
```text
|
||||
HTTPS :443 (Nginx,终止 TLS)
|
||||
├── /v1/events/* → events 路由模块
|
||||
├── /v1/users/* → users 路由模块
|
||||
├── /v1/ads/* → ads 路由模块
|
||||
├── /v1/identity/* → identity 路由模块
|
||||
└── /v1/... → 后续业务模块
|
||||
↓
|
||||
FastAPI 127.0.0.1:8000
|
||||
```
|
||||
|
||||
目前使用模块化单体:四个业务模块共用一个 FastAPI 进程和版本入口。
|
||||
Nginx 为各业务域定义了独立 upstream,后续拆分服务时可以替换地址。
|
||||
Gateway 保留完整路径和查询参数,不剥离 `/v1` 或业务前缀。
|
||||
未知路径返回 404,不支持的 HTTP 方法返回 405。
|
||||
|
||||
| 路径 | 模块职责 | 当前已实现接口 |
|
||||
| --- | --- | --- |
|
||||
| `/v1/events/*` | 埋点事件接收与查询 | `GET /v1/events/health` |
|
||||
| `/v1/users/*` | 平台用户资料管理 | `GET /v1/users/health` |
|
||||
| `/v1/ads/*` | 广告管理与投放 | `GET /v1/ads/health` |
|
||||
| `/v1/identity/*` | 登录、令牌与身份权限 | `GET /v1/identity/health` |
|
||||
|
||||
各模块健康接口用于验证路由挂载,返回 `{"status":"ok","module":"events"}` 等响应;不代表数据库或外部依赖已就绪。
|
||||
当前阶段仅实现 Gateway 和模块入口,尚未实现业务写入、数据持久化、登录或鉴权。
|
||||
|
||||
```text
|
||||
src/adcloud_server/
|
||||
main.py # 应用工厂、CORS、系统健康接口
|
||||
schemas.py # 公共响应模型
|
||||
api/v1/router.py # /v1 统一入口和模块注册
|
||||
api/v1/events.py
|
||||
api/v1/users.py
|
||||
api/v1/ads.py
|
||||
api/v1/identity.py
|
||||
```
|
||||
|
||||
增加新模块时,在 `api/v1/` 新增 `APIRouter`,并在 `router.py` 中注册前缀和 OpenAPI 标签。
|
||||
若部署为独立服务,再增加对应的 Gateway upstream 和 location。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```sh
|
||||
uv sync
|
||||
uv sync --locked
|
||||
uv run uvicorn adcloud_server.main:app --reload --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
- 健康检查:`http://localhost:8000/health`
|
||||
- 系统健康检查:`http://localhost:8000/health`(保留 SDK 和控制台兼容)
|
||||
- 模块探测:`http://localhost:8000/v1/events/health`
|
||||
- API 文档:`http://localhost:8000/docs`
|
||||
- OpenAPI:`http://localhost:8000/openapi.json`
|
||||
|
||||
默认允许来自 `http://localhost:3000`、`http://localhost:5173` 的浏览器请求。
|
||||
通过环境变量配置逗号分隔的允许来源:
|
||||
默认允许来自 `http://localhost:3000`、`http://localhost:5173`、`http://localhost:5174` 的浏览器请求。
|
||||
`CORS_ORIGINS` 接受逗号分隔的来源;环境配置示例见 `.env.example`。
|
||||
|
||||
```sh
|
||||
CORS_ORIGINS=https://your-frontend.example uv run uvicorn adcloud_server.main:app
|
||||
CORS_ORIGINS=https://advertiser.example,https://developer.example uv run uvicorn adcloud_server.main:app
|
||||
```
|
||||
|
||||
CORS 已配置写请求方法和 `Authorization`、`Content-Type`、`X-Request-ID` 请求头。
|
||||
允许预检不代表相应业务端点已实现;CORS 也不代替身份认证和权限校验。
|
||||
|
||||
## HTTPS 入口配置
|
||||
|
||||
`deploy/nginx.conf.example` 是放入 Nginx `http {}` 上下文的配置模板。
|
||||
替换 `server_name` 和证书文件路径,然后在目标服务器使用 `nginx -t` 检查,加载配置。
|
||||
模板未在本机启动 443,也未申请或部署证书。API 文档仅通过内部 FastAPI 地址访问。
|
||||
|
||||
Nginx 在公网边缘重写转发头,FastAPI 只监听回环地址。目标服务器启动后端:
|
||||
|
||||
```sh
|
||||
uv run uvicorn adcloud_server.main:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips 127.0.0.1
|
||||
```
|
||||
|
||||
模板允许最大 1 MiB 请求体,连接超时 5 秒、读写超时 30 秒;可按后续埋点批量策略调整。
|
||||
|
||||
参考:[FastAPI 多模块路由](https://fastapi.tiangolo.com/tutorial/bigger-applications/)、[Nginx proxy_pass 路径规则](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass)。
|
||||
|
||||
## 检查
|
||||
|
||||
```sh
|
||||
@@ -30,4 +90,4 @@ uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
```
|
||||
|
||||
提交 `uv.lock`,在其他电脑使用 `uv sync --locked` 复现依赖。
|
||||
提交 `uv.lock`,使用 `uv sync --locked` 复现依赖。
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Include this file inside the nginx http {} block.
|
||||
# Replace server_name and certificate paths before deployment.
|
||||
# All domains currently run in one modular FastAPI process.
|
||||
upstream adcloud_events { server 127.0.0.1:8000; }
|
||||
upstream adcloud_users { server 127.0.0.1:8000; }
|
||||
upstream adcloud_ads { server 127.0.0.1:8000; }
|
||||
upstream adcloud_identity { server 127.0.0.1:8000; }
|
||||
upstream adcloud_api { server 127.0.0.1:8000; }
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.adcloud.example;
|
||||
|
||||
ssl_certificate /etc/nginx/tls/adcloud/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/tls/adcloud/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
client_max_body_size 1m;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# This gateway is the public edge: overwrite untrusted forwarded headers.
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
|
||||
# No URI suffix on proxy_pass: preserve /v1/... and query strings.
|
||||
location ^~ /v1/events/ { proxy_pass http://adcloud_events; }
|
||||
location ^~ /v1/users/ { proxy_pass http://adcloud_users; }
|
||||
location ^~ /v1/ads/ { proxy_pass http://adcloud_ads; }
|
||||
location ^~ /v1/identity/ { proxy_pass http://adcloud_identity; }
|
||||
# Future domains are handled by the versioned FastAPI router.
|
||||
location /v1/ { proxy_pass http://adcloud_api; }
|
||||
location = /health { proxy_pass http://adcloud_api; }
|
||||
location / { return 404; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from adcloud_server.schemas import ModuleHealthResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", response_model=ModuleHealthResponse)
|
||||
def health() -> ModuleHealthResponse:
|
||||
"""Verify routing to the ads module; not a dependency readiness check."""
|
||||
return ModuleHealthResponse(module="ads")
|
||||
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from adcloud_server.schemas import ModuleHealthResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", response_model=ModuleHealthResponse)
|
||||
def health() -> ModuleHealthResponse:
|
||||
"""Verify routing to the events module; not a dependency readiness check."""
|
||||
return ModuleHealthResponse(module="events")
|
||||
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from adcloud_server.schemas import ModuleHealthResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", response_model=ModuleHealthResponse)
|
||||
def health() -> ModuleHealthResponse:
|
||||
"""Verify routing to the identity module; not a dependency readiness check."""
|
||||
return ModuleHealthResponse(module="identity")
|
||||
@@ -0,0 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from adcloud_server.api.v1 import ads, events, identity, users
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
router.include_router(events.router, prefix="/events", tags=["events"])
|
||||
router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
router.include_router(ads.router, prefix="/ads", tags=["ads"])
|
||||
router.include_router(identity.router, prefix="/identity", tags=["identity"])
|
||||
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from adcloud_server.schemas import ModuleHealthResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", response_model=ModuleHealthResponse)
|
||||
def health() -> ModuleHealthResponse:
|
||||
"""Verify routing to the users module; not a dependency readiness check."""
|
||||
return ModuleHealthResponse(module="users")
|
||||
+30
-20
@@ -1,29 +1,39 @@
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI(title="AdCloud Server", version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
origin.strip()
|
||||
for origin in os.getenv(
|
||||
"CORS_ORIGINS", "http://localhost:3000,http://localhost:5173"
|
||||
).split(",")
|
||||
if origin.strip()
|
||||
],
|
||||
allow_methods=["GET"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
from adcloud_server.api.v1.router import router as v1_router
|
||||
from adcloud_server.schemas import HealthResponse
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: Literal["ok"] = "ok"
|
||||
def create_app() -> FastAPI:
|
||||
application = FastAPI(
|
||||
title="AdCloud Server",
|
||||
version="0.1.0",
|
||||
description="Versioned HTTP API for AdCloud tracking and ad delivery.",
|
||||
redirect_slashes=False,
|
||||
)
|
||||
application.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
origin.strip()
|
||||
for origin in os.getenv(
|
||||
"CORS_ORIGINS",
|
||||
"http://localhost:3000,http://localhost:5173,http://localhost:5174",
|
||||
).split(",")
|
||||
if origin.strip()
|
||||
],
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
|
||||
)
|
||||
application.include_router(v1_router)
|
||||
|
||||
@application.get("/health", response_model=HealthResponse, tags=["system"])
|
||||
def health() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
|
||||
return application
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
def health() -> HealthResponse:
|
||||
return HealthResponse()
|
||||
app = create_app()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: Literal["ok"] = "ok"
|
||||
|
||||
|
||||
class ModuleHealthResponse(HealthResponse):
|
||||
module: Literal["events", "users", "ads", "identity"]
|
||||
@@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from adcloud_server.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(create_app()) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", ["events", "users", "ads", "identity"])
|
||||
def test_versioned_module_routing(client, module):
|
||||
response = client.get(f"/v1/{module}/health?probe=1")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "module": module}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/events/health", "/v2/events/health", "/v1/unknown/health", "/v1/events/unknown"],
|
||||
)
|
||||
def test_unknown_routes_return_404(client, path):
|
||||
assert client.get(path).status_code == 404
|
||||
|
||||
|
||||
def test_unsupported_method(client):
|
||||
assert client.post("/v1/events/health").status_code == 405
|
||||
|
||||
|
||||
def test_write_request_preflight(client):
|
||||
response = client.options(
|
||||
"/v1/events/health",
|
||||
headers={
|
||||
"Origin": "http://localhost:5174",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization,content-type,x-request-id",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["access-control-allow-origin"] == "http://localhost:5174"
|
||||
assert "POST" in response.headers["access-control-allow-methods"]
|
||||
|
||||
|
||||
def test_openapi_groups(client):
|
||||
schema = client.get("/openapi.json").json()
|
||||
for module in ["events", "users", "ads", "identity"]:
|
||||
operation = schema["paths"][f"/v1/{module}/health"]["get"]
|
||||
assert operation["tags"] == [module]
|
||||
|
||||
|
||||
def test_cors_configuration(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"CORS_ORIGINS", " https://console.example, ,https://dev.example "
|
||||
)
|
||||
with TestClient(create_app()) as client:
|
||||
accepted = client.get("/health", headers={"Origin": "https://dev.example"})
|
||||
denied = client.get("/health", headers={"Origin": "http://localhost:5173"})
|
||||
assert accepted.headers["access-control-allow-origin"] == "https://dev.example"
|
||||
assert "access-control-allow-origin" not in denied.headers
|
||||
Reference in New Issue
Block a user