main.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. """API 服务开发课程:同一个内存商品服务,练习 HTTP、SSE 与 WebSocket。"""
  2. import asyncio
  3. import json
  4. import os
  5. import secrets
  6. from contextlib import asynccontextmanager
  7. from typing import Annotated
  8. import httpx
  9. from fastapi import Depends, FastAPI, Header, HTTPException, Path, Query, Request
  10. from fastapi import Response, WebSocket, WebSocketDisconnect
  11. from fastapi.exceptions import RequestValidationError
  12. from fastapi.middleware.cors import CORSMiddleware
  13. from fastapi.responses import JSONResponse, StreamingResponse
  14. from pydantic import BaseModel, ConfigDict, Field
  15. from starlette.exceptions import HTTPException as StarletteHTTPException
  16. class ErrorDetail(BaseModel):
  17. loc: list[str | int]
  18. msg: str
  19. type: str
  20. class ErrorContent(BaseModel):
  21. code: str
  22. message: str
  23. details: list[ErrorDetail]
  24. class ErrorResponse(BaseModel):
  25. error: ErrorContent
  26. @asynccontextmanager
  27. async def lifespan(app: FastAPI):
  28. timeout = httpx.Timeout(3.0, connect=1.0)
  29. async with httpx.AsyncClient(
  30. base_url="http://127.0.0.1:8000", timeout=timeout,
  31. ) as client:
  32. app.state.http = client
  33. yield
  34. app = FastAPI(
  35. title="API Lab · 商品服务",
  36. version="1.0.0",
  37. lifespan=lifespan,
  38. responses={422: {"model": ErrorResponse, "description": "请求参数校验失败"}},
  39. )
  40. app.add_middleware(
  41. CORSMiddleware,
  42. allow_origins=["http://localhost:5500", "http://127.0.0.1:5500"],
  43. allow_credentials=False,
  44. allow_methods=["GET", "POST", "PUT", "DELETE"],
  45. allow_headers=["Content-Type", "X-API-Key"],
  46. )
  47. # 教学用内存存储:重启后丢失;单进程使用,不能用多个 worker 共享数据。
  48. app.state.products = {}
  49. app.state.next_id = 1
  50. class ProductCreate(BaseModel):
  51. model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
  52. name: str = Field(min_length=1, max_length=120)
  53. description: str = Field(default="", max_length=16000)
  54. is_active: bool = False
  55. price_cents: int = Field(default=0, ge=0, le=2147483647, strict=True)
  56. class ProductReplace(BaseModel):
  57. """PUT 完整替换:四个可修改字段都必须提交。"""
  58. model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
  59. name: str = Field(min_length=1, max_length=120)
  60. description: str = Field(max_length=16000)
  61. is_active: bool
  62. price_cents: int = Field(ge=0, le=2147483647, strict=True)
  63. class ProductOut(ProductCreate):
  64. id: int
  65. class PromptIn(BaseModel):
  66. model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
  67. prompt: str = Field(min_length=1, max_length=200)
  68. @app.exception_handler(StarletteHTTPException)
  69. async def http_error_handler(request: Request, exc: StarletteHTTPException):
  70. return JSONResponse(
  71. status_code=exc.status_code,
  72. content={"error": {"code": "http_error", "message": str(exc.detail), "details": []}},
  73. headers=exc.headers,
  74. )
  75. @app.exception_handler(RequestValidationError)
  76. async def validation_error_handler(request: Request, exc: RequestValidationError):
  77. # 只返回定位问题需要的字段,避免回传原始请求、内部异常或不可序列化的 ctx。
  78. details = [
  79. {"loc": list(error["loc"]), "msg": error["msg"], "type": error["type"]}
  80. for error in exc.errors()
  81. ]
  82. return JSONResponse(
  83. status_code=422,
  84. content={"error": {"code": "validation_error", "message": "请求参数校验失败", "details": details}},
  85. )
  86. def find_product(product_id: int) -> ProductOut:
  87. product = app.state.products.get(product_id)
  88. if product is None:
  89. raise HTTPException(status_code=404, detail="Product not found")
  90. return product
  91. @app.get("/health", tags=["起步"])
  92. async def health():
  93. return {"status": "ok"}
  94. @app.get("/products", response_model=list[ProductOut], tags=["商品 CRUD"])
  95. async def list_products(
  96. q: Annotated[str | None, Query(max_length=120)] = None,
  97. limit: Annotated[int, Query(ge=1, le=100)] = 10,
  98. is_active: bool | None = None,
  99. ):
  100. products = list(app.state.products.values())
  101. if q:
  102. query = q.casefold()
  103. products = [product for product in products if query in product.name.casefold() or query in product.description.casefold()]
  104. if is_active is not None:
  105. products = [product for product in products if product.is_active == is_active]
  106. return products[:limit]
  107. @app.post("/products", response_model=ProductOut, status_code=201, tags=["商品 CRUD"])
  108. async def create_product(body: ProductCreate, response: Response):
  109. product = ProductOut(id=app.state.next_id, **body.model_dump())
  110. app.state.products[product.id] = product
  111. app.state.next_id += 1
  112. response.headers["Location"] = f"/products/{product.id}"
  113. return product
  114. @app.get("/products/{product_id}", response_model=ProductOut, responses={404: {"model": ErrorResponse}}, tags=["商品 CRUD"])
  115. async def get_product(product_id: Annotated[int, Path(ge=1)]):
  116. return find_product(product_id)
  117. @app.put("/products/{product_id}", response_model=ProductOut, responses={404: {"model": ErrorResponse}}, tags=["商品 CRUD"])
  118. async def replace_product(product_id: Annotated[int, Path(ge=1)], body: ProductReplace):
  119. find_product(product_id)
  120. product = ProductOut(id=product_id, **body.model_dump())
  121. app.state.products[product_id] = product
  122. return product
  123. @app.delete("/products/{product_id}", status_code=204, response_class=Response, responses={404: {"model": ErrorResponse}}, tags=["商品 CRUD"])
  124. async def delete_product(product_id: Annotated[int, Path(ge=1)]):
  125. find_product(product_id)
  126. del app.state.products[product_id]
  127. return Response(status_code=204)
  128. async def require_api_key(x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None):
  129. expected = os.getenv("DEMO_API_KEY")
  130. if not expected:
  131. raise HTTPException(status_code=503, detail="Set DEMO_API_KEY before using /private")
  132. if x_api_key is None or not secrets.compare_digest(x_api_key.encode(), expected.encode()):
  133. raise HTTPException(
  134. status_code=401,
  135. detail="Invalid or missing API key",
  136. headers={"WWW-Authenticate": "APIKey"},
  137. )
  138. @app.get(
  139. "/private",
  140. dependencies=[Depends(require_api_key)],
  141. responses={401: {"model": ErrorResponse}, 503: {"model": ErrorResponse}},
  142. tags=["依赖与请求头"],
  143. )
  144. async def private_example():
  145. return {"message": "API key accepted"}
  146. @app.get("/slow", tags=["异步等待"])
  147. async def slow(delay: Annotated[float, Query(ge=0, le=5)] = 1):
  148. await asyncio.sleep(delay)
  149. return {"delay": delay, "status": "done"}
  150. @app.get(
  151. "/upstream-demo",
  152. responses={502: {"model": ErrorResponse}, 504: {"model": ErrorResponse}},
  153. tags=["调用其他 HTTP 接口"],
  154. )
  155. async def upstream_demo(
  156. request: Request,
  157. delay: Annotated[float, Query(ge=0, le=5)] = 1,
  158. ):
  159. # 固定调用本机的 /slow,不接受用户提供的出站 URL。
  160. try:
  161. response = await request.app.state.http.get("/slow", params={"delay": delay})
  162. response.raise_for_status()
  163. return {"upstream": response.json()}
  164. except httpx.TimeoutException as exc:
  165. raise HTTPException(504, "上游响应超时") from exc
  166. except httpx.HTTPStatusError as exc:
  167. raise HTTPException(502, "上游返回非成功状态") from exc
  168. except httpx.RequestError as exc:
  169. raise HTTPException(502, "无法连接上游服务") from exc
  170. @app.post("/generate", tags=["普通响应与流式响应"])
  171. async def generate(payload: PromptIn):
  172. # 商品清单导出进度的技术演示:模拟三个步骤,不生成实际导出文件。
  173. parts = []
  174. for text in ["准备导出商品清单:", payload.prompt, "。导出进度演示完成。"]:
  175. await asyncio.sleep(0.4)
  176. parts.append(text)
  177. return {"text": "".join(parts)}
  178. def encode_sse(event: str, data: dict) -> str:
  179. payload = json.dumps(data, ensure_ascii=False)
  180. return f"event: {event}\ndata: {payload}\n\n"
  181. async def token_events(request: Request, prompt: str):
  182. try:
  183. for text in ["准备导出商品清单:", prompt, "。导出进度演示完成。"]:
  184. await asyncio.sleep(0.4)
  185. if await request.is_disconnected():
  186. return
  187. yield encode_sse("token", {"text": text})
  188. yield encode_sse("done", {})
  189. finally:
  190. print("stream closed")
  191. @app.get("/stream", tags=["普通响应与流式响应"])
  192. async def stream(
  193. request: Request,
  194. prompt: Annotated[str, Query(min_length=1, max_length=200)] = "catalog",
  195. ):
  196. return StreamingResponse(
  197. token_events(request, prompt),
  198. media_type="text/event-stream",
  199. headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
  200. )
  201. @app.websocket("/ws")
  202. async def websocket_echo(websocket: WebSocket):
  203. # 商品咨询传输演示:回显收到的文本,不实现客服逻辑或消息存储。
  204. await websocket.accept()
  205. try:
  206. while True:
  207. message = await websocket.receive_text()
  208. if message == "ping":
  209. await websocket.send_json({"type": "pong"})
  210. else:
  211. await websocket.send_json({"type": "echo", "text": message})
  212. except WebSocketDisconnect:
  213. return