323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
import type { Webflow } from "./util/types";
|
|
import { type DeepPartial, type PagingOptions } from "./util/misc";
|
|
import crypto from "node:crypto";
|
|
|
|
const parsePayload = (res: Response): Promise<unknown> =>
|
|
res.json().catch(() => res.text().catch(() => undefined));
|
|
|
|
type ClientOptions = {
|
|
siteId: string;
|
|
collectionsId: string;
|
|
token: string;
|
|
webhookSecret: string;
|
|
};
|
|
|
|
type Credentials = {
|
|
apiBaseUrl: string;
|
|
apiSitesUrl: string;
|
|
apiCollectionsUrl: string;
|
|
authHeader: Record<string, string>;
|
|
};
|
|
|
|
export class WebflowError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public upstreamStatus: number,
|
|
public payload: unknown,
|
|
public status: number = 502
|
|
) {
|
|
super(message);
|
|
this.name = "WebflowError";
|
|
}
|
|
}
|
|
|
|
class SkusClient {
|
|
constructor(private creds: Credentials) { }
|
|
|
|
async create(
|
|
wProductId: string,
|
|
skus: DeepPartial<Webflow.Products.Skus.Sku>[],
|
|
): Promise<string[]> {
|
|
const res = await fetch(
|
|
`${this.creds.apiSitesUrl}/products/${wProductId}/skus`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...this.creds.authHeader,
|
|
},
|
|
body: JSON.stringify({ skus }),
|
|
},
|
|
);
|
|
if (!res.ok) throw new WebflowError("Failed to create Webflow product SKU", res.status, await parsePayload(res));
|
|
const payload = (await res.json()) as { skus: { id: string }[] };
|
|
return payload.skus.map((sku) => sku.id);
|
|
}
|
|
|
|
async update(
|
|
wProductId: string,
|
|
wSkuId: string,
|
|
wSku: DeepPartial<Webflow.Products.Skus.Sku>,
|
|
): Promise<void> {
|
|
const res = await fetch(
|
|
`${this.creds.apiSitesUrl}/products/${wProductId}/skus/${wSkuId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...this.creds.authHeader,
|
|
},
|
|
body: JSON.stringify({ sku: wSku }),
|
|
},
|
|
);
|
|
if (!res.ok) throw new WebflowError("Failed to update Webflow product SKU", res.status, await parsePayload(res));
|
|
}
|
|
}
|
|
|
|
class ProductsClient {
|
|
readonly Skus: SkusClient;
|
|
|
|
constructor(private creds: Credentials) {
|
|
this.Skus = new SkusClient(creds);
|
|
}
|
|
|
|
async create(
|
|
wProductAndSku: DeepPartial<Webflow.Products.ProductAndSku>,
|
|
): Promise<string> {
|
|
const res = await fetch(`${this.creds.apiSitesUrl}/products`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...this.creds.authHeader,
|
|
},
|
|
body: JSON.stringify(wProductAndSku),
|
|
});
|
|
if (!res.ok) throw new WebflowError("Failed to create Webflow product", res.status, await parsePayload(res));
|
|
const payload = (await res.json()) as { product: { id: string } };
|
|
return payload.product.id;
|
|
}
|
|
|
|
async list(opt: PagingOptions = {}): Promise<Webflow.Products.ProductAndSkus[]> {
|
|
const allWProducts: Webflow.Products.ProductAndSkus[] = [];
|
|
let offset = opt.offset ?? 0;
|
|
do {
|
|
const res = await fetch(`${this.creds.apiSitesUrl}/products?offset=${offset}&limit=${opt.limit ?? 100}`, {
|
|
method: "GET",
|
|
headers: this.creds.authHeader,
|
|
});
|
|
if (!res.ok) throw new WebflowError("Failed to list Webflow products", res.status, await parsePayload(res));
|
|
const payload = (await res.json()) as Webflow.MetaDataMulti<"items", Webflow.Products.ProductAndSkus>;
|
|
allWProducts.push(...payload.items);
|
|
offset = allWProducts.length;
|
|
if (allWProducts.length >= (payload?.pagination?.total ?? 0)) break;
|
|
} while (opt.forceAll);
|
|
return allWProducts;
|
|
}
|
|
|
|
async get(wProductId: string): Promise<Webflow.Products.ProductAndSkus | undefined> {
|
|
const res = await fetch(`${this.creds.apiSitesUrl}/products/${wProductId}`, {
|
|
method: "GET",
|
|
headers: this.creds.authHeader,
|
|
});
|
|
if (res.status === 404 || res.status === 400) return;
|
|
if (!res.ok) throw new WebflowError("Failed to get Webflow product", res.status, await parsePayload(res));
|
|
return (await res.json()) as Webflow.Products.ProductAndSkus;
|
|
}
|
|
|
|
async update(
|
|
wProductId: string,
|
|
wProduct: DeepPartial<Webflow.Products.ProductAndSku>,
|
|
): Promise<void> {
|
|
const res = await fetch(`${this.creds.apiSitesUrl}/products/${wProductId}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...this.creds.authHeader,
|
|
},
|
|
body: JSON.stringify(wProduct),
|
|
});
|
|
if (!res.ok) throw new WebflowError("Failed to update Webflow product", res.status, await parsePayload(res));
|
|
}
|
|
|
|
async remove(wProductId: string): Promise<void> {
|
|
const res = await fetch(
|
|
`${this.creds.apiCollectionsUrl}/items/${wProductId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...this.creds.authHeader,
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
);
|
|
if (!res.ok) throw new WebflowError("Failed to remove Webflow product", res.status, await parsePayload(res));
|
|
}
|
|
}
|
|
|
|
class OrdersClient {
|
|
constructor(private creds: Credentials) { }
|
|
|
|
async list(opt: PagingOptions & { status?: Webflow.Orders.Order["status"] } = {}): Promise<Webflow.Orders.Order[]> {
|
|
const allOrders: Webflow.Orders.Order[] = [];
|
|
let offset = opt.offset ?? 0;
|
|
do {
|
|
const params = new URLSearchParams();
|
|
if (opt.status !== undefined) params.set("status", opt.status);
|
|
params.set("offset", String(offset));
|
|
params.set("limit", String(opt.limit ?? 100));
|
|
const res = await fetch(`${this.creds.apiSitesUrl}/orders?${params.toString()}`, {
|
|
method: "GET",
|
|
headers: this.creds.authHeader,
|
|
});
|
|
if (!res.ok) throw new WebflowError("Failed to list Webflow orders", res.status, await parsePayload(res));
|
|
const payload = (await res.json()) as Webflow.MetaDataMulti<"orders", Webflow.Orders.Order>;
|
|
allOrders.push(...payload.orders);
|
|
offset = allOrders.length;
|
|
if (allOrders.length >= (payload?.pagination?.total ?? 0)) break;
|
|
} while (opt.forceAll);
|
|
return allOrders;
|
|
}
|
|
|
|
async update(
|
|
wOrderId: string,
|
|
wOrderUpdate: {
|
|
comment?: string;
|
|
shippingProvider?: string;
|
|
shippingTracking?: string;
|
|
shippingTrackingURL: string;
|
|
},
|
|
): Promise<void> {
|
|
const res = await fetch(`${this.creds.apiSitesUrl}/orders/${wOrderId}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...this.creds.authHeader,
|
|
},
|
|
body: JSON.stringify(wOrderUpdate),
|
|
});
|
|
if (!res.ok) throw new WebflowError("Failed to update Webflow order", res.status, await parsePayload(res));
|
|
}
|
|
|
|
async fulfill(
|
|
wOrderId: string,
|
|
opt: { sendOrderFulfilledEmail?: boolean } = {},
|
|
): Promise<void> {
|
|
const res = await fetch(`${this.creds.apiSitesUrl}/orders/${wOrderId}/fulfill`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...this.creds.authHeader,
|
|
},
|
|
body: JSON.stringify(opt),
|
|
});
|
|
if (!res.ok) throw new WebflowError("Failed to fulfill Webflow order", res.status, await parsePayload(res));
|
|
}
|
|
}
|
|
|
|
class WebhooksClient {
|
|
constructor(private creds: Credentials) { }
|
|
|
|
async list(opt: PagingOptions = {}): Promise<Webflow.Webhook.Config[]> {
|
|
const allWebhooks: Webflow.Webhook.Config[] = [];
|
|
let offset = opt.offset ?? 0;
|
|
do {
|
|
const res = await fetch(`${this.creds.apiSitesUrl}/webhooks?offset=${offset}&limit=${opt.limit ?? 100}`, {
|
|
method: "GET",
|
|
headers: this.creds.authHeader,
|
|
});
|
|
if (!res.ok) throw new WebflowError("Failed to list Webflow webhooks", res.status, await parsePayload(res));
|
|
const payload = (await res.json()) as Webflow.MetaDataMulti<"webhooks", Webflow.Webhook.Config>;
|
|
allWebhooks.push(...payload.webhooks);
|
|
offset = allWebhooks.length;
|
|
if (allWebhooks.length >= (payload?.pagination?.total ?? 0)) break;
|
|
} while (opt.forceAll);
|
|
return allWebhooks;
|
|
}
|
|
|
|
/**
|
|
* Limit of 75 registrations per event
|
|
*/
|
|
async create(url: string, event: Webflow.Webhook.Event) {
|
|
const res = await fetch(`${this.creds.apiSitesUrl}/webhooks`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...this.creds.authHeader,
|
|
},
|
|
body: JSON.stringify({
|
|
"url": url,
|
|
"triggerType": event
|
|
}),
|
|
});
|
|
if (!res.ok) throw new WebflowError("Failed to create Webflow webhook", res.status, await parsePayload(res));
|
|
}
|
|
|
|
async remove(webhookId: string): Promise<void> {
|
|
const res = await fetch(
|
|
`${this.creds.apiBaseUrl}/webhooks/${webhookId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...this.creds.authHeader,
|
|
},
|
|
body: JSON.stringify({}),
|
|
},
|
|
);
|
|
if (!res.ok) throw new WebflowError("Failed to remove Webflow webhook", res.status, await parsePayload(res));
|
|
}
|
|
}
|
|
|
|
class UtilClient {
|
|
constructor(private webhookSecret: string) { }
|
|
|
|
verifyWebflowSignature(request: Request, body: unknown): boolean {
|
|
const timestamp = request.headers.get("x-webflow-timestamp");
|
|
if (!timestamp) return false;
|
|
|
|
const providedSignature = request.headers.get("x-webflow-signature");
|
|
if (!providedSignature) return false;
|
|
|
|
if (!body) return false;
|
|
|
|
const requestTimestamp = parseInt(timestamp, 10);
|
|
if (Date.now() - requestTimestamp > 300000) return false;
|
|
|
|
const data = `${requestTimestamp}:${JSON.stringify(body)}`;
|
|
const hash = crypto
|
|
.createHmac("sha256", this.webhookSecret)
|
|
.update(data)
|
|
.digest("hex");
|
|
|
|
try {
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(hash, "hex"),
|
|
Buffer.from(providedSignature, "hex"),
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
export class WebflowClient {
|
|
readonly Products: ProductsClient;
|
|
readonly Orders: OrdersClient;
|
|
readonly Webhooks: WebhooksClient;
|
|
readonly Util: UtilClient;
|
|
|
|
constructor(options: ClientOptions) {
|
|
const creds: Credentials = {
|
|
apiBaseUrl: `https://api.webflow.com/v2`,
|
|
apiSitesUrl: `https://api.webflow.com/v2/sites/${options.siteId}`,
|
|
apiCollectionsUrl: `https://api.webflow.com/v2/collections/${options.collectionsId}`,
|
|
authHeader: { Authorization: `bearer ${options.token}` },
|
|
};
|
|
this.Products = new ProductsClient(creds);
|
|
this.Orders = new OrdersClient(creds);
|
|
this.Webhooks = new WebhooksClient(creds);
|
|
this.Util = new UtilClient(options.webhookSecret);
|
|
}
|
|
}
|