Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions tests/routes/files-download.test.ts
Original file line number Diff line number Diff line change
@@ -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"',
)
})
Loading