Skip to content

Commit 5f89742

Browse files
committed
feat(expo): intake client with idempotency-key header
1 parent beb368a commit 5f89742

2 files changed

Lines changed: 104 additions & 0 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { test, expect } from "bun:test"
2+
import { createIntakeClient } from "./intake-client"
3+
4+
test("POSTs to intakeUrl/reports with multipart body and Idempotency-Key header", async () => {
5+
let capturedUrl = ""
6+
let capturedHeaders: Record<string, string> = {}
7+
const mockFetch: typeof fetch = async (input, init) => {
8+
capturedUrl = typeof input === "string" ? input : (input as Request).url
9+
capturedHeaders = Object.fromEntries(new Headers(init?.headers).entries())
10+
return new Response(JSON.stringify({ id: "server-id" }), {
11+
status: 201,
12+
headers: { "content-type": "application/json" },
13+
})
14+
}
15+
const client = createIntakeClient({
16+
intakeUrl: "https://ex.com/api/intake",
17+
fetchImpl: mockFetch,
18+
})
19+
const res = await client.submit({
20+
idempotencyKey: "idem-1",
21+
input: {
22+
projectKey: "rp_pk_" + "a".repeat(24),
23+
title: "t",
24+
context: {
25+
source: "expo",
26+
pageUrl: "myapp://x",
27+
userAgent: "u",
28+
viewport: { w: 1, h: 1 },
29+
timestamp: new Date().toISOString(),
30+
},
31+
} as never,
32+
attachments: [],
33+
})
34+
expect(capturedUrl).toBe("https://ex.com/api/intake/reports")
35+
expect(capturedHeaders["idempotency-key"]).toBe("idem-1")
36+
expect(res.id).toBe("server-id")
37+
})
38+
39+
const mockServerErrorFetch: typeof fetch = async () => new Response("boom", { status: 503 })
40+
41+
test("surfaces 5xx errors to the caller", async () => {
42+
const client = createIntakeClient({
43+
intakeUrl: "https://ex.com/api/intake",
44+
fetchImpl: mockServerErrorFetch,
45+
})
46+
await expect(
47+
client.submit({
48+
idempotencyKey: "k",
49+
input: { projectKey: "rp_pk_" + "a".repeat(24), title: "t", context: {} as never } as never,
50+
attachments: [],
51+
}),
52+
).rejects.toMatchObject({ status: 503 })
53+
})

packages/expo/src/intake-client.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { ReportIntakeInput, IntakeResponse } from "@reprojs/shared"
2+
import type { QueueItemAttachment } from "./queue/storage"
3+
4+
export interface IntakeSubmitArgs {
5+
idempotencyKey: string
6+
input: ReportIntakeInput
7+
attachments: Array<QueueItemAttachment & { contentType: string }>
8+
}
9+
10+
export class IntakeError extends Error {
11+
status: number
12+
retryable: boolean
13+
constructor(status: number, message: string) {
14+
super(message)
15+
this.status = status
16+
this.retryable = status >= 500 || status === 429
17+
}
18+
}
19+
20+
export interface IntakeClient {
21+
submit: (args: IntakeSubmitArgs) => Promise<IntakeResponse>
22+
}
23+
24+
export function createIntakeClient(opts: {
25+
intakeUrl: string
26+
fetchImpl?: typeof fetch
27+
}): IntakeClient {
28+
const f = opts.fetchImpl ?? fetch
29+
30+
return {
31+
async submit({ idempotencyKey, input, attachments }) {
32+
const form = new FormData()
33+
form.append("report", new Blob([JSON.stringify(input)], { type: "application/json" }))
34+
for (const a of attachments) {
35+
// In RN, FormData accepts { uri, name, type } shorthand. We use it via a typed helper.
36+
const part = { uri: a.uri, name: `${a.kind}.bin`, type: a.contentType } as unknown as Blob
37+
form.append(a.kind, part)
38+
}
39+
const res = await f(`${opts.intakeUrl}/reports`, {
40+
method: "POST",
41+
body: form as unknown as BodyInit,
42+
headers: { "idempotency-key": idempotencyKey },
43+
})
44+
if (res.status >= 400) {
45+
const body = await res.text().catch(() => "")
46+
throw new IntakeError(res.status, body || res.statusText)
47+
}
48+
return (await res.json()) as IntakeResponse
49+
},
50+
}
51+
}

0 commit comments

Comments
 (0)