From a412b3d7bdfbcecc4ab7da67aa6da11ee6309a8d Mon Sep 17 00:00:00 2001 From: XananasX7 Date: Thu, 18 Jun 2026 16:31:29 +0000 Subject: [PATCH] feat: add download endpoint docs and comprehensive tests - Document /files/download?file_path= and /files/download/[[file_path]] in README - Add download routes to the API index page - Add files-download.test.ts with 8 tests covering: - Text file download via query param (file_path and file_id) - Binary file download via query param with byte verification - 404 handling for missing files (query param) - Text file download via path form (nested paths) - Binary file download via path form with byte verification - 404 handling for missing files (path form) - Single-segment top-level file path form Closes #5 --- README.md | 18 ++++ bun.lock | 4 +- routes/index.ts | 4 +- tests/routes/files-download.test.ts | 141 ++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tests/routes/files-download.test.ts diff --git a/README.md b/README.md index 993d309..f897180 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,24 @@ Response: { } ``` +#### Download File + +Download a file with appropriate headers for browser file download prompts. + +**Query parameter form:** +```http +GET /files/download?file_path=path/to/file.txt +# or +GET /files/download?file_id=1 +``` + +**Path form:** +```http +GET /files/download/path/to/file.txt +``` + +Both forms return the raw file content with `Content-Disposition: attachment` headers, making them suitable for direct download links in browsers. Text files are served with `Content-Type: text/plain` and binary files with `Content-Type: application/octet-stream`. + ### Event Operations #### Create Event diff --git a/bun.lock b/bun.lock index 7098c88..56559a1 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,11 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@tscircuit/file-server", "dependencies": { "winterspec": "^0.0.86", - "zod": "^3.23.8", "zustand": "^4.5.5", "zustand-hoist": "^2.0.1", }, @@ -16,9 +16,11 @@ "next": "^14.2.5", "redaxios": "^0.5.1", "tsup": "^8.3.5", + "zod": "^3.23.8", }, "peerDependencies": { "typescript": "^5.0.0", + "zod": "*", }, }, }, diff --git a/routes/index.ts b/routes/index.ts index ade1972..0087cfc 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -12,9 +12,11 @@ export default withRouteSpec({ This is a simple file server API, it has the following API: /health - Health check -/files/get?file_path=... - Get a file +/files/get?file_path=... - Get a file (returns JSON) /files/list - List all files /files/upsert - Upsert a file +/files/download?file_path=... - Download a file by query param +/files/download/[[file_path]] - Download a file by path /events/list?since=... - List events since a given timestamp /events/list?event_type=... - List events filtered by event type diff --git a/tests/routes/files-download.test.ts b/tests/routes/files-download.test.ts new file mode 100644 index 0000000..4197fa8 --- /dev/null +++ b/tests/routes/files-download.test.ts @@ -0,0 +1,141 @@ +import { test, expect } from "bun:test" +import { getTestServer } from "tests/fixtures/get-test-server" +import { Buffer } from "node:buffer" + +test("download text file via query param (?file_path=...)", async () => { + const { axios } = await getTestServer() + + await axios.post("/files/upsert", { + file_path: "/hello.txt", + text_content: "Hello, download!", + }) + + const res = await axios.get("/files/download", { + params: { file_path: "/hello.txt" }, + }) + + expect(res.status).toBe(200) + expect(res.data).toBe("Hello, download!") + expect(res.headers.get("content-type")).toBe("text/plain") + expect(res.headers.get("content-disposition")).toBe( + 'attachment; filename="hello.txt"', + ) +}) + +test("download text file via query param (?file_id=...)", async () => { + const { axios } = await getTestServer() + + const upsertRes = await axios.post("/files/upsert", { + file_path: "/id-download.txt", + text_content: "Download by id", + }) + const { file_id } = upsertRes.data.file + + const res = await axios.get("/files/download", { + params: { file_id }, + }) + + expect(res.status).toBe(200) + expect(res.data).toBe("Download by id") + expect(res.headers.get("content-disposition")).toBe( + 'attachment; filename="id-download.txt"', + ) +}) + +test("download binary file via query param returns correct bytes", async () => { + const { axios } = await getTestServer() + + const buffer = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0x00, 0xff]) + const base64 = buffer.toString("base64") + + await axios.post("/files/upsert", { + file_path: "/data.bin", + binary_content_b64: base64, + }) + + const res = await axios.get("/files/download", { + params: { file_path: "/data.bin" }, + responseType: "arrayBuffer", + }) + + expect(res.status).toBe(200) + expect(res.headers.get("content-type")).toBe("application/octet-stream") + expect(res.headers.get("content-length")).toBe(buffer.length.toString()) + expect(res.headers.get("content-disposition")).toBe( + 'attachment; filename="data.bin"', + ) + expect(Buffer.from(res.data)).toEqual(buffer) +}) + +test("download missing file returns 404 (query param)", async () => { + const { axios } = await getTestServer() + + await expect( + axios.get("/files/download", { + params: { file_path: "/does-not-exist.txt" }, + }), + ).rejects.toMatchObject({ status: 404 }) +}) + +test("download text file via path form (/files/download/...)", async () => { + const { axios } = await getTestServer() + + await axios.post("/files/upsert", { + file_path: "/sub/dir/notes.txt", + text_content: "Nested file download", + }) + + const res = await axios.get("/files/download/sub/dir/notes.txt") + + expect(res.status).toBe(200) + expect(res.data).toBe("Nested file download") + expect(res.headers.get("content-type")).toBe("text/plain") + expect(res.headers.get("content-disposition")).toBe( + 'attachment; filename="notes.txt"', + ) +}) + +test("download binary file via path form returns correct bytes", async () => { + const { axios } = await getTestServer() + + const buffer = Buffer.from([0x01, 0x02, 0x03, 0x80, 0xfe]) + const base64 = buffer.toString("base64") + + await axios.post("/files/upsert", { + file_path: "/assets/image.bin", + binary_content_b64: base64, + }) + + const res = await axios.get("/files/download/assets/image.bin", { + responseType: "arrayBuffer", + }) + + expect(res.status).toBe(200) + expect(res.headers.get("content-type")).toBe("application/octet-stream") + expect(res.headers.get("content-length")).toBe(buffer.length.toString()) + expect(Buffer.from(res.data)).toEqual(buffer) +}) + +test("download missing file returns 404 (path form)", async () => { + const { axios } = await getTestServer() + + await expect( + axios.get("/files/download/ghost/file.txt"), + ).rejects.toMatchObject({ status: 404 }) +}) + +test("download top-level file via path form (single segment path)", async () => { + const { axios } = await getTestServer() + + await axios.post("/files/upsert", { + file_path: "/readme.md", + text_content: "# Readme", + }) + + const res = await axios.get("/files/download/readme.md") + expect(res.status).toBe(200) + expect(res.data).toBe("# Readme") + expect(res.headers.get("content-disposition")).toBe( + 'attachment; filename="readme.md"', + ) +})