main.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  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.notes = {}
  49. app.state.next_id = 1
  50. class NoteCreate(BaseModel):
  51. model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
  52. title: str = Field(min_length=1, max_length=120)
  53. content: str = ""
  54. pinned: bool = False
  55. class NoteReplace(BaseModel):
  56. """PUT 完整替换:三个可修改字段都必须提交。"""
  57. model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
  58. title: str = Field(min_length=1, max_length=120)
  59. content: str
  60. pinned: bool
  61. class NoteOut(NoteCreate):
  62. id: int
  63. class PromptIn(BaseModel):
  64. model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
  65. prompt: str = Field(min_length=1, max_length=200)
  66. @app.exception_handler(StarletteHTTPException)
  67. async def http_error_handler(request: Request, exc: StarletteHTTPException):
  68. return JSONResponse(
  69. status_code=exc.status_code,
  70. content={"error": {"code": "http_error", "message": str(exc.detail), "details": []}},
  71. headers=exc.headers,
  72. )
  73. @app.exception_handler(RequestValidationError)
  74. async def validation_error_handler(request: Request, exc: RequestValidationError):
  75. # 只返回定位问题需要的字段,避免回传原始请求、内部异常或不可序列化的 ctx。
  76. details = [
  77. {"loc": list(error["loc"]), "msg": error["msg"], "type": error["type"]}
  78. for error in exc.errors()
  79. ]
  80. return JSONResponse(
  81. status_code=422,
  82. content={"error": {"code": "validation_error", "message": "请求参数校验失败", "details": details}},
  83. )
  84. def find_note(note_id: int) -> NoteOut:
  85. note = app.state.notes.get(note_id)
  86. if note is None:
  87. raise HTTPException(status_code=404, detail="Note not found")
  88. return note
  89. @app.get("/health", tags=["起步"])
  90. async def health():
  91. return {"status": "ok"}
  92. @app.get("/notes", response_model=list[NoteOut], tags=["笔记 CRUD"])
  93. async def list_notes(
  94. q: Annotated[str | None, Query(max_length=120)] = None,
  95. limit: Annotated[int, Query(ge=1, le=100)] = 10,
  96. pinned: bool | None = None,
  97. ):
  98. notes = list(app.state.notes.values())
  99. if q:
  100. query = q.casefold()
  101. notes = [note for note in notes if query in note.title.casefold() or query in note.content.casefold()]
  102. if pinned is not None:
  103. notes = [note for note in notes if note.pinned == pinned]
  104. return notes[:limit]
  105. @app.post("/notes", response_model=NoteOut, status_code=201, tags=["笔记 CRUD"])
  106. async def create_note(body: NoteCreate, response: Response):
  107. note = NoteOut(id=app.state.next_id, **body.model_dump())
  108. app.state.notes[note.id] = note
  109. app.state.next_id += 1
  110. response.headers["Location"] = f"/notes/{note.id}"
  111. return note
  112. @app.get("/notes/{note_id}", response_model=NoteOut, responses={404: {"model": ErrorResponse}}, tags=["笔记 CRUD"])
  113. async def get_note(note_id: Annotated[int, Path(ge=1)]):
  114. return find_note(note_id)
  115. @app.put("/notes/{note_id}", response_model=NoteOut, responses={404: {"model": ErrorResponse}}, tags=["笔记 CRUD"])
  116. async def replace_note(note_id: Annotated[int, Path(ge=1)], body: NoteReplace):
  117. find_note(note_id)
  118. note = NoteOut(id=note_id, **body.model_dump())
  119. app.state.notes[note_id] = note
  120. return note
  121. @app.delete("/notes/{note_id}", status_code=204, response_class=Response, responses={404: {"model": ErrorResponse}}, tags=["笔记 CRUD"])
  122. async def delete_note(note_id: Annotated[int, Path(ge=1)]):
  123. find_note(note_id)
  124. del app.state.notes[note_id]
  125. return Response(status_code=204)
  126. async def require_api_key(x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None):
  127. expected = os.getenv("DEMO_API_KEY")
  128. if not expected:
  129. raise HTTPException(status_code=503, detail="Set DEMO_API_KEY before using /private")
  130. if x_api_key is None or not secrets.compare_digest(x_api_key.encode(), expected.encode()):
  131. raise HTTPException(
  132. status_code=401,
  133. detail="Invalid or missing API key",
  134. headers={"WWW-Authenticate": "APIKey"},
  135. )
  136. @app.get(
  137. "/private",
  138. dependencies=[Depends(require_api_key)],
  139. responses={401: {"model": ErrorResponse}, 503: {"model": ErrorResponse}},
  140. tags=["依赖与请求头"],
  141. )
  142. async def private_example():
  143. return {"message": "API key accepted"}
  144. @app.get("/slow", tags=["异步等待"])
  145. async def slow(delay: Annotated[float, Query(ge=0, le=5)] = 1):
  146. await asyncio.sleep(delay)
  147. return {"delay": delay, "status": "done"}
  148. @app.get(
  149. "/upstream-demo",
  150. responses={502: {"model": ErrorResponse}, 504: {"model": ErrorResponse}},
  151. tags=["调用其他 HTTP 接口"],
  152. )
  153. async def upstream_demo(
  154. request: Request,
  155. delay: Annotated[float, Query(ge=0, le=5)] = 1,
  156. ):
  157. # 固定调用本机的 /slow,不接受用户提供的出站 URL。
  158. try:
  159. response = await request.app.state.http.get("/slow", params={"delay": delay})
  160. response.raise_for_status()
  161. return {"upstream": response.json()}
  162. except httpx.TimeoutException as exc:
  163. raise HTTPException(504, "上游响应超时") from exc
  164. except httpx.HTTPStatusError as exc:
  165. raise HTTPException(502, "上游返回非成功状态") from exc
  166. except httpx.RequestError as exc:
  167. raise HTTPException(502, "无法连接上游服务") from exc
  168. @app.post("/generate", tags=["普通响应与流式响应"])
  169. async def generate(payload: PromptIn):
  170. # 确定性输出,无模型 SDK 或模型密钥;等待三步完成后一次返回。
  171. parts = []
  172. for text in ["收到:", payload.prompt, "。处理完成。"]:
  173. await asyncio.sleep(0.4)
  174. parts.append(text)
  175. return {"text": "".join(parts)}
  176. def encode_sse(event: str, data: dict) -> str:
  177. payload = json.dumps(data, ensure_ascii=False)
  178. return f"event: {event}\ndata: {payload}\n\n"
  179. async def token_events(request: Request, prompt: str):
  180. try:
  181. for text in ["收到:", prompt, "。处理完成。"]:
  182. await asyncio.sleep(0.4)
  183. if await request.is_disconnected():
  184. return
  185. yield encode_sse("token", {"text": text})
  186. yield encode_sse("done", {})
  187. finally:
  188. print("stream closed")
  189. @app.get("/stream", tags=["普通响应与流式响应"])
  190. async def stream(
  191. request: Request,
  192. prompt: Annotated[str, Query(min_length=1, max_length=200)] = "hello",
  193. ):
  194. return StreamingResponse(
  195. token_events(request, prompt),
  196. media_type="text/event-stream",
  197. headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
  198. )
  199. @app.websocket("/ws")
  200. async def websocket_echo(websocket: WebSocket):
  201. await websocket.accept()
  202. try:
  203. while True:
  204. message = await websocket.receive_text()
  205. if message == "ping":
  206. await websocket.send_json({"type": "pong"})
  207. else:
  208. await websocket.send_json({"type": "echo", "text": message})
  209. except WebSocketDisconnect:
  210. return