路由函数
路由实现
基本流程
路由函数在fastapi.routing.APIRouter.add_api_route注册的时候,会经由 fastapi.routing.APIRoute 实例化的时候编译路径, 以便后续匹配
get_route_handler 基于闭包返回了处理请求和返回响应的处理函数。
在get_route_handler中提取了请求参数,并将请求传递到solve_dependencies, 处理依赖注入。
最终使用 run_endpoint_function 运行用户函数传入请求和依赖, 返回响应结果
starlette.routing.request_response 包装路由函数, 实现 ASGIApp 协议的调用机制
最终,通过中间件的 ASGI 调用链, ASGIApp 被传递到fastapi.routing.APIRoute.app 执行用户代码
fastapi.routing.APIRouter.add_api_routefastapi.routing.APIRoute.__init__starlette.routing.compile_pathstarlette.routing.request_responsefastapi.routing.APIRoute.get_route_handler
fastapi.routing.APIRoute.app
注册/编译
starlette.routing.compile_path 实现路由编译, 并返回编译后的三元组
路径参数匹配
通过正则匹配类似{id:int}的字符串,获取路径参数, 提取参数名称和类型
查看代码示例
PARAM_REGEX = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
for match in PARAM_REGEX.finditer(path):
param_name, convertor_type = match.groups("str")
convertor_type = convertor_type.lstrip(":")
assert convertor_type in CONVERTOR_TYPES, (
f"Unknown path convertor '{convertor_type}'"
)
convertor = CONVERTOR_TYPES[convertor_type]
path_regex += re.escape(path[idx : match.start()])
path_regex += f"(?P<{param_name}>{convertor.regex})"
path_format += path[idx : match.start()]
path_format += "{%s}" % param_name
if param_name in param_convertors:
duplicated_params.add(param_name)
param_convertors[param_name] = convertor
idx = match.end()
路径参数类型转换
成功匹配到类似{username:str}的路径参数表达式后, 生成命名捕获组并分配一个Convertor, 其中Convertor 包含一个对应参数类型的正则表达式以及转换处理函数
查看代码示例
T = TypeVar("T")
class Convertor(Generic[T]):
regex: ClassVar[str] = ""
def convert(self, value: str) -> T:
raise NotImplementedError() # pragma: no cover
def to_string(self, value: T) -> str:
raise NotImplementedError() # pragma: no cover
class StringConvertor(Convertor[str]):
regex = "[^/]+"
def convert(self, value: str) -> str:
return value
def to_string(self, value: str) -> str:
value = str(value)
assert "/" not in value, "May not contain path separators"
assert value, "Must not be empty"
return value
class IntegerConvertor(Convertor[int]):
regex = "[0-9]+"
def convert(self, value: str) -> int:
return int(value)
def to_string(self, value: int) -> str:
value = int(value)
assert value >= 0, "Negative integers are not supported"
return str(value)
编译存储
路由参数编译后返回三元组,用于后续匹配规则
path_regex是对路径字符处理后的命名捕获组format是移除路径参数中参数类型后的字符串convertors是对命名捕获后的遍历进行有效值验证和类型转换的转换器
查看代码示例
self.path_regex, self.path_format, self.param_convertors = compile_path(path)
def compile_path(
path: str,
) -> typing.Tuple[typing.Pattern, str, typing.Dict[str, Convertor]]:
"""
Given a path string, like: "/{username:str}", return a three-tuple
of (regex, format, {param_name:convertor}).
regex: "/(?P<username>[^/]+)"
format: "/{username}"
convertors: {"username": StringConvertor()}
"""
匹配
- 首轮匹配
- 次轮匹配
- 匹配失败
首轮匹配
starlette.routing.Router.app 内触发路由匹配,根据匹配结果分为 FULL和PARTIAL
对于第一个全匹配的路由,则直接进入处理流程
遍历完路由后,如果没有全匹配的路由,则进入对一个部分匹配的路由函数
查看代码
for route in self.routes:
# Determine if any route matches the incoming scope,
# and hand over to the matching route if found.
match, child_scope = route.matches(scope)
if match == Match.FULL:
scope.update(child_scope)
await route.handle(scope, receive, send)
return
elif match == Match.PARTIAL and partial is None:
partial = route
partial_scope = child_scope
次轮匹配
如果在首轮遍历未能找到全匹配或部分匹配的路由函数, 则会根据设置,添加或移除尾斜杠/再进行第二轮匹配
次轮匹配对于第一个有效的匹配结果, 无论是完全匹配还是部分匹配, 都会以此返回重定向响应结果
查看代码
route_path = get_route_path(scope)
if scope["type"] == "http" and self.redirect_slashes and route_path != "/":
redirect_scope = dict(scope)
if route_path.endswith("/"):
redirect_scope["path"] = redirect_scope["path"].rstrip("/")
else:
redirect_scope["path"] = redirect_scope["path"] + "/"
for route in self.routes:
match, child_scope = route.matches(redirect_scope)
if match != Match.NONE:
redirect_url = URL(scope=redirect_scope)
response = RedirectResponse(url=str(redirect_url))
await response(scope, receive, send)
return
await self.default(scope, receive, send)
匹配规则
匹配的具体行为由 starlette.routing.Route.matches 进行
根据编译时存储的命名捕获组, 对请求路径进行正则匹配和参数提取校验.
如果没有成功的正则匹配,则返回匹配失败。
如果参数校验通过,且当前请求的方法在路由函数的注册方法内,则认为完全匹配。
如果方法未注册,则返回部分匹配。
graph LR
A[路由匹配开始] --> B{命名捕获组正则匹配成功?}
B -->|YES| C{命名参数提取校验是否成功?}
B -->|NO| D[Match.NONE]
C -->|YES| F{请求方法是否允许?}
C -->|NO| E[AssertionError]
F -->|YES| G[Match.FULL]
F -->|NO| H[Match.PARTIAL] 查看代码
def matches(self, scope: Scope) -> tuple[Match, Scope]:
path_params: dict[str, Any]
if scope["type"] == "http":
route_path = get_route_path(scope)
match = self.path_regex.match(route_path)
if match:
matched_params = match.groupdict()
for key, value in matched_params.items():
matched_params[key] = self.param_convertors[key].convert(value)
path_params = dict(scope.get("path_params", {}))
path_params.update(matched_params)
child_scope = {"endpoint": self.endpoint, "path_params": path_params}
if self.methods and scope["method"] not in self.methods:
return Match.PARTIAL, child_scope
else:
return Match.FULL, child_scope
return Match.NONE, {}
匹配失败
对于两轮匹配失败后,进入not_found
返回 404
查看代码
async def not_found(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "websocket":
websocket_close = WebSocketClose()
await websocket_close(scope, receive, send)
return
# If we're running inside a starlette application then raise an
# exception, so that the configurable exception handler can deal with
# returning the response. For plain ASGI apps, just return the response.
if "app" in scope:
raise HTTPException(status_code=404)
else:
response = PlainTextResponse("Not Found", status_code=404)
await response(scope, receive, send)
路由函数预处理
完全匹配与部分匹配的区别
在路由匹配规则里,根据路由函数是否允许当前请求的方法,分为 Match.FULL 和 Match.PARTIAL
实际上,这两种情况在处理上没有实际的区别。
Match.PARTIAL 是 Match.FULL 没有成功的一种回退,最终都会在Route.handle 里对方法再次判断,返回 405 响应
查看代码
# starlette.routing.Router.app
for route in self.routes:
# Determine if any route matches the incoming scope,
# and hand over to the matching route if found.
match, child_scope = route.matches(scope)
if match == Match.FULL:
scope.update(child_scope)
await route.handle(scope, receive, send)
return
elif match == Match.PARTIAL and partial is None:
partial = route
partial_scope = child_scope
if partial is not None:
# Handle partial matches. These are cases where an endpoint is
# able to handle the request, but is not a preferred option.
# We use this in particular to deal with "405 Method Not Allowed".
scope.update(partial_scope)
await partial.handle(scope, receive, send)
return
# starlette.routing.Route.handle
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
if self.methods and scope["method"] not in self.methods:
headers = {"Allow": ", ".join(self.methods)}
if "app" in scope:
raise HTTPException(status_code=405, headers=headers)
else:
response = PlainTextResponse(
"Method Not Allowed", status_code=405, headers=headers
)
await response(scope, receive, send)
else:
await self.app(scope, receive, send)
函数注释
让函数文档注释的一部分出现在 openapi 的文档说明中,而不是全部。
FastAPI 是如何从函数注释中提取文档部分的
fastapi.openapi.utils.get_openapi_path 读取了每个路由函数对应 openapi 的参数
从fastapi.openapi.utils.get_openapi_operation_metadata可知,API 的 description 对应 APIRoute.description。
而从fastapi.routing.APIRoute 源码可知, OpenAPI 默认会使用函数的__doc__的第一个\f字符的前缀部分, 并且使用了 inspect.cleandoc 格式化文档
查看完整代码
get_openapi_pathoperation = get_openapi_operation_metadata(
route=route, method=method, operation_ids=operation_ids
)
def get_openapi_operation_metadata(
*, route: routing.APIRoute, method: str, operation_ids: Set[str]
) -> Dict[str, Any]:
operation: Dict[str, Any] = {}
if route.tags:
operation["tags"] = route.tags
operation["summary"] = generate_operation_summary(route=route, method=method)
if route.description:
operation["description"] = route.description
operation_id = route.operation_id or route.unique_id
if operation_id in operation_ids:
message = (
f"Duplicate Operation ID {operation_id} for function "
+ f"{route.endpoint.__name__}"
)
file_name = getattr(route.endpoint, "__globals__", {}).get("__file__")
if file_name:
message += f" at {file_name}"
warnings.warn(message, stacklevel=1)
operation_ids.add(operation_id)
operation["operationId"] = operation_id
if route.deprecated:
operation["deprecated"] = route.deprecated
return operation
self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "")
# if a "form feed" character (page break) is found in the description text,
# truncate description text to the content preceding the first "form feed"
self.description = self.description.split("\f")[0].strip()
函数注释的正确姿势
import uvicorn
from fastapi import FastAPI
app = FastAPI()
@app.get("/v1")
def v1():
"""应该出现在OpenAPI 内
\f不应该出现在 OpenAPI 内
"""
return ""
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
如代码所示,其中 v1 在文档中会看见不符合预期的内容
函数注释分段失效的原因
import inspect
def v1():
"""应该出现在OpenAPI 内
\f不应该出现在 OpenAPI 内
"""
return ""
doc = v1.__doc__
assert doc
assert "\f" in doc
assert "\f" not in inspect.cleandoc(doc)
如上图所示参数代码, inspect.cleandoc 会导致行首的\f被清除
所以处理方式应该修改如下
self.description = description or inspect.cleandoc(self.endpoint.__doc__.split("\f")[0]).strip() if self.endpoint.__doc__ else ""
回顾
- 路由根据添加顺序存储在列表,匹配时在第一个全匹配路由函数停止,否则会遍历.
- 路由在添加时会被编译成命名捕获组进行存储, 在匹配时基于命名捕获组提取变量,并基于编译存储的转换器验证和转换值和类型