feat: add the openapi docs

This commit is contained in:
2026-08-16 16:43:21 +07:00
parent 92d4da14ed
commit a235040be7
27 changed files with 3558 additions and 2837 deletions
+119
View File
@@ -0,0 +1,119 @@
import { AwsClient } from "aws4fetch";
import { afterEach, describe, expect, it, vi } from "vitest";
import { preflightResponse, withCors } from "../src/cors";
import worker from "../src/index";
import type { Env } from "../src/types";
import { env } from "cloudflare:test";
const ENV = env as unknown as Env;
const ENDPOINT = "https://s3-api.example.com";
const ORIGIN = "http://localhost:5173";
const CTX = { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as unknown as ExecutionContext;
async function signed(path: string, init: RequestInit): Promise<Request> {
const aws = new AwsClient({ accessKeyId: ENV.ACCESS_KEY, secretAccessKey: ENV.SECRET_KEY, region: ENV.REGION, service: "s3" });
const bodyLength = typeof init.body === "string" ? new TextEncoder().encode(init.body).byteLength : init.body instanceof Uint8Array ? init.body.byteLength : undefined;
return aws.sign(`${ENDPOINT}${path}`, {
...init,
headers: { "x-amz-content-sha256": "UNSIGNED-PAYLOAD", ...(bodyLength === undefined ? {} : { "x-amz-decoded-content-length": String(bodyLength) }), ...init.headers },
});
}
function fakeGoogleFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const request = input instanceof Request ? input : new Request(input, init);
const url = new URL(request.url);
if (url.hostname === "oauth2.googleapis.com") return Promise.resolve(Response.json({ access_token: "token", expires_in: 3600 }));
if (url.pathname === "/drive/v3/files" && request.method === "GET") return Promise.resolve(Response.json({ files: [] }));
if (url.pathname === "/drive/v3/files" && request.method === "POST") return Promise.resolve(Response.json({ id: "folder-1" }));
if (url.pathname.startsWith("/upload/drive/v3/files")) return Promise.resolve(new Response(null, { headers: { Location: "https://www.googleapis.com/upload/session/test" } }));
if (url.pathname === "/upload/session/test") return Promise.resolve(Response.json({ id: "file-1", name: "file.txt", md5Checksum: "d41d8cd98f00b204e9800998ecf8427e" }));
return Promise.resolve(new Response("Not Found", { status: 404 }));
}
afterEach(() => vi.unstubAllGlobals());
describe("CORS", () => {
it("returns allow-list preflight headers", async () => {
const response = preflightResponse(
new Request(`${ENDPOINT}/test-bucket/file.txt`, {
method: "OPTIONS",
headers: { Origin: ORIGIN, "Access-Control-Request-Method": "PUT", "Access-Control-Request-Headers": "content-type,x-amz-date" },
}),
ENV,
);
expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(ORIGIN);
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, HEAD, PUT, POST, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("content-type,x-amz-date");
expect(response.headers.get("Access-Control-Max-Age")).toBe("86400");
expect(response.headers.get("Vary")).toBe("Origin");
});
it("returns a bare 204 for a disallowed origin", async () => {
const response = preflightResponse(new Request(`${ENDPOINT}/test-bucket/file.txt`, { method: "OPTIONS", headers: { Origin: "https://not-allowed.example", "Access-Control-Request-Method": "PUT" } }), ENV);
expect(response.status).toBe(204);
expect([...response.headers]).toEqual([]);
});
it("keeps OPTIONS requests without an Origin byte-compatible with the prior bare 204", () => {
const response = preflightResponse(new Request(`${ENDPOINT}/test-bucket/file.txt`, { method: "OPTIONS" }), ENV);
expect(response.status).toBe(204);
expect([...response.headers]).toEqual([]);
});
it("exposes ETag for successful PUT responses", async () => {
vi.stubGlobal("fetch", vi.fn(fakeGoogleFetch));
const request = await signed("/test-bucket/file.txt", { method: "PUT", body: "hello", headers: { Origin: ORIGIN, "Content-Type": "text/plain" } });
const response = await worker.fetch(request, ENV, CTX);
expect(response.status).toBe(200);
expect(response.headers.get("ETag")).toBe('"d41d8cd98f00b204e9800998ecf8427e"');
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(ORIGIN);
expect(response.headers.get("Access-Control-Expose-Headers")).toContain("ETag");
});
it("adds CORS headers to access-denied and missing-key errors", async () => {
const denied = await worker.fetch(new Request(`${ENDPOINT}/not-a-bucket`, { headers: { Origin: ORIGIN } }), ENV, CTX);
expect(denied.status).toBe(403);
expect(denied.headers.get("Access-Control-Allow-Origin")).toBe(ORIGIN);
vi.stubGlobal("fetch", vi.fn(fakeGoogleFetch));
const missing = await worker.fetch(await signed("/test-bucket/missing.txt", { method: "GET", headers: { Origin: ORIGIN } }), ENV, CTX);
expect(missing.status).toBe(404);
expect(await missing.text()).toContain("<Code>NoSuchKey</Code>");
expect(missing.headers.get("Access-Control-Allow-Origin")).toBe(ORIGIN);
});
it("does not alter the S3 response for a disallowed Origin", async () => {
const response = await worker.fetch(new Request(`${ENDPOINT}/not-a-bucket`, { headers: { Origin: "https://not-allowed.example" } }), ENV, CTX);
expect(response.status).toBe(403);
expect(await response.text()).toContain("<Code>AccessDenied</Code>");
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
expect(response.headers.get("Access-Control-Expose-Headers")).toBeNull();
expect(response.headers.get("Vary")).toBe("Origin");
});
it("does not attach Access-Control headers to server-to-server responses without Origin", () => {
const response = withCors(new Response("S3 response", { status: 200, headers: { ETag: '"etag"' } }), new Request(`${ENDPOINT}/test-bucket/file.txt`), ENV);
expect(response.status).toBe(200);
expect(response.headers.get("ETag")).toBe('"etag"');
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
expect(response.headers.get("Access-Control-Expose-Headers")).toBeNull();
expect(response.headers.get("Vary")).toBe("Origin");
});
it("emits no CORS headers when CORS_ALLOWED_ORIGINS is unset", () => {
const request = new Request(`${ENDPOINT}/test-bucket/file.txt`, { headers: { Origin: ORIGIN } });
const response = withCors(new Response(null, { headers: { ETag: '"etag"' } }), request, { ...ENV, CORS_ALLOWED_ORIGINS: undefined });
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
expect(response.headers.get("Access-Control-Expose-Headers")).toBeNull();
expect(response.headers.get("Vary")).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import worker from "../src/index";
import type { Env } from "../src/types";
import { env } from "cloudflare:test";
const ENV = env as unknown as Env;
const ENDPOINT = "https://s3-api.example.com";
const CTX = { waitUntil: vi.fn(), passThroughOnException: vi.fn() } as unknown as ExecutionContext;
describe("API documentation routes", () => {
it("serves the Scalar shell and raw OpenAPI document", async () => {
const docs = await worker.fetch(new Request(`${ENDPOINT}/docs`), ENV, CTX);
expect(docs.status).toBe(200);
expect(docs.headers.get("Content-Type")).toContain("text/html");
expect(await docs.text()).toContain("@scalar/api-reference");
const spec = await worker.fetch(new Request(`${ENDPOINT}/openapi.yaml`), ENV, CTX);
expect(spec.status).toBe(200);
expect(spec.headers.get("Content-Type")).toContain("application/yaml");
expect(new TextDecoder().decode(await spec.arrayBuffer())).toContain("openapi: 3.1.0");
});
it("bypasses documentation routes when disabled", async () => {
const disabled = { ...ENV, ENABLE_DOCS: "false" };
for (const path of ["/docs", "/openapi.yaml"]) {
const response = await worker.fetch(new Request(`${ENDPOINT}${path}`), disabled, CTX);
expect(response.status).toBe(403);
}
});
it("does not claim /docs when docs is a configured bucket", async () => {
const withDocsBucket = { ...ENV, ALLOWED_BUCKETS: "test-bucket,docs" };
const response = await worker.fetch(new Request(`${ENDPOINT}/docs`), withDocsBucket, CTX);
expect(response.status).toBe(403);
expect(await response.text()).toContain("<Code>SignatureDoesNotMatch</Code>");
});
});
+64 -9
View File
@@ -56,7 +56,9 @@ class FakeDrive {
if (url.pathname === "/drive/v3/files" && request.method === "POST") return this.createMetadata(await request.json<Record<string, unknown>>());
if (url.pathname.startsWith("/drive/v3/files/") && url.searchParams.get("alt") === "media") return this.download(url, request);
if (url.pathname.startsWith("/drive/v3/files/") && request.method === "DELETE") {
this.files.delete(url.pathname.split("/").at(-1)!);
const fileId = url.pathname.split("/").at(-1);
if (!fileId) return new Response("Not Found", { status: 404 });
this.files.delete(fileId);
return new Response(null, { status: 204 });
}
if (url.pathname.startsWith("/upload/drive/v3/files") && url.searchParams.get("uploadType") === "resumable") return this.initialize(url, request);
@@ -121,7 +123,8 @@ class FakeDrive {
}
private async upload(url: URL, request: Request): Promise<Response> {
const id = url.pathname.split("/").at(-1)!;
const id = url.pathname.split("/").at(-1);
if (!id) return new Response(null, { status: 404 });
const session = this.sessions.get(id);
if (!session) return new Response(null, { status: 404 });
if (request.method === "DELETE") {
@@ -159,7 +162,9 @@ class FakeDrive {
}
private download(url: URL, request: Request): Response {
const file = this.files.get(url.pathname.split("/").at(-1)!);
const fileId = url.pathname.split("/").at(-1);
if (!fileId) return new Response(null, { status: 404 });
const file = this.files.get(fileId);
if (!file) return new Response(null, { status: 404 });
const range = request.headers.get("Range");
if (!range) return new Response(file.data, { headers: { "Content-Length": String(file.data.byteLength) } });
@@ -204,6 +209,18 @@ async function signed(path: string, init: RequestInit): Promise<Request> {
});
}
async function presigned(path: string, init: RequestInit, options: { datetime?: string; accessKeyId?: string; expires?: string } = {}): Promise<Request> {
const url = new URL(`${ENDPOINT}${path}`);
url.searchParams.set("X-Amz-Expires", options.expires ?? "60");
const aws = new AwsClient({ accessKeyId: options.accessKeyId ?? ENV.ACCESS_KEY, secretAccessKey: ENV.SECRET_KEY, region: ENV.REGION, service: "s3" });
const bodyLength = typeof init.body === "string" ? new TextEncoder().encode(init.body).byteLength : init.body instanceof Uint8Array ? init.body.byteLength : undefined;
return aws.sign(url.toString(), {
...init,
headers: { "x-amz-content-sha256": "UNSIGNED-PAYLOAD", ...(bodyLength === undefined ? {} : { "x-amz-decoded-content-length": String(bodyLength) }), ...init.headers },
aws: { signQuery: true, ...(options.datetime ? { datetime: options.datetime } : {}) },
});
}
let drive: FakeDrive;
beforeEach(async () => {
@@ -226,7 +243,10 @@ describe("S3 compatibility", () => {
const second = await worker.fetch(await signed("/test-bucket/file.txt", { method: "PUT", body: "second" }), ENV, CTX);
expect(second.status).toBe(200);
expect([...drive.files.values()].filter((file) => file.name === "file.txt")).toHaveLength(1);
expect(new TextDecoder().decode([...drive.files.values()].find((file) => file.name === "file.txt")!.data)).toBe("second");
const stored = [...drive.files.values()].find((file) => file.name === "file.txt");
expect(stored).toBeDefined();
if (!stored) throw new Error("Overwritten object was not stored");
expect(new TextDecoder().decode(stored.data)).toBe("second");
});
it("sets Last-Modified on GET and HEAD from Drive's modifiedTime", async () => {
@@ -264,6 +284,33 @@ describe("S3 compatibility", () => {
expect(response.status).toBe(200);
});
it("rejects expired presigned URLs and accepts unexpired ones", async () => {
const expired = await worker.fetch(await presigned("/test-bucket/file.txt", { method: "GET" }, { datetime: "20200101T000000Z", expires: "60" }), ENV, CTX);
expect(expired.status).toBe(403);
expect(await expired.text()).toContain("<Code>AccessDenied</Code>");
});
it("accepts an unexpired presigned URL", async () => {
await worker.fetch(await signed("/test-bucket/presigned.txt", { method: "PUT", body: "hello" }), ENV, CTX);
const response = await worker.fetch(await presigned("/test-bucket/presigned.txt", { method: "GET" }), ENV, CTX);
expect(response.status).toBe(200);
expect(await response.text()).toBe("hello");
});
it("rejects a Credential access key that does not match ACCESS_KEY", async () => {
const response = await worker.fetch(await presigned("/test-bucket/file.txt", { method: "GET" }, { accessKeyId: "unexpected-access-key" }), ENV, CTX);
expect(response.status).toBe(403);
expect(await response.text()).toContain("<Code>AccessDenied</Code>");
});
it("rejects header-authenticated requests outside the 15-minute clock skew", async () => {
const aws = new AwsClient({ accessKeyId: ENV.ACCESS_KEY, secretAccessKey: ENV.SECRET_KEY, region: ENV.REGION, service: "s3" });
const request = await aws.sign(`${ENDPOINT}/test-bucket/file.txt`, { method: "GET", aws: { datetime: "20200101T000000Z" } });
const response = await worker.fetch(request, ENV, CTX);
expect(response.status).toBe(403);
expect(await response.text()).toContain("<Code>RequestTimeTooSkewed</Code>");
});
it("decodes both aws-chunked framing variants across arbitrary boundaries", async () => {
const payload = bytes(70_013);
for (const trailer of [true, false]) {
@@ -282,20 +329,28 @@ describe("S3 compatibility", () => {
const source = bytes(1_500_123);
const create = await worker.fetch(await signed("/test-bucket/big.bin?uploads", { method: "POST", headers: { "Content-Type": "application/octet-stream" } }), ENV, CTX);
expect(create.status).toBe(200);
const uploadId = /<UploadId>([^<]+)<\/UploadId>/.exec(await create.text())![1];
const uploadId = /<UploadId>([^<]+)<\/UploadId>/.exec(await create.text())?.[1];
expect(uploadId).toBeDefined();
if (!uploadId) throw new Error("Multipart initiation did not return an upload ID");
const completed: Array<{ partNumber: number; etag: string }> = [];
for (let index = 0, offset = 0; offset < source.byteLength; index++) {
const end = Math.min(source.byteLength, offset + 500_000);
const part = await worker.fetch(await signed(`/test-bucket/big.bin?partNumber=${index + 1}&uploadId=${encodeURIComponent(uploadId)}`, { method: "PUT", body: source.slice(offset, end) }), ENV, CTX);
expect(part.status).toBe(200);
completed.push({ partNumber: index + 1, etag: part.headers.get("ETag")!.replaceAll('"', "") });
const partEtag = part.headers.get("ETag");
expect(partEtag).toBeDefined();
if (!partEtag) throw new Error(`Multipart part ${index + 1} did not return an ETag`);
completed.push({ partNumber: index + 1, etag: partEtag.replaceAll('"', "") });
offset = end;
}
const xml = `<CompleteMultipartUpload>${completed.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>"${part.etag}"</ETag></Part>`).join("")}</CompleteMultipartUpload>`;
const result = await worker.fetch(await signed(`/test-bucket/big.bin?uploadId=${encodeURIComponent(uploadId)}`, { method: "POST", body: xml }), ENV, CTX);
expect(result.status).toBe(200);
expect(await result.text()).toMatch(/<ETag>"[0-9a-f]{32}"<\/ETag>/);
const stored = [...drive.files.values()].find((file) => file.name === "big.bin")!.data;
const storedFile = [...drive.files.values()].find((file) => file.name === "big.bin");
expect(storedFile).toBeDefined();
if (!storedFile) throw new Error("Completed multipart object was not stored");
const stored = storedFile.data;
expect(stored.byteLength).toBe(source.byteLength);
expect(fakeMd5(stored)).toBe(fakeMd5(source));
});
@@ -317,13 +372,13 @@ describe("S3 compatibility", () => {
CTX,
);
expect(response.status).toBe(200);
expect([...drive.files.values()].find((file) => file.name === "chunked.bin")!.data).toEqual(source);
expect([...drive.files.values()].find((file) => file.name === "chunked.bin")?.data).toEqual(source);
});
it("supports an empty PutObject", async () => {
const response = await worker.fetch(await signed("/test-bucket/empty", { method: "PUT", body: new Uint8Array() }), ENV, CTX);
expect(response.status).toBe(200);
expect([...drive.files.values()].find((file) => file.name === "empty")!.data.byteLength).toBe(0);
expect([...drive.files.values()].find((file) => file.name === "empty")?.data.byteLength).toBe(0);
});
it("lists nested keys under a prefix, as CommonPrefixes with a delimiter and recursively without one", async () => {