Image_Inversion / job_api_common.py
IdlecloudX's picture
Keep HEAD metadata consistent and update existing API contract tests
b91d49e verified
Raw History Blame Contribute Delete
4.61 kB
"""内存结果共用的 HTTP 下载响应,适用于图像字节和 JSON。"""
import hashlib
import secrets
from email.utils import formatdate
from fastapi import Request
from fastapi.responses import FileResponse, Response
class ResultFileResponse(FileResponse):
"""文件响应结束时释放既有下载租约,包括 416 和连接中断路径。"""
async def __call__(self, scope, receive, send):
"""发送文件并保证后台租约释放动作只执行一次。
Args:
scope: ASGI 请求上下文。
receive: ASGI 请求消息接收函数。
send: ASGI 响应消息发送函数。
"""
release = self.background
self.background = None
if scope["method"] == "HEAD":
# Range 只用于 GET;HEAD 应提供完整结果的长度和校验信息。
scope = {**scope, "headers": [(name, value) for name, value in scope["headers"]
if name.lower() != b"range"]}
try:
await super().__call__(scope, receive, send)
finally:
# FileResponse 的范围错误会提前返回,不会运行通常的 background。
if release is not None:
await release()
def result_bytes_response(request: Request, content: bytes, media_type: str,
modified_at: float) -> Response:
"""返回带校验信息、范围读取和 HEAD 支持的不可变结果。
Args:
request: 当前 GET 或 HEAD 请求。
content: 已在任务清理锁内读出的完整结果,发送期间不再依赖文件。
media_type: 结果的 MIME 类型。
modified_at: 任务完成时间,作为稳定的 Last-Modified。
Returns:
完整结果、206 范围结果或 416 越界响应;HEAD 始终不含响应体。
"""
size = len(content)
etag = '"' + hashlib.sha256(content).hexdigest() + '"'
modified = formatdate(modified_at, usegmt=True)
headers = {
"ETag": etag,
"Last-Modified": modified,
"Accept-Ranges": "bytes",
"Cache-Control": "private, max-age=300",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers": (
"Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified"
),
}
status_code = 200
range_header = request.headers.get("range")
# If-Range 不匹配时发送完整的新结果,避免把两个版本拼成一个文件。
if (request.method == "GET" and range_header
and request.headers.get("if-range", etag) in {etag, modified}):
try:
unit, ranges_text = range_header.split("=", 1)
ranges = []
if unit.strip() != "bytes" or len(ranges_text.split(",")) > 100:
raise ValueError("Unsupported range")
for part in ranges_text.split(","):
start_text, end_text = part.strip().split("-", 1)
if not start_text:
suffix = int(end_text)
if suffix <= 0:
raise ValueError("Empty suffix")
start, end = max(0, size - suffix), size - 1
else:
start = int(start_text)
end = min(int(end_text), size - 1) if end_text else size - 1
if start < 0 or start > end or start >= size:
raise ValueError("Unsatisfiable range")
ranges.append((start, end))
except ValueError:
headers["Content-Range"] = f"bytes */{size}"
return Response(status_code=416, headers=headers)
status_code = 206
if len(ranges) == 1:
start, end = ranges[0]
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
content = content[start:end + 1]
else:
boundary = secrets.token_hex(16)
content = b"".join(
(f"--{boundary}\r\nContent-Type: {media_type}\r\n"
f"Content-Range: bytes {start}-{end}/{size}\r\n\r\n").encode()
+ content[start:end + 1] + b"\r\n" for start, end in ranges
) + f"--{boundary}--\r\n".encode()
media_type = f"multipart/byteranges; boundary={boundary}"
headers["Content-Length"] = str(len(content))
return Response(b"" if request.method == "HEAD" else content,
status_code=status_code, media_type=media_type, headers=headers)