idempotency (#282)

This commit is contained in:
KM Koushik
2025-11-17 11:42:09 +11:00
committed by GitHub
parent eacf231173
commit cb489654b5
14 changed files with 859 additions and 1118 deletions
@@ -1,3 +1,5 @@
--- ---
openapi: post /v1/emails openapi: post /v1/emails
--- ---
Send a transactional email via the public API.
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,9 @@
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono"; import { PublicAPIApp } from "~/server/public-api/hono";
import { getTeamFromToken } from "~/server/public-api/auth";
import { sendBulkEmails } from "~/server/service/email-service"; import { sendBulkEmails } from "~/server/service/email-service";
import { EmailContent } from "~/types"; import { EmailContent } from "~/types";
import { emailSchema } from "../../schemas/email-schema"; // Corrected import path import { emailSchema } from "../../schemas/email-schema";
import { IdempotencyService } from "~/server/service/idempotency-service";
// Define the schema for a single email within the bulk request // Define the schema for a single email within the bulk request
// This is similar to the schema in send-email.ts but without the top-level 'required' // This is similar to the schema in send-email.ts but without the top-level 'required'
@@ -13,6 +13,24 @@ const route = createRoute({
method: "post", method: "post",
path: "/v1/emails/batch", path: "/v1/emails/batch",
request: { request: {
headers: z
.object({
"Idempotency-Key": z
.string()
.min(1)
.max(256)
.optional()
.openapi({
description: `Pass the optional Idempotency-Key header to make the request safe to retry. The key can be up to 256 characters. The server stores the canonical request body and behaves as follows:
- Same key + same request body → returns the original emailId with 200 OK without re-sending.
- Same key + different request body → returns 409 Conflict with code: NOT_UNIQUE so you can detect the mismatch.
- Same key while another request is still being processed → returns 409 Conflict; retry after a short delay or once the first request completes.
Entries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID).`,
}),
})
.partial(),
body: { body: {
required: true, required: true,
content: { content: {
@@ -47,27 +65,41 @@ function sendBatch(app: PublicAPIApp) {
const team = c.var.team; const team = c.var.team;
const emailPayloads = c.req.valid("json"); const emailPayloads = c.req.valid("json");
// Add teamId and apiKeyId to each email payload const normalizedPayloads = emailPayloads.map((payload) => ({
const emailsToSend: Array<
EmailContent & { teamId: number; apiKeyId?: number }
> = emailPayloads.map((payload) => ({
...payload, ...payload,
text: payload.text ?? undefined, text: payload.text ?? undefined,
html: html:
payload.html && payload.html !== "true" && payload.html !== "false" payload.html && payload.html !== "true" && payload.html !== "false"
? payload.html ? payload.html
: undefined, : undefined,
}));
const idemKey = c.req.header("Idempotency-Key") ?? undefined;
const responseData = await IdempotencyService.withIdempotency({
teamId: team.id,
idemKey,
payload: normalizedPayloads,
operation: async () => {
const emailsToSend: Array<
EmailContent & { teamId: number; apiKeyId?: number }
> = normalizedPayloads.map((payload) => ({
...payload,
teamId: team.id, teamId: team.id,
apiKeyId: team.apiKeyId, apiKeyId: team.apiKeyId,
})); }));
// Call the service function to send emails in bulk
const createdEmails = await sendBulkEmails(emailsToSend); const createdEmails = await sendBulkEmails(emailsToSend);
// Map the result to the response format return createdEmails.map((email) => ({
const responseData = createdEmails.map((email) => ({
emailId: email.id, emailId: email.id,
})); }));
},
extractEmailIds: (data) => data.map((item) => item.emailId),
formatCachedResponse: (emailIds) =>
emailIds.map((id) => ({ emailId: id })),
logContext: "bulk email send",
});
return c.json({ data: responseData }); return c.json({ data: responseData });
}); });
@@ -2,11 +2,30 @@ import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono"; import { PublicAPIApp } from "~/server/public-api/hono";
import { sendEmail } from "~/server/service/email-service"; import { sendEmail } from "~/server/service/email-service";
import { emailSchema } from "../../schemas/email-schema"; import { emailSchema } from "../../schemas/email-schema";
import { IdempotencyService } from "~/server/service/idempotency-service";
const route = createRoute({ const route = createRoute({
method: "post", method: "post",
path: "/v1/emails", path: "/v1/emails",
request: { request: {
headers: z
.object({
"Idempotency-Key": z
.string()
.min(1)
.max(256)
.optional()
.openapi({
description: `Pass the optional Idempotency-Key header to make the request safe to retry. The key can be up to 256 characters. The server stores the canonical request body and behaves as follows:
- Same key + same request body → returns the original emailId with 200 OK without re-sending.
- Same key + different request body → returns 409 Conflict with code: NOT_UNIQUE so you can detect the mismatch.
- Same key while another request is still being processed → returns 409 Conflict; retry after a short delay or once the first request completes.
Entries expire after 24 hours. Use a unique key per logical send (for example, an order or signup ID).`,
}),
})
.partial(),
body: { body: {
required: true, required: true,
content: { content: {
@@ -31,24 +50,43 @@ const route = createRoute({
function send(app: PublicAPIApp) { function send(app: PublicAPIApp) {
app.openapi(route, async (c) => { app.openapi(route, async (c) => {
const team = c.var.team; const team = c.var.team;
const requestBody = c.req.valid("json");
let html = undefined; let html: string | undefined;
const rawHtml = requestBody?.html?.toString();
const _html = c.req.valid("json")?.html?.toString(); if (rawHtml && rawHtml !== "true" && rawHtml !== "false") {
html = rawHtml;
if (_html && _html !== "true" && _html !== "false") {
html = _html;
} }
const clientPayload = {
...requestBody,
text: requestBody.text ?? undefined,
html,
};
const idemKey = c.req.header("Idempotency-Key") ?? undefined;
const result = await IdempotencyService.withIdempotency<
typeof clientPayload,
{ emailId?: string }
>({
teamId: team.id,
idemKey,
payload: clientPayload,
operation: async () => {
const email = await sendEmail({ const email = await sendEmail({
...c.req.valid("json"), ...clientPayload,
teamId: team.id, teamId: team.id,
apiKeyId: team.apiKeyId, apiKeyId: team.apiKeyId,
text: c.req.valid("json").text ?? undefined, });
html: html, return { emailId: email?.id };
},
extractEmailIds: (result) => (result.emailId ? [result.emailId] : []),
formatCachedResponse: (emailIds) => ({ emailId: emailIds[0] }),
logContext: "email send",
}); });
return c.json({ emailId: email?.id }); return c.json(result);
}); });
} }
@@ -0,0 +1,177 @@
import { getRedis } from "~/server/redis";
import { canonicalizePayload } from "~/server/utils/idempotency";
import { UnsendApiError } from "~/server/public-api/api-error";
import { logger } from "~/server/logger/log";
const IDEMPOTENCY_RESULT_TTL_SECONDS = 24 * 60 * 60; // 24h
const IDEMPOTENCY_LOCK_TTL_SECONDS = 60; // 60s
export type IdempotencyRecord = {
bodyHash: string;
emailIds: string[];
};
export type IdempotencyHandlerOptions<TPayload, TResult> = {
teamId: number;
idemKey: string | undefined;
payload: TPayload;
operation: () => Promise<TResult>;
extractEmailIds: (result: TResult) => string[];
formatCachedResponse: (emailIds: string[]) => TResult;
logContext: string;
};
function resultKey(teamId: number, key: string) {
return `idem:${teamId}:${key}`;
}
function lockKey(teamId: number, key: string) {
return `idemlock:${teamId}:${key}`;
}
export const IdempotencyService = {
async getResult(
teamId: number,
key: string,
): Promise<IdempotencyRecord | null> {
const redis = getRedis();
const raw = await redis.get(resultKey(teamId, key));
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
typeof (parsed as any).bodyHash === "string" &&
Array.isArray((parsed as any).emailIds)
) {
return parsed as IdempotencyRecord;
}
return null;
} catch {
return null;
}
},
async setResult(
teamId: number,
key: string,
record: IdempotencyRecord,
): Promise<void> {
const redis = getRedis();
await redis.setex(
resultKey(teamId, key),
IDEMPOTENCY_RESULT_TTL_SECONDS,
JSON.stringify(record),
);
},
async acquireLock(teamId: number, key: string): Promise<boolean> {
const redis = getRedis();
const ok = await redis.set(
lockKey(teamId, key),
"1",
"EX",
IDEMPOTENCY_LOCK_TTL_SECONDS,
"NX",
);
return ok === "OK";
},
async releaseLock(teamId: number, key: string): Promise<void> {
const redis = getRedis();
await redis.del(lockKey(teamId, key));
},
async withIdempotency<TPayload, TResult>(
options: IdempotencyHandlerOptions<TPayload, TResult>,
): Promise<TResult> {
const {
teamId,
idemKey,
payload,
operation,
extractEmailIds,
formatCachedResponse,
logContext,
} = options;
// Validate idempotency key length
if (idemKey !== undefined && (idemKey.length < 1 || idemKey.length > 256)) {
throw new UnsendApiError({
code: "BAD_REQUEST",
message: "Invalid Idempotency-Key length",
});
}
// If no idempotency key, just execute the operation
if (!idemKey) {
return await operation();
}
// Calculate payload hash
const { bodyHash: payloadHash } = canonicalizePayload(payload);
// Check for existing result
const existing = await this.getResult(teamId, idemKey);
if (existing) {
if (existing.bodyHash === payloadHash) {
logger.info({ teamId }, `Idempotency hit for ${logContext}`);
return formatCachedResponse(existing.emailIds);
}
throw new UnsendApiError({
code: "NOT_UNIQUE",
message: "Idempotency-Key already used with a different payload",
});
}
// Try to acquire lock
const lockAcquired = await this.acquireLock(teamId, idemKey);
if (!lockAcquired) {
// Check again in case another request completed
const again = await this.getResult(teamId, idemKey);
if (again) {
if (again.bodyHash === payloadHash) {
logger.info(
{ teamId },
`Idempotency hit after contention for ${logContext}`,
);
return formatCachedResponse(again.emailIds);
}
throw new UnsendApiError({
code: "NOT_UNIQUE",
message: "Idempotency-Key already used with a different payload",
});
}
throw new UnsendApiError({
code: "NOT_UNIQUE",
message:
"Request with same Idempotency-Key is in progress. Retry later.",
});
}
try {
// Execute the operation
const result = await operation();
// Store the result for future idempotency checks
await this.setResult(teamId, idemKey, {
bodyHash: payloadHash,
emailIds: extractEmailIds(result),
});
return result;
} finally {
// Always release the lock
await this.releaseLock(teamId, idemKey);
}
},
};
export const IDEMPOTENCY_CONSTANTS = {
RESULT_TTL_SECONDS: IDEMPOTENCY_RESULT_TTL_SECONDS,
LOCK_TTL_SECONDS: IDEMPOTENCY_LOCK_TTL_SECONDS,
};
+69
View File
@@ -0,0 +1,69 @@
import { createHash } from "crypto";
type CanonicalValue =
| string
| number
| boolean
| null
| CanonicalValue[]
| { [key: string]: CanonicalValue };
function normalize(value: unknown): CanonicalValue | undefined {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (Array.isArray(value)) {
return value.map((item) => normalize(item) ?? null);
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>).sort(
([keyA], [keyB]) => (keyA < keyB ? -1 : keyA > keyB ? 1 : 0)
);
const result: Record<string, CanonicalValue> = {};
for (const [key, val] of entries) {
const normalized = normalize(val);
if (normalized !== undefined) {
result[key] = normalized;
}
}
return result;
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number") {
return value;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "bigint") {
return value.toString();
}
return String(value);
}
export function canonicalizePayload(payload: unknown) {
const normalized = normalize(payload);
const canonical = JSON.stringify(normalized ?? null);
const bodyHash = createHash("sha256").update(canonical).digest("hex");
return { canonical, bodyHash };
}
+13
View File
@@ -37,6 +37,19 @@ resp, _ = client.emails.send(payload={
"html": "<strong>Hi!</strong>", "html": "<strong>Hi!</strong>",
}) })
# Idempotent retries: same payload + same key returns the original response
resp, _ = client.emails.send(
payload=payload,
options={"idempotency_key": "signup-123"},
)
# Works for batch requests as well
resp, _ = client.emails.batch(
payload=[payload],
options={"idempotency_key": "bulk-welcome-1"},
)
# If the same key is reused with a different payload, the API responds with HTTP 409.
# 3) Campaigns # 3) Campaigns
campaign_payload: types.CampaignCreate = { campaign_payload: types.CampaignCreate = {
"name": "Welcome Series", "name": "Welcome Series",
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "usesend" name = "usesend"
version = "0.2.7" version = "0.2.8"
description = "Python SDK for the UseSend API" description = "Python SDK for the UseSend API"
authors = ["UseSend"] authors = ["UseSend"]
license = "MIT" license = "MIT"
+36 -6
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
from typing_extensions import TypedDict
from .types import ( from .types import (
APIError, APIError,
@@ -18,6 +19,17 @@ from .types import (
) )
class EmailOptions(TypedDict, total=False):
"""Options for email operations."""
idempotency_key: Optional[str]
def _idem_headers(idempotency_key: Optional[str]) -> Optional[Dict[str, str]]:
if idempotency_key:
return {"Idempotency-Key": idempotency_key}
return None
class Emails: class Emails:
"""Client for `/emails` endpoints.""" """Client for `/emails` endpoints."""
@@ -25,11 +37,19 @@ class Emails:
self.usesend = usesend self.usesend = usesend
# Basic operations ------------------------------------------------- # Basic operations -------------------------------------------------
def send(self, payload: EmailCreate) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]: def send(
self,
payload: EmailCreate,
options: Optional[EmailOptions] = None,
) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
"""Alias for :meth:`create`.""" """Alias for :meth:`create`."""
return self.create(payload) return self.create(payload, options)
def create(self, payload: Union[EmailCreate, Dict[str, Any]]) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]: def create(
self,
payload: Union[EmailCreate, Dict[str, Any]],
options: Optional[EmailOptions] = None,
) -> Tuple[Optional[EmailCreateResponse], Optional[APIError]]:
if isinstance(payload, dict): if isinstance(payload, dict):
payload = dict(payload) payload = dict(payload)
@@ -42,10 +62,17 @@ class Emails:
if isinstance(body.get("scheduledAt"), datetime): if isinstance(body.get("scheduledAt"), datetime):
body["scheduledAt"] = body["scheduledAt"].isoformat() body["scheduledAt"] = body["scheduledAt"].isoformat()
data, err = self.usesend.post("/emails", body) idempotency_key = options.get("idempotency_key") if options else None
data, err = self.usesend.post(
"/emails", body, headers=_idem_headers(idempotency_key)
)
return (data, err) # type: ignore[return-value] return (data, err) # type: ignore[return-value]
def batch(self, payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]]) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]: def batch(
self,
payload: Sequence[Union[EmailBatchItem, Dict[str, Any]]],
options: Optional[EmailOptions] = None,
) -> Tuple[Optional[EmailBatchResponse], Optional[APIError]]:
items: List[Dict[str, Any]] = [] items: List[Dict[str, Any]] = []
for item in payload: for item in payload:
d = dict(item) d = dict(item)
@@ -54,7 +81,10 @@ class Emails:
if isinstance(d.get("scheduledAt"), datetime): if isinstance(d.get("scheduledAt"), datetime):
d["scheduledAt"] = d["scheduledAt"].isoformat() d["scheduledAt"] = d["scheduledAt"].isoformat()
items.append(d) items.append(d)
data, err = self.usesend.post("/emails/batch", items) idempotency_key = options.get("idempotency_key") if options else None
data, err = self.usesend.post(
"/emails/batch", items, headers=_idem_headers(idempotency_key)
)
return (data, err) # type: ignore[return-value] return (data, err) # type: ignore[return-value]
def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]: def get(self, email_id: str) -> Tuple[Optional[Email], Optional[APIError]]:
+45 -12
View File
@@ -77,12 +77,25 @@ class UseSend:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Internal request helper # Internal request helper
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _build_headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
headers = dict(self.headers)
if extra:
headers.update({k: v for k, v in extra.items() if v is not None})
return headers
def _request( def _request(
self, method: str, path: str, json: Optional[Any] = None self,
method: str,
path: str,
json: Optional[Any] = None,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Perform an HTTP request and return ``(data, error)``.""" """Perform an HTTP request and return ``(data, error)``."""
resp = self._session.request( resp = self._session.request(
method, f"{self.url}{path}", headers=self.headers, json=json method,
f"{self.url}{path}",
headers=self._build_headers(headers),
json=json,
) )
default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason} default_error = {"code": "INTERNAL_SERVER_ERROR", "message": resp.reason}
@@ -104,22 +117,42 @@ class UseSend:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# HTTP verb helpers # HTTP verb helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def post(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: def post(
return self._request("POST", path, json=body) self,
path: str,
body: Any,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("POST", path, json=body, headers=headers)
def get(self, path: str) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: def get(
return self._request("GET", path) self, path: str, headers: Optional[Dict[str, str]] = None
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("GET", path, headers=headers)
def put(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: def put(
return self._request("PUT", path, json=body) self,
path: str,
body: Any,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("PUT", path, json=body, headers=headers)
def patch(self, path: str, body: Any) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: def patch(
return self._request("PATCH", path, json=body) self,
path: str,
body: Any,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("PATCH", path, json=body, headers=headers)
def delete( def delete(
self, path: str, body: Optional[Any] = None self,
path: str,
body: Optional[Any] = None,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
return self._request("DELETE", path, json=body) return self._request("DELETE", path, json=body, headers=headers)
# Import here to avoid circular dependency during type checking # Import here to avoid circular dependency during type checking
+31
View File
@@ -48,6 +48,37 @@ usesend.emails.send({
html: "<p>useSend is the best open source product to send emails</p>", html: "<p>useSend is the best open source product to send emails</p>",
text: "useSend is the best open source product to send emails", text: "useSend is the best open source product to send emails",
}); });
// Safely retry sends with an idempotency key
await usesend.emails.send(
{
to: "hello@acme.com",
from: "hello@company.com",
subject: "useSend email",
html: "<p>useSend is the best open source product to send emails</p>",
},
{ idempotencyKey: "signup-123" },
);
// Works for bulk sends too
await usesend.emails.batch(
[
{
to: "a@example.com",
from: "hello@company.com",
subject: "Welcome",
html: "<p>Hello A</p>",
},
{
to: "b@example.com",
from: "hello@company.com",
subject: "Welcome",
html: "<p>Hello B</p>",
},
],
{ idempotencyKey: "bulk-welcome-1" },
);
// Reusing the same key with a different payload returns HTTP 409.
``` ```
## Campaigns ## Campaigns
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "usesend-js", "name": "usesend-js",
"version": "1.5.6", "version": "1.5.7",
"description": "", "description": "",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
+22 -6
View File
@@ -67,16 +67,23 @@ type BatchEmailResponse = {
error: ErrorResponse | null; error: ErrorResponse | null;
}; };
type EmailRequestOptions = {
idempotencyKey?: string;
};
export class Emails { export class Emails {
constructor(private readonly usesend: UseSend) { constructor(private readonly usesend: UseSend) {
this.usesend = usesend; this.usesend = usesend;
} }
async send(payload: SendEmailPayload) { async send(payload: SendEmailPayload, options?: EmailRequestOptions) {
return this.create(payload); return this.create(payload, options);
} }
async create(payload: SendEmailPayload): Promise<CreateEmailResponse> { async create(
payload: SendEmailPayload,
options?: EmailRequestOptions,
): Promise<CreateEmailResponse> {
if (payload.react) { if (payload.react) {
payload.html = await render(payload.react as React.ReactElement); payload.html = await render(payload.react as React.ReactElement);
delete payload.react; delete payload.react;
@@ -84,7 +91,10 @@ export class Emails {
const data = await this.usesend.post<CreateEmailResponseSuccess>( const data = await this.usesend.post<CreateEmailResponseSuccess>(
"/emails", "/emails",
payload payload,
options?.idempotencyKey
? { headers: { "Idempotency-Key": options.idempotencyKey } }
: undefined,
); );
return data; return data;
@@ -96,11 +106,17 @@ export class Emails {
* @param payload An array of email payloads. Max 100 emails. * @param payload An array of email payloads. Max 100 emails.
* @returns A promise that resolves to the list of created email IDs or an error. * @returns A promise that resolves to the list of created email IDs or an error.
*/ */
async batch(payload: BatchEmailPayload): Promise<BatchEmailResponse> { async batch(
payload: BatchEmailPayload,
options?: EmailRequestOptions,
): Promise<BatchEmailResponse> {
// Note: React element rendering is not supported in batch mode. // Note: React element rendering is not supported in batch mode.
const response = await this.usesend.post<BatchEmailResponseSuccess>( const response = await this.usesend.post<BatchEmailResponseSuccess>(
"/emails/batch", "/emails/batch",
payload payload,
options?.idempotencyKey
? { headers: { "Idempotency-Key": options.idempotencyKey } }
: undefined,
); );
return { return {
data: response.data ? response.data.data : null, data: response.data ? response.data.data : null,
+61 -20
View File
@@ -12,8 +12,12 @@ function isUseSendErrorResponse(error: { error: ErrorResponse }) {
return error.error.code !== undefined; return error.error.code !== undefined;
} }
type RequestOptions = {
headers?: HeadersInit;
};
export class UseSend { export class UseSend {
private readonly headers: Headers; private readonly baseHeaders: Headers;
// readonly domains = new Domains(this); // readonly domains = new Domains(this);
readonly emails = new Emails(this); readonly emails = new Emails(this);
@@ -42,17 +46,36 @@ export class UseSend {
this.url = `${url}/api/v1`; this.url = `${url}/api/v1`;
} }
this.headers = new Headers({ this.baseHeaders = new Headers({
Authorization: `Bearer ${this.key}`, Authorization: `Bearer ${this.key}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}); });
} }
private mergeHeaders(extra?: HeadersInit) {
const headers = new Headers(this.baseHeaders);
if (!extra) {
return headers;
}
const additional = new Headers(extra);
additional.forEach((value, key) => {
headers.set(key, value);
});
return headers;
}
async fetchRequest<T>( async fetchRequest<T>(
path: string, path: string,
options = {}, options: RequestInit = {},
): Promise<{ data: T | null; error: ErrorResponse | null }> { ): Promise<{ data: T | null; error: ErrorResponse | null }> {
const response = await fetch(`${this.url}${path}`, options); const requestOptions: RequestInit = {
...options,
headers: this.mergeHeaders(options.headers),
};
const response = await fetch(`${this.url}${path}`, requestOptions);
const defaultError = { const defaultError = {
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: response.statusText, message: response.statusText,
@@ -82,52 +105,70 @@ export class UseSend {
return { data, error: null }; return { data, error: null };
} }
async post<T>(path: string, body: unknown) { async post<T>(path: string, body: unknown, options?: RequestOptions) {
const requestOptions = { const requestOptions: RequestInit = {
method: "POST", method: "POST",
headers: this.headers,
body: JSON.stringify(body), body: JSON.stringify(body),
}; };
if (options?.headers) {
requestOptions.headers = options.headers;
}
return this.fetchRequest<T>(path, requestOptions); return this.fetchRequest<T>(path, requestOptions);
} }
async get<T>(path: string) { async get<T>(path: string, options?: RequestOptions) {
const requestOptions = { const requestOptions: RequestInit = {
method: "GET", method: "GET",
headers: this.headers,
}; };
if (options?.headers) {
requestOptions.headers = options.headers;
}
return this.fetchRequest<T>(path, requestOptions); return this.fetchRequest<T>(path, requestOptions);
} }
async put<T>(path: string, body: any) { async put<T>(path: string, body: any, options?: RequestOptions) {
const requestOptions = { const requestOptions: RequestInit = {
method: "PUT", method: "PUT",
headers: this.headers,
body: JSON.stringify(body), body: JSON.stringify(body),
}; };
if (options?.headers) {
requestOptions.headers = options.headers;
}
return this.fetchRequest<T>(path, requestOptions); return this.fetchRequest<T>(path, requestOptions);
} }
async patch<T>(path: string, body: any) { async patch<T>(path: string, body: any, options?: RequestOptions) {
const requestOptions = { const requestOptions: RequestInit = {
method: "PATCH", method: "PATCH",
headers: this.headers,
body: JSON.stringify(body), body: JSON.stringify(body),
}; };
if (options?.headers) {
requestOptions.headers = options.headers;
}
return this.fetchRequest<T>(path, requestOptions); return this.fetchRequest<T>(path, requestOptions);
} }
async delete<T>(path: string, body?: unknown) { async delete<T>(path: string, body?: unknown, options?: RequestOptions) {
const requestOptions = { const requestOptions: RequestInit = {
method: "DELETE", method: "DELETE",
headers: this.headers,
body: JSON.stringify(body),
}; };
if (body !== undefined) {
requestOptions.body = JSON.stringify(body);
}
if (options?.headers) {
requestOptions.headers = options.headers;
}
return this.fetchRequest<T>(path, requestOptions); return this.fetchRequest<T>(path, requestOptions);
} }
} }