import { isEncodable, type Encodable } from "@automate.ax/codec" import { isPlainObject } from "@zachsents/zippy" import { omit } from "remeda" import { WebflowClient } from "webflow-api" import * as z from "zod" import type { DefinedAction } from "../../automation/actions" import type { ResolvedIntegrationAccount } from "../../automation/integrations" const WEBFLOW_SECRET_SCHEMA = z.object({ accessToken: z.string().min(1) }) const WEBFLOW_OBJECT_SCHEMA = z.custom>( (value) => isPlainObject(value) && isEncodable(value), { message: "Expected an encodable Webflow object." }, ) type InputObject = Record interface WebflowRequestSerializer { json( value: unknown, options?: { unrecognizedObjectKeys?: "fail" | "passthrough" | "strip" }, ): { ok: boolean } } /** Codec-safe form of an official Webflow SDK type. */ export type WebflowValue = unknown extends T ? Encodable : T extends void ? void : T extends Date ? string : T extends readonly (infer TItem)[] ? WebflowValue[] : T extends object ? { [TKey in keyof T]: WebflowValue } & Encodable : T extends Encodable ? T : never /** Official Webflow request type accepted by an action. */ export type WebflowInputValue = T extends unknown ? T extends Date ? string : T extends readonly (infer TItem)[] ? WebflowInputValue[] : T extends object ? { [TKey in keyof T]: WebflowInputValue } : T : never /** Portable public type for one Webflow-backed action definition. */ export type WebflowDefinedAction< TInput extends InputObject, TOutput, > = DefinedAction< z.ZodType, z.ZodType>, "webflow" > /** * Keeps declaration emit on public Webflow SDK names. * * @param action - Webflow-backed action definition. */ export function webflowAction( action: WebflowDefinedAction, ): WebflowDefinedAction { return action } /** * Creates the official authenticated Webflow Data API client. * * @param account - Resolved Webflow integration account. */ export function getWebflowApi(account: ResolvedIntegrationAccount<"webflow">) { return new WebflowClient({ accessToken: getWebflowAccessToken(account) }) } /** * Returns the bearer token for direct Webflow Data API v2 requests. * * @param account - Resolved Webflow integration account. */ export function getWebflowAccessToken( account: ResolvedIntegrationAccount<"webflow">, ) { return WEBFLOW_SECRET_SCHEMA.parse(account.secret).accessToken } /** * Sends an authenticated request to a current Webflow Data API v2 endpoint. * * @param account - Resolved Webflow integration account. * @param path - Data API v2 path relative to the Webflow API origin. * @param options - Optional method, query, and JSON body. * @param options.body - JSON request body. * @param options.method - HTTP request method. * @param options.query - Query parameters, including nested filter objects. */ export async function requestWebflowApi( account: ResolvedIntegrationAccount<"webflow">, path: string, options: { body?: InputObject method?: "DELETE" | "GET" | "PATCH" | "POST" | "PUT" query?: InputObject } = {}, ): Promise { const url = new URL(path.replace(/^\//, ""), "https://api.webflow.com/v2/") for (const [key, value] of Object.entries(options.query ?? {})) { appendWebflowQuery(url.searchParams, key, value) } const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${getWebflowAccessToken(account)}`, }) if (options.body !== undefined) headers.set("Content-Type", "application/json") const response = await fetch(url, { body: options.body === undefined ? undefined : JSON.stringify(options.body), headers, method: options.method ?? "GET", }) if (!response.ok) { const error = z .object({ message: z.string().optional() }) .loose() .safeParse(await response.json().catch(() => ({}))) throw new Error( error.success && error.data.message ? `Webflow API error (${response.status}): ${error.data.message}` : `Webflow API request failed with status ${response.status}.`, ) } if (response.status === 204) return undefined return await response.json() } /* oxlint-disable typescript/no-unnecessary-type-parameters -- The SDK call context infers the provider request type from this return. */ /** * Removes path identifiers before passing request fields to the Webflow SDK. * * @param input - Action input containing path and request fields. * @param keys - Path identifier keys to remove. */ export function webflowRequest( input: InputObject, ...keys: string[] ): TRequest { return z .custom((value) => isPlainObject(value)) .parse(omit(input, keys)) } /* oxlint-enable typescript/no-unnecessary-type-parameters */ /** Runtime schema for an official Webflow response type. */ export function webflowObjectSchema(): z.ZodType> { return z.unknown().transform((value, context) => { try { const normalized = normalizeWebflowValue(value) if (!isPlainObject(normalized)) throw new TypeError("Expected an object.") return z .custom< WebflowValue >((candidate) => isPlainObject(candidate) && isEncodable(candidate)) .parse(normalized) } catch { context.addIssue({ code: "custom", message: "Expected an encodable Webflow response.", }) return z.NEVER } }) } /** Runtime schema for Webflow mutations that return no response body. */ export function webflowVoidSchema(): z.ZodType> { return z.void() } /** * Runtime schema for an official Webflow request plus concrete constraints. * * @param schema - Concrete validation applied before the codec check. * @param requestSerializer - Official serializer for non-parameter fields. */ export function webflowInputSchema( schema: z.ZodObject, requestSerializer?: WebflowRequestSerializer, ): z.ZodType> { return z.custom>( (value) => { if ( !isPlainObject(value) || !schema.safeParse(value).success || !WEBFLOW_OBJECT_SCHEMA.safeParse(value).success ) return false const parameterKeys = Object.keys(schema.shape) if (requestSerializer === undefined) return Object.keys(value).every((key) => parameterKeys.includes(key)) return requestSerializer.json(omit(value, parameterKeys), { unrecognizedObjectKeys: "fail", }).ok }, { message: "Expected valid Webflow request parameters." }, ) } /** * Runtime schema backed by an official Webflow request serializer. * * @param requestSerializer - Official serializer for the request body. */ export function webflowSerializedSchema( requestSerializer: WebflowRequestSerializer, ) { return z.custom( (value) => isPlainObject(value) && requestSerializer.json(value, { unrecognizedObjectKeys: "fail" }).ok, { message: "Expected a valid Webflow request body." }, ) } /** Shared concrete schemas for Webflow action inputs. */ export const webflowActionSchemas = { comments: (...keys: string[]) => z .object({ ...Object.fromEntries( keys.map((key) => [key, z.string().trim().min(1)]), ), limit: z.number().int().min(1).max(100).optional(), localeId: z.string().trim().min(1).optional(), offset: z.number().int().nonnegative().optional(), sortBy: z.enum(["createdOn", "lastUpdated"]).optional(), sortOrder: z.enum(["asc", "desc"]).optional(), }) .loose(), empty: z.object({}), id: (key: string) => z.object({ [key]: z.string().trim().min(1) }).loose(), ids: (...keys: string[]) => z .object( Object.fromEntries(keys.map((key) => [key, z.string().trim().min(1)])), ) .loose(), paginated: (...keys: string[]) => z .object({ ...Object.fromEntries( keys.map((key) => [key, z.string().trim().min(1)]), ), limit: z.number().int().min(1).max(100).optional(), offset: z.number().int().nonnegative().optional(), }) .loose(), parameters: (keys: string[], shape: Record = {}) => z .object({ ...Object.fromEntries( keys.map((key) => [key, z.string().trim().min(1)]), ), ...shape, }) .loose(), pagination: z .object({ limit: z.number().int().min(1).max(100).optional(), offset: z.number().int().nonnegative().optional(), }) .loose(), } /** * Converts official Webflow response dates into portable ISO strings. * * @param value - Value returned by the official Webflow SDK. * @throws When Webflow returns a value the automation codec cannot encode. */ export function normalizeWebflowValue(value: unknown): Encodable { if (value instanceof Date) return value.toISOString() if (Array.isArray(value)) return value.map(normalizeWebflowValue) if (isPlainObject(value)) { return Object.fromEntries( Object.entries(value) .filter(([, entry]) => entry !== undefined) .map(([key, entry]) => [key, normalizeWebflowValue(entry)]), ) } if (isEncodable(value)) return value throw new TypeError("Webflow returned a non-encodable value.") } /** * Appends a nested Webflow query value using indexed bracket notation. * * @param searchParams - URL query parameters to update. * @param key - Current query parameter key. * @param value - Query value to serialize. */ function appendWebflowQuery( searchParams: URLSearchParams, key: string, value: unknown, ) { if (value === undefined) return if (Array.isArray(value)) { for (const [index, item] of value.entries()) { appendWebflowQuery(searchParams, `${key}[${index}]`, item) } } else if (isPlainObject(value)) { for (const [nestedKey, item] of Object.entries(value)) { appendWebflowQuery(searchParams, `${key}[${nestedKey}]`, item) } } else { const primitive = z .union([z.string(), z.number(), z.boolean(), z.null()]) .parse(value) searchParams.set(key, primitive === null ? "null" : String(primitive)) } }