Skip to content

Commit b0bf9f3

Browse files
authored
Add resend api (#201)
* feat: add resend api * fix: mock resend in email test * fix: tests * fix: add appendAPIOptions to enqueue and batch * fix: add batch emails
1 parent ddd44f5 commit b0bf9f3

File tree

9 files changed

+187
-8
lines changed

9 files changed

+187
-8
lines changed

Diff for: src/client/api/email.test.ts

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, test } from "bun:test";
2+
import { Client } from "../client";
3+
import { resend } from "./email";
4+
import { MOCK_QSTASH_SERVER_URL, mockQStashServer } from "../workflow/test-utils";
5+
import { nanoid } from "../utils";
6+
7+
describe("email", () => {
8+
const qstashToken = nanoid();
9+
const resendToken = nanoid();
10+
const client = new Client({ baseUrl: MOCK_QSTASH_SERVER_URL, token: qstashToken });
11+
12+
test("should use resend", async () => {
13+
await mockQStashServer({
14+
execute: async () => {
15+
await client.publishJSON({
16+
api: {
17+
name: "email",
18+
provider: resend({ token: resendToken }),
19+
},
20+
body: {
21+
from: "Acme <[email protected]>",
22+
23+
subject: "hello world",
24+
html: "<p>it works!</p>",
25+
},
26+
});
27+
},
28+
responseFields: {
29+
body: { messageId: "msgId" },
30+
status: 200,
31+
},
32+
receivesRequest: {
33+
method: "POST",
34+
token: qstashToken,
35+
url: "http://localhost:8080/v2/publish/https://api.resend.com/emails",
36+
body: {
37+
from: "Acme <[email protected]>",
38+
39+
subject: "hello world",
40+
html: "<p>it works!</p>",
41+
},
42+
headers: {
43+
authorization: `Bearer ${qstashToken}`,
44+
"upstash-forward-authorization": resendToken,
45+
},
46+
},
47+
});
48+
});
49+
50+
test("should use resend with batch", async () => {
51+
await mockQStashServer({
52+
execute: async () => {
53+
await client.publishJSON({
54+
api: {
55+
name: "email",
56+
provider: resend({ token: resendToken, batch: true }),
57+
},
58+
body: [
59+
{
60+
from: "Acme <[email protected]>",
61+
62+
subject: "hello world",
63+
html: "<h1>it works!</h1>",
64+
},
65+
{
66+
from: "Acme <[email protected]>",
67+
68+
subject: "world hello",
69+
html: "<p>it works!</p>",
70+
},
71+
],
72+
});
73+
},
74+
responseFields: {
75+
body: { messageId: "msgId" },
76+
status: 200,
77+
},
78+
receivesRequest: {
79+
method: "POST",
80+
token: qstashToken,
81+
url: "http://localhost:8080/v2/publish/https://api.resend.com/emails/batch",
82+
body: [
83+
{
84+
from: "Acme <[email protected]>",
85+
86+
subject: "hello world",
87+
html: "<h1>it works!</h1>",
88+
},
89+
{
90+
from: "Acme <[email protected]>",
91+
92+
subject: "world hello",
93+
html: "<p>it works!</p>",
94+
},
95+
],
96+
headers: {
97+
authorization: `Bearer ${qstashToken}`,
98+
"upstash-forward-authorization": resendToken,
99+
},
100+
},
101+
});
102+
});
103+
});

Diff for: src/client/api/email.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export type EmailProviderReturnType = {
2+
owner: "resend";
3+
baseUrl: "https://api.resend.com/emails" | "https://api.resend.com/emails/batch";
4+
token: string;
5+
};
6+
7+
export const resend = ({
8+
token,
9+
batch = false,
10+
}: {
11+
token: string;
12+
batch?: boolean;
13+
}): EmailProviderReturnType => {
14+
return {
15+
owner: "resend",
16+
baseUrl: `https://api.resend.com/emails${batch ? "/batch" : ""}`,
17+
token,
18+
};
19+
};

Diff for: src/client/api/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { resend } from "./email";

Diff for: src/client/api/utils.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { PublishRequest } from "../client";
2+
3+
export const appendAPIOptions = (request: PublishRequest<unknown>, headers: Headers) => {
4+
if (request.api?.name === "email") {
5+
headers.set("Authorization", request.api.provider.token);
6+
request.method = request.method ?? "POST";
7+
}
8+
};

Diff for: src/client/client.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { appendAPIOptions } from "./api/utils";
2+
import type { EmailProviderReturnType } from "./api/email";
13
import { DLQ } from "./dlq";
24
import type { Duration } from "./duration";
35
import { HttpClient, type Requester, type RetryConfig } from "./http";
@@ -189,6 +191,7 @@ export type PublishRequest<TBody = BodyInit> = {
189191
provider?: ProviderReturnType;
190192
analytics?: { name: "helicone"; token: string };
191193
};
194+
topic?: never;
192195
/**
193196
* Use a callback url to forward the response of your destination server to your callback url.
194197
*
@@ -197,14 +200,28 @@ export type PublishRequest<TBody = BodyInit> = {
197200
* @default undefined
198201
*/
199202
callback: string;
203+
}
204+
| {
205+
url?: never;
206+
urlGroup?: never;
207+
/**
208+
* The api endpoint the request should be sent to.
209+
*/
210+
api: {
211+
name: "email";
212+
provider: EmailProviderReturnType;
213+
};
200214
topic?: never;
215+
callback?: string;
201216
}
202217
| {
203218
url?: never;
204219
urlGroup?: never;
205-
api: never;
220+
api?: never;
206221
/**
207222
* Deprecated. The topic the message should be sent to. Same as urlGroup
223+
*
224+
* @deprecated
208225
*/
209226
topic?: string;
210227
/**
@@ -370,6 +387,8 @@ export class Client {
370387
ensureCallbackPresent<TBody>(request);
371388
//If needed, this allows users to directly pass their requests to any open-ai compatible 3rd party llm directly from sdk.
372389
appendLLMOptionsIfNeeded<TBody, TRequest>(request, headers, this.http);
390+
// append api options
391+
appendAPIOptions(request, headers);
373392

374393
// @ts-expect-error it's just internal
375394
const response = await this.publish<TRequest>({
@@ -434,6 +453,11 @@ export class Client {
434453
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
435454
//@ts-ignore this is required otherwise message header prevent ts to compile
436455
appendLLMOptionsIfNeeded<TBody, TRequest>(message, message.headers, this.http);
456+
457+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
458+
//@ts-ignore this is required otherwise message header prevent ts to compile
459+
appendAPIOptions(message, message.headers);
460+
437461
(message.headers as Headers).set("Content-Type", "application/json");
438462
}
439463

Diff for: src/client/llm/utils.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export function appendLLMOptionsIfNeeded<
88
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
99
TRequest extends PublishRequest<TBody> = PublishRequest<TBody>,
1010
>(request: TRequest, headers: Headers, http: Requester) {
11-
if (!request.api) return;
11+
if (request.api?.name === "email" || !request.api) return;
1212

1313
const provider = request.api.provider;
1414
const analytics = request.api.analytics;

Diff for: src/client/queue.ts

+3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { appendAPIOptions } from "./api/utils";
12
import type { PublishRequest, PublishResponse } from "./client";
23
import type { Requester } from "./http";
34
import { appendLLMOptionsIfNeeded, ensureCallbackPresent } from "./llm/utils";
@@ -140,6 +141,8 @@ export class Queue {
140141
// If needed, this allows users to directly pass their requests to any open-ai compatible 3rd party llm directly from sdk.
141142
appendLLMOptionsIfNeeded<TBody, TRequest>(request, headers, this.http);
142143

144+
appendAPIOptions(request, headers);
145+
143146
const response = await this.enqueue({
144147
...request,
145148
body: JSON.stringify(request.body),

Diff for: src/client/utils.ts

+26-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { PublishRequest } from "./client";
2+
import { QstashError } from "./error";
23

34
const isIgnoredHeader = (header: string) => {
45
const lowerCaseHeader = header.toLowerCase();
@@ -79,7 +80,18 @@ export function processHeaders(request: PublishRequest) {
7980
export function getRequestPath(
8081
request: Pick<PublishRequest, "url" | "urlGroup" | "api" | "topic">
8182
): string {
82-
return request.url ?? request.urlGroup ?? request.topic ?? `api/${request.api?.name}`;
83+
// eslint-disable-next-line @typescript-eslint/no-deprecated
84+
const nonApiPath = request.url ?? request.urlGroup ?? request.topic;
85+
if (nonApiPath) return nonApiPath;
86+
87+
// return llm api
88+
if (request.api?.name === "llm") return `api/${request.api.name}`;
89+
// return email api
90+
if (request.api?.name === "email") {
91+
return request.api.provider.baseUrl;
92+
}
93+
94+
throw new QstashError(`Failed to infer request path for ${JSON.stringify(request)}`);
8395
}
8496

8597
const NANOID_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
@@ -110,11 +122,19 @@ export function decodeBase64(base64: string) {
110122
return new TextDecoder().decode(intArray);
111123
} catch (error) {
112124
// this error should never happen essentially. It's only a failsafe
113-
console.warn(
114-
`Upstash Qstash: Failed while decoding base64 "${base64}".` +
115-
` Decoding with atob and returning it instead. ${error}`
116-
);
117-
return atob(base64);
125+
try {
126+
const result = atob(base64);
127+
console.warn(
128+
`Upstash QStash: Failed while decoding base64 "${base64}".` +
129+
` Decoding with atob and returning it instead. ${error}`
130+
);
131+
return result;
132+
} catch (error) {
133+
console.warn(
134+
`Upstash QStash: Failed to decode base64 "${base64}" with atob. Returning it as it is. ${error}`
135+
);
136+
return base64;
137+
}
118138
}
119139
}
120140

Diff for: src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export { decodeBase64 } from "./client/utils";
1010
export * from "./client/llm/chat";
1111
export * from "./client/llm/types";
1212
export * from "./client/llm/providers";
13+
export * from "./client/api";

0 commit comments

Comments
 (0)