Add public image optimization endpoint

This commit is contained in:
2026-07-02 19:36:47 -07:00
parent 9a0a2b1da7
commit 518a5380d9
2 changed files with 347 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import type { Context } from "hono";
import { resolveCurrentUser, type CurrentUser } from "./auth-session"; import { resolveCurrentUser, type CurrentUser } from "./auth-session";
import type { Env } from "./env"; import type { Env } from "./env";
import { problemResponse, validationProblemHook } from "./problem"; import { problemResponse, validationProblemHook } from "./problem";
@@ -45,6 +46,14 @@ import {
type AppEnv = { Bindings: Env; Variables: { user: CurrentUser } }; type AppEnv = { Bindings: Env; Variables: { user: CurrentUser } };
const OPENAPI_TAGS = [
{ name: "Attachments", description: "Authenticated attachment metadata, content upload, lifecycle, search, and download APIs." },
{ name: "Categories", description: "Authenticated user-scoped attachment category management APIs." },
{ name: "Tags", description: "Authenticated user-scoped attachment tag management APIs." },
{ name: "Public files", description: "Public file-serving APIs for attachments marked public or shared." },
{ name: "Images", description: "Public remote image optimization proxy APIs." },
];
function errRes(description: string) { function errRes(description: string) {
return { description, content: { "application/json": { schema: ProblemSchema } } }; return { description, content: { "application/json": { schema: ProblemSchema } } };
} }
@@ -60,6 +69,46 @@ const SearchQuery = z.object({
status: z.string().optional(), status: z.string().optional(),
}); });
const IMAGE_PATH_EXT = /\.(jpe?g|png|gif|webp)$/i;
const IMAGE_FIT_OPTIONS = ["scale-down", "scale-up", "contain", "cover", "crop", "pad", "squeeze"] as const;
const IMAGE_FORMAT_OPTIONS = ["avif", "webp", "jpeg", "jpg", "png", "gif", "auto"] as const;
const IMAGE_FIT_VALUES = new Set<string>(IMAGE_FIT_OPTIONS);
const ImageQuery = z
.object({
url: z.string().optional().openapi({
description: "Remote image URL. Only HTTP(S) JPEG, PNG, GIF, and WebP URLs are accepted.",
param: { required: true },
}),
width: z.string().optional().openapi({ type: "integer", minimum: 1, description: "Target image width in pixels." }),
height: z.string().optional().openapi({ type: "integer", minimum: 1, description: "Target image height in pixels." }),
quality: z.string().optional().openapi({ type: "integer", minimum: 1, maximum: 100, description: "Output quality from 1 to 100." }),
format: z.string().optional().openapi({
enum: [...IMAGE_FORMAT_OPTIONS],
description: "Requested output format. Use auto to negotiate AVIF/WebP from the request Accept header.",
}),
fit: z.string().optional().openapi({
enum: [...IMAGE_FIT_OPTIONS],
description: "Resize mode used by Cloudflare image transformations.",
}),
bg: z.string().optional().openapi({ description: "Background color used for transparent images, for example white or #ffffff." }),
})
.openapi("ImageQuery");
function parsePositiveIntParam(value: string | undefined, label: string): { value?: number; error?: string } {
if (value === undefined) {
return {};
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
return { error: `Invalid ${label}. Must be a positive integer` };
}
return { value: parsed };
}
function imageError(c: Context<AppEnv>, error: string) {
return c.json({ error }, 400);
}
function pagination(page: number | undefined, pageSize: number | undefined) { function pagination(page: number | undefined, pageSize: number | undefined) {
const p = Math.max(1, page ?? 1); const p = Math.max(1, page ?? 1);
const ps = Math.min(100, Math.max(1, pageSize ?? 20)); const ps = Math.min(100, Math.max(1, pageSize ?? 20));
@@ -71,7 +120,7 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
app.onError((error) => problemResponse(error)); app.onError((error) => problemResponse(error));
// /api/* 先解析登录用户(401 先于 zod 422);/healthz、/openapi.json、/files 保持公开 // /api/* 先解析登录用户(401 先于 zod 422);/healthz、/openapi.json、/files、/image 保持公开
app.use("/api/*", async (c, next) => { app.use("/api/*", async (c, next) => {
const user = await resolveCurrentUser(c.req.raw, c.env); const user = await resolveCurrentUser(c.req.raw, c.env);
c.set("user", user); c.set("user", user);
@@ -80,10 +129,114 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
app.get("/healthz", (c) => c.json({ ok: true, service: "cfw-attachment" })); app.get("/healthz", (c) => c.json({ ok: true, service: "cfw-attachment" }));
app.openapi(
createRoute({
method: "get",
path: "/image",
tags: ["Images"],
summary: "Optimize a remote image",
description: "Public proxy that applies Cloudflare image transformation options to a remote JPEG, PNG, GIF, or WebP URL.",
request: { query: ImageQuery },
responses: {
200: { description: "Optimized image bytes" },
400: { description: "Invalid image proxy request" },
500: { description: "Image processing failed" },
},
}),
async (c) => {
try {
const { url, width, height, quality, format, fit, bg } = c.req.query();
if (!url) {
return imageError(c, "Missing required parameter: url");
}
let imageUrl: URL;
try {
imageUrl = new URL(url);
} catch {
return imageError(c, "Invalid image URL");
}
if (imageUrl.protocol !== "http:" && imageUrl.protocol !== "https:") {
return imageError(c, "Invalid image URL protocol. Only HTTP and HTTPS are supported");
}
if (!IMAGE_PATH_EXT.test(imageUrl.pathname)) {
return imageError(c, "Unsupported image format. Only JPEG, PNG, GIF, WebP are supported");
}
const cfImage: RequestInitCfPropertiesImage = {};
const numericParams = [
["width", width],
["height", height],
["quality", quality],
] as const;
for (const [label, value] of numericParams) {
const parsed = parsePositiveIntParam(value, label);
if (parsed.error) {
return imageError(c, parsed.error);
}
if (parsed.value !== undefined) {
cfImage[label] = parsed.value;
}
}
if (fit) {
if (!IMAGE_FIT_VALUES.has(fit)) {
return imageError(c, "Invalid fit. Supported values: scale-down, scale-up, contain, cover, crop, pad, squeeze");
}
cfImage.fit = fit as RequestInitCfPropertiesImage["fit"];
}
if (bg) cfImage.background = bg;
if (format === "avif") {
cfImage.format = "avif";
} else if (format === "webp") {
cfImage.format = "webp";
} else if (format === "jpeg" || format === "jpg") {
cfImage.format = "jpeg";
} else if (format === "png") {
cfImage.format = "png";
} else if (format === "auto") {
const accept = c.req.header("Accept");
if (accept) {
if (/image\/avif/.test(accept)) {
cfImage.format = "avif";
} else if (/image\/webp/.test(accept)) {
cfImage.format = "webp";
}
}
} else if (format && format !== "gif") {
return imageError(c, "Unsupported output format. Supported values: avif, webp, jpeg, jpg, png, gif, auto");
}
const options: RequestInit = {
headers: {
Accept: "image/avif, image/webp, image/*",
},
};
if (Object.keys(cfImage).length > 0) {
options.cf = { image: cfImage };
}
return await fetch(imageUrl.toString(), options);
} catch (error) {
console.error("Image processing error:", error);
return c.json({ error: error instanceof Error ? error.message : "Image processing failed" }, 500);
}
},
);
app.openapi( app.openapi(
createRoute({ createRoute({
method: "get", method: "get",
path: "/files/{id}", path: "/files/{id}",
tags: ["Public files"],
summary: "Serve a public attachment file",
description: "Streams bytes for an available attachment whose visibility allows public file access.",
request: { params: IdParams }, request: { params: IdParams },
responses: { responses: {
200: { description: "File bytes streamed with the stored content-type" }, 200: { description: "File bytes streamed with the stored content-type" },
@@ -100,6 +253,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "get", method: "get",
path: "/api/attachments/search", path: "/api/attachments/search",
tags: ["Attachments"],
summary: "Search attachments",
description: "Returns a paginated list of the current user's attachments, optionally filtered by keyword, category, tag, or status.",
request: { query: SearchQuery }, request: { query: SearchQuery },
responses: { 200: { description: "Paginated attachments", content: { "application/json": { schema: PaginatedAttachmentsSchema } } }, 401: errRes("Authentication required") }, responses: { 200: { description: "Paginated attachments", content: { "application/json": { schema: PaginatedAttachmentsSchema } } }, 401: errRes("Authentication required") },
}), }),
@@ -125,6 +281,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "post", method: "post",
path: "/api/attachments", path: "/api/attachments",
tags: ["Attachments"],
summary: "Create attachment metadata",
description: "Creates an upload-pending attachment record for the current user before content is uploaded.",
request: { body: { content: { "application/json": { schema: CreateAttachmentSchema } }, required: true } }, request: { body: { content: { "application/json": { schema: CreateAttachmentSchema } }, required: true } },
responses: { responses: {
201: { description: "Attachment created", content: { "application/json": { schema: AttachmentResponseSchema } } }, 201: { description: "Attachment created", content: { "application/json": { schema: AttachmentResponseSchema } } },
@@ -143,6 +302,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "get", method: "get",
path: "/api/attachments/{id}", path: "/api/attachments/{id}",
tags: ["Attachments"],
summary: "Get attachment detail",
description: "Reads one attachment belonging to the current user, including metadata, category, tags, and computed public URL when available.",
request: { params: IdParams }, request: { params: IdParams },
responses: { responses: {
200: { description: "Attachment detail", content: { "application/json": { schema: AttachmentResponseSchema } } }, 200: { description: "Attachment detail", content: { "application/json": { schema: AttachmentResponseSchema } } },
@@ -161,6 +323,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "patch", method: "patch",
path: "/api/attachments/{id}", path: "/api/attachments/{id}",
tags: ["Attachments"],
summary: "Update attachment metadata",
description: "Updates mutable metadata for one attachment, including filename, visibility, description, category, and tag assignments.",
request: { request: {
params: IdParams, params: IdParams,
body: { content: { "application/json": { schema: UpdateAttachmentSchema } }, required: true }, body: { content: { "application/json": { schema: UpdateAttachmentSchema } }, required: true },
@@ -183,6 +348,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "put", method: "put",
path: "/api/attachments/{id}/content", path: "/api/attachments/{id}/content",
tags: ["Attachments"],
summary: "Upload attachment content",
description: "Uploads the full file body for an upload-pending attachment through the Worker into R2.",
request: { params: IdParams }, request: { params: IdParams },
responses: { responses: {
200: { description: "Content uploaded", content: { "application/json": { schema: AttachmentResponseSchema } } }, 200: { description: "Content uploaded", content: { "application/json": { schema: AttachmentResponseSchema } } },
@@ -203,6 +371,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "post", method: "post",
path: "/api/attachments/{id}/multipart", path: "/api/attachments/{id}/multipart",
tags: ["Attachments"],
summary: "Initialize multipart upload",
description: "Starts an R2 multipart upload session for an upload-pending attachment owned by the current user.",
request: { params: IdParams }, request: { params: IdParams },
responses: { responses: {
200: { description: "Multipart upload initialized", content: { "application/json": { schema: MultipartInitResponseSchema } } }, 200: { description: "Multipart upload initialized", content: { "application/json": { schema: MultipartInitResponseSchema } } },
@@ -222,6 +393,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "put", method: "put",
path: "/api/attachments/{id}/multipart/parts/{partNumber}", path: "/api/attachments/{id}/multipart/parts/{partNumber}",
tags: ["Attachments"],
summary: "Upload multipart part",
description: "Uploads or replaces one numbered part in the active multipart upload session for an attachment.",
request: { params: PartParams }, request: { params: PartParams },
responses: { responses: {
200: { description: "Part uploaded", content: { "application/json": { schema: UploadPartResponseSchema } } }, 200: { description: "Part uploaded", content: { "application/json": { schema: UploadPartResponseSchema } } },
@@ -242,6 +416,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "delete", method: "delete",
path: "/api/attachments/{id}/multipart", path: "/api/attachments/{id}/multipart",
tags: ["Attachments"],
summary: "Abort multipart upload",
description: "Aborts the active multipart upload session for an attachment and returns it to single-shot upload eligibility.",
request: { params: IdParams }, request: { params: IdParams },
responses: { responses: {
204: { description: "Multipart upload aborted" }, 204: { description: "Multipart upload aborted" },
@@ -260,6 +437,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "post", method: "post",
path: "/api/attachments/{id}/finalize", path: "/api/attachments/{id}/finalize",
tags: ["Attachments"],
summary: "Finalize attachment upload",
description: "Validates uploaded content or multipart state and marks the attachment available for download.",
request: { params: IdParams }, request: { params: IdParams },
responses: { responses: {
200: { description: "Attachment finalized", content: { "application/json": { schema: AttachmentResponseSchema } } }, 200: { description: "Attachment finalized", content: { "application/json": { schema: AttachmentResponseSchema } } },
@@ -280,6 +460,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "get", method: "get",
path: "/api/attachments/{id}/download", path: "/api/attachments/{id}/download",
tags: ["Attachments"],
summary: "Download attachment file",
description: "Streams file bytes for an available attachment owned by the current user.",
request: { params: IdParams }, request: { params: IdParams },
responses: { responses: {
200: { description: "File bytes" }, 200: { description: "File bytes" },
@@ -299,6 +482,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "delete", method: "delete",
path: "/api/attachments/{id}", path: "/api/attachments/{id}",
tags: ["Attachments"],
summary: "Delete attachment",
description: "Soft-deletes one attachment for the current user and attempts to delete its R2 object.",
request: { params: IdParams }, request: { params: IdParams },
responses: { responses: {
204: { description: "Attachment deleted" }, 204: { description: "Attachment deleted" },
@@ -318,6 +504,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "post", method: "post",
path: "/api/attachment-categories", path: "/api/attachment-categories",
tags: ["Categories"],
summary: "Create attachment category",
description: "Creates a user-scoped category that can be assigned to attachments.",
request: { body: { content: { "application/json": { schema: CategoryCreateSchema } }, required: true } }, request: { body: { content: { "application/json": { schema: CategoryCreateSchema } }, required: true } },
responses: { responses: {
201: { description: "Category created", content: { "application/json": { schema: CategoryResponseSchema } } }, 201: { description: "Category created", content: { "application/json": { schema: CategoryResponseSchema } } },
@@ -336,6 +525,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "get", method: "get",
path: "/api/attachment-categories", path: "/api/attachment-categories",
tags: ["Categories"],
summary: "List attachment categories",
description: "Lists all categories owned by the current user.",
responses: { responses: {
200: { description: "Categories", content: { "application/json": { schema: CategoriesResponseSchema } } }, 200: { description: "Categories", content: { "application/json": { schema: CategoriesResponseSchema } } },
401: errRes("Authentication required"), 401: errRes("Authentication required"),
@@ -351,6 +543,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "patch", method: "patch",
path: "/api/attachment-categories/{id}", path: "/api/attachment-categories/{id}",
tags: ["Categories"],
summary: "Update attachment category",
description: "Updates the name or slug for one category owned by the current user.",
request: { params: IdParams, body: { content: { "application/json": { schema: CategoryUpdateSchema } }, required: true } }, request: { params: IdParams, body: { content: { "application/json": { schema: CategoryUpdateSchema } }, required: true } },
responses: { responses: {
200: { description: "Category updated", content: { "application/json": { schema: CategoryResponseSchema } } }, 200: { description: "Category updated", content: { "application/json": { schema: CategoryResponseSchema } } },
@@ -370,6 +565,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "delete", method: "delete",
path: "/api/attachment-categories/{id}", path: "/api/attachment-categories/{id}",
tags: ["Categories"],
summary: "Delete attachment category",
description: "Deletes one category owned by the current user and clears it from attachments that reference it.",
request: { params: IdParams }, request: { params: IdParams },
responses: { 204: { description: "Category deleted" }, 401: errRes("Authentication required"), 404: errRes("Category not found") }, responses: { 204: { description: "Category deleted" }, 401: errRes("Authentication required"), 404: errRes("Category not found") },
}), }),
@@ -385,6 +583,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "post", method: "post",
path: "/api/attachment-tags", path: "/api/attachment-tags",
tags: ["Tags"],
summary: "Create attachment tag",
description: "Creates a user-scoped tag that can be assigned to attachments.",
request: { body: { content: { "application/json": { schema: TagCreateSchema } }, required: true } }, request: { body: { content: { "application/json": { schema: TagCreateSchema } }, required: true } },
responses: { responses: {
201: { description: "Tag created", content: { "application/json": { schema: TagResponseSchema } } }, 201: { description: "Tag created", content: { "application/json": { schema: TagResponseSchema } } },
@@ -403,6 +604,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "get", method: "get",
path: "/api/attachment-tags", path: "/api/attachment-tags",
tags: ["Tags"],
summary: "List attachment tags",
description: "Lists all tags owned by the current user.",
responses: { 200: { description: "Tags", content: { "application/json": { schema: TagsResponseSchema } } }, 401: errRes("Authentication required") }, responses: { 200: { description: "Tags", content: { "application/json": { schema: TagsResponseSchema } } }, 401: errRes("Authentication required") },
}), }),
async (c) => { async (c) => {
@@ -415,6 +619,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "patch", method: "patch",
path: "/api/attachment-tags/{id}", path: "/api/attachment-tags/{id}",
tags: ["Tags"],
summary: "Update attachment tag",
description: "Updates the name or slug for one tag owned by the current user.",
request: { params: IdParams, body: { content: { "application/json": { schema: TagUpdateSchema } }, required: true } }, request: { params: IdParams, body: { content: { "application/json": { schema: TagUpdateSchema } }, required: true } },
responses: { responses: {
200: { description: "Tag updated", content: { "application/json": { schema: TagResponseSchema } } }, 200: { description: "Tag updated", content: { "application/json": { schema: TagResponseSchema } } },
@@ -434,6 +641,9 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
createRoute({ createRoute({
method: "delete", method: "delete",
path: "/api/attachment-tags/{id}", path: "/api/attachment-tags/{id}",
tags: ["Tags"],
summary: "Delete attachment tag",
description: "Deletes one tag owned by the current user and removes it from attachments that reference it.",
request: { params: IdParams }, request: { params: IdParams },
responses: { 204: { description: "Tag deleted" }, 401: errRes("Authentication required"), 404: errRes("Tag not found") }, responses: { 204: { description: "Tag deleted" }, 401: errRes("Authentication required"), 404: errRes("Tag not found") },
}), }),
@@ -451,6 +661,7 @@ export function createRoutes(): OpenAPIHono<AppEnv> {
version: "0.1.0", version: "0.1.0",
description: "Attachment management service (metadata, single-shot & multipart upload, public files).", description: "Attachment management service (metadata, single-shot & multipart upload, public files).",
}, },
tags: OPENAPI_TAGS,
servers: [{ url: c.env.PUBLIC_BASE_URL ?? "https://cfw-attachment.bowong.cc", description: "Production" }], servers: [{ url: c.env.PUBLIC_BASE_URL ?? "https://cfw-attachment.bowong.cc", description: "Production" }],
})); }));

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import app from "../src/index"; import app from "../src/index";
import { makeEnv, sharedStores } from "./fakes"; import { makeEnv, sharedStores } from "./fakes";
@@ -35,6 +35,69 @@ async function createAttachment(
} }
describe("cfw-attachment worker (core flow)", () => { describe("cfw-attachment worker (core flow)", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("proxies /image without a session and forwards Cloudflare image options", async () => {
const stores = sharedStores();
const env = makeEnv(stores, null);
const origin = "https://cdn.example.com/photo.jpg";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("image-bytes", { status: 200, headers: { "content-type": "image/webp" } }),
);
const response = await call(
"GET",
`/image?url=${encodeURIComponent(origin)}&width=320&height=200&quality=70&fit=cover&format=auto&bg=white`,
env,
{ headers: { Accept: "image/webp" } },
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/webp");
expect(await response.text()).toBe("image-bytes");
expect(env.AUTH.fetch).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toBe(origin);
expect(fetchSpy.mock.calls[0][1]).toEqual({
headers: { Accept: "image/avif, image/webp, image/*" },
cf: {
image: {
width: 320,
height: 200,
quality: 70,
fit: "cover",
format: "webp",
background: "white",
},
},
});
});
it("returns 400 for invalid /image requests before fetching upstream", async () => {
const stores = sharedStores();
const env = makeEnv(stores, null);
const fetchSpy = vi.spyOn(globalThis, "fetch");
const missingUrl = await call("GET", "/image", env);
expect(missingUrl.status).toBe(400);
expect(await missingUrl.json()).toEqual({ error: "Missing required parameter: url" });
const unsupported = await call("GET", `/image?url=${encodeURIComponent("https://cdn.example.com/vector.svg")}`, env);
expect(unsupported.status).toBe(400);
expect(await unsupported.json()).toEqual({
error: "Unsupported image format. Only JPEG, PNG, GIF, WebP are supported",
});
const invalidSize = await call("GET", `/image?url=${encodeURIComponent("https://cdn.example.com/photo.png")}&width=0`, env);
expect(invalidSize.status).toBe(400);
expect(await invalidSize.json()).toEqual({ error: "Invalid width. Must be a positive integer" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(env.AUTH.fetch).not.toHaveBeenCalled();
});
it("creates an attachment for the current user and hides it from another user", async () => { it("creates an attachment for the current user and hides it from another user", async () => {
const stores = sharedStores(); const stores = sharedStores();
const userA = makeEnv(stores, "user_a"); const userA = makeEnv(stores, "user_a");
@@ -153,14 +216,84 @@ describe("cfw-attachment worker (core flow)", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
const document = (await response.json()) as { const document = (await response.json()) as {
openapi: string; openapi: string;
paths: Record<string, unknown>; tags?: Array<{ name: string; description?: string }>;
paths: Record<
string,
Record<
string,
{
tags?: string[];
summary?: string;
description?: string;
parameters?: Array<{
name: string;
in: string;
required?: boolean;
schema?: { type?: string; enum?: string[] };
}>;
}
>
>;
components?: { schemas?: Record<string, unknown> }; components?: { schemas?: Record<string, unknown> };
}; };
expect(document.openapi).toBe("3.1.0"); expect(document.openapi).toBe("3.1.0");
expect(document.tags).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Attachments", description: expect.any(String) }),
expect.objectContaining({ name: "Categories", description: expect.any(String) }),
expect.objectContaining({ name: "Tags", description: expect.any(String) }),
expect.objectContaining({ name: "Public files", description: expect.any(String) }),
]),
);
expect(document.paths["/api/attachments"]).toBeTruthy(); expect(document.paths["/api/attachments"]).toBeTruthy();
expect(document.paths["/api/attachments/{id}/multipart"]).toBeTruthy(); expect(document.paths["/api/attachments/{id}/multipart"]).toBeTruthy();
expect(document.paths["/api/attachments/{id}/multipart/parts/{partNumber}"]).toBeTruthy(); expect(document.paths["/api/attachments/{id}/multipart/parts/{partNumber}"]).toBeTruthy();
expect(document.paths["/files/{id}"]).toBeTruthy(); expect(document.paths["/files/{id}"]).toBeTruthy();
expect(document.paths["/image"]).toBeTruthy();
expect(document.paths["/api/attachments"].post).toMatchObject({
tags: ["Attachments"],
summary: expect.any(String),
description: expect.any(String),
});
expect(document.paths["/api/attachment-categories"].get).toMatchObject({
tags: ["Categories"],
summary: expect.any(String),
description: expect.any(String),
});
expect(document.paths["/api/attachment-tags"].get).toMatchObject({
tags: ["Tags"],
summary: expect.any(String),
description: expect.any(String),
});
expect(document.paths["/files/{id}"].get).toMatchObject({
tags: ["Public files"],
summary: expect.any(String),
description: expect.any(String),
});
expect(document.paths["/image"].get).toMatchObject({
tags: ["Images"],
summary: expect.any(String),
description: expect.any(String),
});
expect(document.paths["/image"].get.parameters).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "url", in: "query", required: true, schema: expect.objectContaining({ type: "string" }) }),
expect.objectContaining({ name: "width", in: "query", schema: expect.objectContaining({ type: "integer" }) }),
expect.objectContaining({ name: "height", in: "query", schema: expect.objectContaining({ type: "integer" }) }),
expect.objectContaining({ name: "quality", in: "query", schema: expect.objectContaining({ type: "integer" }) }),
expect.objectContaining({
name: "format",
in: "query",
schema: expect.objectContaining({ enum: expect.arrayContaining(["avif", "webp", "jpeg", "jpg", "png", "gif", "auto"]) }),
}),
expect.objectContaining({
name: "fit",
in: "query",
schema: expect.objectContaining({ enum: expect.arrayContaining(["scale-down", "scale-up", "contain", "cover", "crop", "pad", "squeeze"]) }),
}),
expect.objectContaining({ name: "bg", in: "query", schema: expect.objectContaining({ type: "string" }) }),
]),
);
expect(document.components?.schemas?.Attachment).toBeTruthy(); expect(document.components?.schemas?.Attachment).toBeTruthy();
}); });
}); });
@@ -475,4 +608,3 @@ describe("cfw-attachment worker (multipart & public files)", () => {
expect(((await second.json()) as { code: string }).code).toBe("multipart-active"); expect(((await second.json()) as { code: string }).code).toBe("multipart-active");
}); });
}); });