| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274 |
- """API 服务开发课程:同一个内存商品服务,练习 HTTP、SSE 与 WebSocket。"""
- import asyncio
- import json
- import os
- import secrets
- from contextlib import asynccontextmanager
- from typing import Annotated
- import httpx
- from fastapi import Depends, FastAPI, Header, HTTPException, Path, Query, Request
- from fastapi import Response, WebSocket, WebSocketDisconnect
- from fastapi.exceptions import RequestValidationError
- from fastapi.middleware.cors import CORSMiddleware
- from fastapi.responses import JSONResponse, StreamingResponse
- from pydantic import BaseModel, ConfigDict, Field
- from starlette.exceptions import HTTPException as StarletteHTTPException
- class ErrorDetail(BaseModel):
- loc: list[str | int]
- msg: str
- type: str
- class ErrorContent(BaseModel):
- code: str
- message: str
- details: list[ErrorDetail]
- class ErrorResponse(BaseModel):
- error: ErrorContent
- @asynccontextmanager
- async def lifespan(app: FastAPI):
- timeout = httpx.Timeout(3.0, connect=1.0)
- async with httpx.AsyncClient(
- base_url="http://127.0.0.1:8000", timeout=timeout,
- ) as client:
- app.state.http = client
- yield
- app = FastAPI(
- title="API Lab · 商品服务",
- version="1.0.0",
- lifespan=lifespan,
- responses={422: {"model": ErrorResponse, "description": "请求参数校验失败"}},
- )
- app.add_middleware(
- CORSMiddleware,
- allow_origins=["http://localhost:5500", "http://127.0.0.1:5500"],
- allow_credentials=False,
- allow_methods=["GET", "POST", "PUT", "DELETE"],
- allow_headers=["Content-Type", "X-API-Key"],
- )
- # 教学用内存存储:重启后丢失;单进程使用,不能用多个 worker 共享数据。
- app.state.products = {}
- app.state.next_id = 1
- class ProductCreate(BaseModel):
- model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
- name: str = Field(min_length=1, max_length=120)
- description: str = Field(default="", max_length=16000)
- is_active: bool = False
- price_cents: int = Field(default=0, ge=0, le=2147483647, strict=True)
- class ProductReplace(BaseModel):
- """PUT 完整替换:四个可修改字段都必须提交。"""
- model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
- name: str = Field(min_length=1, max_length=120)
- description: str = Field(max_length=16000)
- is_active: bool
- price_cents: int = Field(ge=0, le=2147483647, strict=True)
- class ProductOut(ProductCreate):
- id: int
- class PromptIn(BaseModel):
- model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
- prompt: str = Field(min_length=1, max_length=200)
- @app.exception_handler(StarletteHTTPException)
- async def http_error_handler(request: Request, exc: StarletteHTTPException):
- return JSONResponse(
- status_code=exc.status_code,
- content={"error": {"code": "http_error", "message": str(exc.detail), "details": []}},
- headers=exc.headers,
- )
- @app.exception_handler(RequestValidationError)
- async def validation_error_handler(request: Request, exc: RequestValidationError):
- # 只返回定位问题需要的字段,避免回传原始请求、内部异常或不可序列化的 ctx。
- details = [
- {"loc": list(error["loc"]), "msg": error["msg"], "type": error["type"]}
- for error in exc.errors()
- ]
- return JSONResponse(
- status_code=422,
- content={"error": {"code": "validation_error", "message": "请求参数校验失败", "details": details}},
- )
- def find_product(product_id: int) -> ProductOut:
- product = app.state.products.get(product_id)
- if product is None:
- raise HTTPException(status_code=404, detail="Product not found")
- return product
- @app.get("/health", tags=["起步"])
- async def health():
- return {"status": "ok"}
- @app.get("/products", response_model=list[ProductOut], tags=["商品 CRUD"])
- async def list_products(
- q: Annotated[str | None, Query(max_length=120)] = None,
- limit: Annotated[int, Query(ge=1, le=100)] = 10,
- is_active: bool | None = None,
- ):
- products = list(app.state.products.values())
- if q:
- query = q.casefold()
- products = [product for product in products if query in product.name.casefold() or query in product.description.casefold()]
- if is_active is not None:
- products = [product for product in products if product.is_active == is_active]
- return products[:limit]
- @app.post("/products", response_model=ProductOut, status_code=201, tags=["商品 CRUD"])
- async def create_product(body: ProductCreate, response: Response):
- product = ProductOut(id=app.state.next_id, **body.model_dump())
- app.state.products[product.id] = product
- app.state.next_id += 1
- response.headers["Location"] = f"/products/{product.id}"
- return product
- @app.get("/products/{product_id}", response_model=ProductOut, responses={404: {"model": ErrorResponse}}, tags=["商品 CRUD"])
- async def get_product(product_id: Annotated[int, Path(ge=1)]):
- return find_product(product_id)
- @app.put("/products/{product_id}", response_model=ProductOut, responses={404: {"model": ErrorResponse}}, tags=["商品 CRUD"])
- async def replace_product(product_id: Annotated[int, Path(ge=1)], body: ProductReplace):
- find_product(product_id)
- product = ProductOut(id=product_id, **body.model_dump())
- app.state.products[product_id] = product
- return product
- @app.delete("/products/{product_id}", status_code=204, response_class=Response, responses={404: {"model": ErrorResponse}}, tags=["商品 CRUD"])
- async def delete_product(product_id: Annotated[int, Path(ge=1)]):
- find_product(product_id)
- del app.state.products[product_id]
- return Response(status_code=204)
- async def require_api_key(x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None):
- expected = os.getenv("DEMO_API_KEY")
- if not expected:
- raise HTTPException(status_code=503, detail="Set DEMO_API_KEY before using /private")
- if x_api_key is None or not secrets.compare_digest(x_api_key.encode(), expected.encode()):
- raise HTTPException(
- status_code=401,
- detail="Invalid or missing API key",
- headers={"WWW-Authenticate": "APIKey"},
- )
- @app.get(
- "/private",
- dependencies=[Depends(require_api_key)],
- responses={401: {"model": ErrorResponse}, 503: {"model": ErrorResponse}},
- tags=["依赖与请求头"],
- )
- async def private_example():
- return {"message": "API key accepted"}
- @app.get("/slow", tags=["异步等待"])
- async def slow(delay: Annotated[float, Query(ge=0, le=5)] = 1):
- await asyncio.sleep(delay)
- return {"delay": delay, "status": "done"}
- @app.get(
- "/upstream-demo",
- responses={502: {"model": ErrorResponse}, 504: {"model": ErrorResponse}},
- tags=["调用其他 HTTP 接口"],
- )
- async def upstream_demo(
- request: Request,
- delay: Annotated[float, Query(ge=0, le=5)] = 1,
- ):
- # 固定调用本机的 /slow,不接受用户提供的出站 URL。
- try:
- response = await request.app.state.http.get("/slow", params={"delay": delay})
- response.raise_for_status()
- return {"upstream": response.json()}
- except httpx.TimeoutException as exc:
- raise HTTPException(504, "上游响应超时") from exc
- except httpx.HTTPStatusError as exc:
- raise HTTPException(502, "上游返回非成功状态") from exc
- except httpx.RequestError as exc:
- raise HTTPException(502, "无法连接上游服务") from exc
- @app.post("/generate", tags=["普通响应与流式响应"])
- async def generate(payload: PromptIn):
- # 商品清单导出进度的技术演示:模拟三个步骤,不生成实际导出文件。
- parts = []
- for text in ["准备导出商品清单:", payload.prompt, "。导出进度演示完成。"]:
- await asyncio.sleep(0.4)
- parts.append(text)
- return {"text": "".join(parts)}
- def encode_sse(event: str, data: dict) -> str:
- payload = json.dumps(data, ensure_ascii=False)
- return f"event: {event}\ndata: {payload}\n\n"
- async def token_events(request: Request, prompt: str):
- try:
- for text in ["准备导出商品清单:", prompt, "。导出进度演示完成。"]:
- await asyncio.sleep(0.4)
- if await request.is_disconnected():
- return
- yield encode_sse("token", {"text": text})
- yield encode_sse("done", {})
- finally:
- print("stream closed")
- @app.get("/stream", tags=["普通响应与流式响应"])
- async def stream(
- request: Request,
- prompt: Annotated[str, Query(min_length=1, max_length=200)] = "catalog",
- ):
- return StreamingResponse(
- token_events(request, prompt),
- media_type="text/event-stream",
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
- )
- @app.websocket("/ws")
- async def websocket_echo(websocket: WebSocket):
- # 商品咨询传输演示:回显收到的文本,不实现客服逻辑或消息存储。
- await websocket.accept()
- try:
- while True:
- message = await websocket.receive_text()
- if message == "ping":
- await websocket.send_json({"type": "pong"})
- else:
- await websocket.send_json({"type": "echo", "text": message})
- except WebSocketDisconnect:
- return
|