@formfeed/sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,27 @@ const job = await client.renders.batch({
43
43
  const finished = await client.jobs.waitFor(job.id);
44
44
  ```
45
45
 
46
+ ## Word and PowerPoint
47
+
48
+ ```ts
49
+ import { readFile } from 'node:fs/promises';
50
+
51
+ // a template is a .docx or .pptx with tags such as {{ customer.name }} in its text
52
+ await client.templates.create({
53
+ name: 'Offer',
54
+ slug: 'offer',
55
+ kind: 'docx',
56
+ engine: 'jinja2',
57
+ file: { data: await readFile('offer.docx'), name: 'offer.docx' },
58
+ publish: true,
59
+ });
60
+
61
+ const filled = await client.renders.create({ template: 'offer', output: 'docx', data }); // or output: 'pdf'
62
+
63
+ // any office document to PDF (Word, Excel, PowerPoint, OpenDocument, RTF)
64
+ const pdf = await client.pdf.convert({ file: { data: await readFile('report.xlsx'), name: 'report.xlsx' } }, { single_page_sheets: true });
65
+ ```
66
+
46
67
  ## Webhooks
47
68
 
48
69
  ```ts
package/index.cjs CHANGED
@@ -28,6 +28,22 @@ __export(index_exports, {
28
28
  module.exports = __toCommonJS(index_exports);
29
29
 
30
30
  // packages/sdk-ts/src/lib/client.ts
31
+ var isOfficeInput = (input) => "file" in input;
32
+ function blobOf(file) {
33
+ const blob = file.data instanceof Blob ? file.data : new Blob([file.data]);
34
+ const own = typeof File !== "undefined" && file.data instanceof File ? file.data.name : void 0;
35
+ return { blob, name: file.name ?? own ?? "document" };
36
+ }
37
+ function multipart(fields, file) {
38
+ const form = new FormData();
39
+ const { blob, name } = blobOf(file);
40
+ form.set("file", blob, name);
41
+ for (const [key, value] of Object.entries(fields)) {
42
+ if (value === void 0) continue;
43
+ form.set(key, typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value));
44
+ }
45
+ return form;
46
+ }
31
47
  var FormfeedError = class extends Error {
32
48
  constructor(code, message, status, problem = null, requestId = null) {
33
49
  super(message);
@@ -54,6 +70,8 @@ function randomKey() {
54
70
  if (c?.randomUUID) return c.randomUUID();
55
71
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
56
72
  }
73
+ var withKey = (options) => ({ ...options, idempotencyKey: options.idempotencyKey ?? randomKey() });
74
+ var idOf = (source) => typeof source === "string" ? source : source.id;
57
75
  var Formfeed = class {
58
76
  baseUrl;
59
77
  apiKey;
@@ -117,11 +135,96 @@ var Formfeed = class {
117
135
  },
118
136
  /** Removes the stored files of a render before they expire. Needs the `file:delete` scope. */
119
137
  deleteOutputs: (id, options = {}) => this.request("DELETE", `/renders/${encodeURIComponent(id)}/outputs`, void 0, options),
138
+ /**
139
+ * The stored request of a render, to render it again. Needs `render:input`; throws
140
+ * `render_input_expired` (410) when the workspace keeps no requests or the period ended.
141
+ */
142
+ input: (id, options = {}) => this.request("GET", `/renders/${encodeURIComponent(id)}/input`, void 0, options),
120
143
  batch: (request, options = {}) => this.request("POST", "/renders/batch", request, {
121
144
  ...options,
122
145
  idempotencyKey: options.idempotencyKey ?? randomKey()
123
146
  })
124
147
  };
148
+ /**
149
+ * PDF tools on the outputs of earlier renders (a `Render` or its id) and on PDFs of the file library
150
+ * (a `LibraryFile`, its id or its name). merge, protect and watermark return a new render and cost
151
+ * 0.5 units each on live keys; info is free.
152
+ */
153
+ pdf = {
154
+ merge: (sources, output = {}, options = {}) => this.request("POST", "/pdf/merge", { ...output, sources: sources.map(idOf) }, withKey(options)),
155
+ protect: (source, protect, options = {}) => this.request("POST", "/pdf/protect", { ...protect, source: idOf(source) }, withKey(options)),
156
+ watermark: (source, watermark, options = {}) => this.request("POST", "/pdf/watermark", { ...watermark, source: idOf(source) }, withKey(options)),
157
+ info: (source, options = {}) => this.request("POST", "/pdf/info", { source: idOf(source) }, options),
158
+ /**
159
+ * Converts an office document to a PDF render (Starter plan and above): an uploaded file (Word,
160
+ * Excel, PowerPoint, OpenDocument, RTF or HTML, up to 20 MB, not kept), or the output of a Word or
161
+ * PowerPoint template render. Costs follow the PDF rule. A busy converter answers 503, which is
162
+ * retried like any other.
163
+ */
164
+ convert: (source, convert = {}, options = {}) => {
165
+ if (typeof source === "object" && "file" in source)
166
+ return this.request("POST", "/pdf/convert", multipart({ ...convert }, source.file), withKey(options));
167
+ return this.request("POST", "/pdf/convert", { ...convert, source: idOf(source) }, withKey(options));
168
+ }
169
+ };
170
+ /**
171
+ * The workspace file library (`/files`): images and PDFs a template references by name with
172
+ * `asset('logo.png')`, and the sources of image watermarks and merges. Needs `file:read`,
173
+ * `file:write` or `file:delete`. Files are served without a signature, so keep confidential
174
+ * documents out of it.
175
+ */
176
+ files = {
177
+ /** Uploads a file; the same name replaces the existing file in place and keeps its URL. */
178
+ upload: (file, options = {}) => {
179
+ const blob = file.data instanceof Blob ? file.data : new Blob([file.data], file.contentType ? { type: file.contentType } : {});
180
+ const form = new FormData();
181
+ form.set("file", blob, file.name);
182
+ form.set("name", file.name);
183
+ return this.request("POST", "/files", form, options);
184
+ },
185
+ /** Page of files, newest first. */
186
+ list: (query = {}, options = {}) => {
187
+ const params = new URLSearchParams();
188
+ for (const [k, v] of Object.entries(query)) if (v !== void 0 && v !== null && v !== "") params.set(k, String(v));
189
+ const qs = params.toString();
190
+ return this.request("GET", `/files${qs ? `?${qs}` : ""}`, void 0, options);
191
+ },
192
+ /** Every file matching the query, following the cursor. */
193
+ all: async (query = {}, options = {}) => {
194
+ const out = [];
195
+ let cursor = null;
196
+ do {
197
+ const page = await this.files.list({ ...query, cursor }, options);
198
+ out.push(...page.data);
199
+ cursor = page.next_cursor;
200
+ } while (cursor);
201
+ return out;
202
+ },
203
+ get: (id, options = {}) => this.request("GET", `/files/${encodeURIComponent(id)}`, void 0, options),
204
+ /** Removes the file and its bytes; templates that name it render a missing image afterwards. */
205
+ delete: (file, options = {}) => this.request("DELETE", `/files/${encodeURIComponent(idOf(file))}`, void 0, options)
206
+ };
207
+ /** The organisation's brand kit, read-only through the API (it is edited in the app). Needs `template:read`. */
208
+ brand = {
209
+ get: (options = {}) => this.request("GET", "/brand", void 0, options)
210
+ };
211
+ /**
212
+ * Shared partials of the organisation. Renders use the current source, so a change reaches every
213
+ * template that includes the partial without a new version. Needs `template:read` or `template:write`.
214
+ */
215
+ partials = {
216
+ /** Every shared partial, without its source. */
217
+ list: async (options = {}) => (await this.request("GET", "/partials", void 0, options)).data,
218
+ /** One partial with its source. */
219
+ get: (name, options = {}) => this.request("GET", `/partials/${encodeURIComponent(name)}`, void 0, options),
220
+ /** Creates or replaces a partial; pass `base_version` to fail with 409 when it changed since you read it. */
221
+ put: async (name, input, options = {}) => {
222
+ const { status, body } = await this.send("PUT", `/partials/${encodeURIComponent(name)}`, input, options);
223
+ return { partial: body, created: status === 201 };
224
+ },
225
+ /** Removes a partial; templates that still include it fail to render afterwards. */
226
+ delete: (name, options = {}) => this.request("DELETE", `/partials/${encodeURIComponent(name)}`, void 0, options)
227
+ };
125
228
  jobs = {
126
229
  get: (id, options = {}) => this.request("GET", `/jobs/${encodeURIComponent(id)}`, void 0, options),
127
230
  waitFor: async (id, options = {}) => {
@@ -141,7 +244,20 @@ var Formfeed = class {
141
244
  create: (input, options = {}) => this.request("POST", "/webhooks", input, options),
142
245
  update: (id, patch, options = {}) => this.request("PUT", `/webhooks/${encodeURIComponent(id)}`, patch, options),
143
246
  delete: (id, options = {}) => this.request("DELETE", `/webhooks/${encodeURIComponent(id)}`, void 0, options),
144
- test: (id, options = {}) => this.request("POST", `/webhooks/${encodeURIComponent(id)}/test`, void 0, options)
247
+ test: (id, options = {}) => this.request("POST", `/webhooks/${encodeURIComponent(id)}/test`, void 0, options),
248
+ /**
249
+ * Sends a stored event (`evt_…`) again as a new delivery to a registered endpoint (`webhook:manage`)
250
+ * or a running listen session (`webhook:listen`).
251
+ */
252
+ resend: (eventId, endpointId, options = {}) => this.request("POST", `/webhooks/events/${encodeURIComponent(eventId)}/resend`, { endpoint_id: endpointId }, options),
253
+ /**
254
+ * Listen sessions, what `formfeed listen` uses. Needs `webhook:listen`. Starting a session ends the
255
+ * previous one of the same key; the WebSocket itself is up to the caller.
256
+ */
257
+ listen: {
258
+ start: (input = {}, options = {}) => this.request("POST", "/webhooks/listen", input, options),
259
+ end: (id, options = {}) => this.request("DELETE", `/webhooks/listen/${encodeURIComponent(id)}`, void 0, options)
260
+ }
145
261
  };
146
262
  templates = {
147
263
  list: (query = {}, options = {}) => {
@@ -162,32 +278,127 @@ var Formfeed = class {
162
278
  return out;
163
279
  },
164
280
  get: (idOrSlug, options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}`, void 0, options),
165
- create: (input, options = {}) => this.request("POST", "/templates", input, options),
281
+ /** A Word or PowerPoint template (`kind` `docx` or `pptx`) is created from its `file`, sent as multipart. */
282
+ create: (input, options = {}) => {
283
+ if (isOfficeInput(input) && input.file) {
284
+ const { file, ...fields } = input;
285
+ return this.request("POST", "/templates", multipart(fields, file), options);
286
+ }
287
+ return this.request("POST", "/templates", input, options);
288
+ },
166
289
  update: (idOrSlug, patch, options = {}) => this.request("PUT", `/templates/${encodeURIComponent(idOrSlug)}`, patch, options),
167
290
  archive: (idOrSlug, options = {}) => this.request("DELETE", `/templates/${encodeURIComponent(idOrSlug)}`, void 0, options),
168
291
  versions: {
169
292
  list: async (idOrSlug, options = {}) => (await this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/versions`, void 0, options)).data,
170
- /** `which`: 'published', 'latest' or a version number. Carries the files. */
171
- get: (idOrSlug, which = "published", options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/versions/${which}`, void 0, options),
172
- create: (idOrSlug, input, options = {}) => this.request("POST", `/templates/${encodeURIComponent(idOrSlug)}/versions`, input, options),
173
- publish: (idOrSlug, number, options = {}) => this.request("POST", `/templates/${encodeURIComponent(idOrSlug)}/versions/${number}/publish`, void 0, options)
293
+ /** `which`: 'published', 'latest', a version number or a channel name (its main version). Carries the files. */
294
+ get: (idOrSlug, which = "published", options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/versions/${encodeURIComponent(String(which))}`, void 0, options),
295
+ /** For a Word or PowerPoint template, pass `file` to replace the document; without it the latest file is kept. */
296
+ create: (idOrSlug, input, options = {}) => {
297
+ const path = `/templates/${encodeURIComponent(idOrSlug)}/versions`;
298
+ if (isOfficeInput(input)) {
299
+ const { file, ...fields } = input;
300
+ if (file) return this.request("POST", path, multipart(fields, file), options);
301
+ return this.request("POST", path, fields, options);
302
+ }
303
+ return this.request("POST", path, input, options);
304
+ },
305
+ /** The Word or PowerPoint file of a version (`which` as for `get`); needs `template:read`. */
306
+ file: async (idOrSlug, which = "published", options = {}) => {
307
+ const path = `/templates/${encodeURIComponent(idOrSlug)}/versions/${encodeURIComponent(String(which))}/file`;
308
+ return (await this.send("GET", path, void 0, options, "bytes")).body;
309
+ },
310
+ /**
311
+ * Publishes a version. When both versions store a data schema and the new one breaks callers of the
312
+ * published one, the API refuses with `schema_breaking_change` unless `allowBreaking` is set.
313
+ */
314
+ publish: (idOrSlug, number, options = {}) => {
315
+ const { allowBreaking, ...rest } = options;
316
+ return this.request(
317
+ "POST",
318
+ `/templates/${encodeURIComponent(idOrSlug)}/versions/${number}/publish`,
319
+ allowBreaking ? { allow_breaking: true } : void 0,
320
+ rest
321
+ );
322
+ }
174
323
  },
175
- schema: (idOrSlug, options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/schema`, void 0, options)
324
+ /**
325
+ * Release channels (`published` and named ones such as `staging`): a render with `version: 'staging'`
326
+ * uses the version the channel points at. Moves reach renders within a minute.
327
+ */
328
+ channels: {
329
+ list: (idOrSlug, options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/channels`, void 0, options),
330
+ /** Creates or moves a channel; on `published` this publishes. */
331
+ set: (idOrSlug, name, move, options = {}) => {
332
+ const { allowBreaking, ...rest } = options;
333
+ return this.request(
334
+ "PUT",
335
+ `/templates/${encodeURIComponent(idOrSlug)}/channels/${encodeURIComponent(name)}`,
336
+ { ...move, ...allowBreaking ? { allow_breaking: true } : {} },
337
+ rest
338
+ );
339
+ },
340
+ /** The canary becomes the main version. */
341
+ promote: (idOrSlug, name, options = {}) => this.channelAction(idOrSlug, name, "promote", options),
342
+ /** Back to the version before the last move. */
343
+ rollback: (idOrSlug, name, options = {}) => this.channelAction(idOrSlug, name, "rollback", options),
344
+ /** Refused with `channel_in_use` while renders of the last hour used the channel, unless `force`. */
345
+ delete: (idOrSlug, name, options = {}) => {
346
+ const { force, ...rest } = options;
347
+ return this.request(
348
+ "DELETE",
349
+ `/templates/${encodeURIComponent(idOrSlug)}/channels/${encodeURIComponent(name)}${force ? "?force=true" : ""}`,
350
+ void 0,
351
+ rest
352
+ );
353
+ }
354
+ },
355
+ schema: (idOrSlug, options = {}) => this.request("GET", `/templates/${encodeURIComponent(idOrSlug)}/schema`, void 0, options),
356
+ /**
357
+ * Checks a version with data without rendering: syntax, header and footer, missing partials,
358
+ * runtime errors and the data against the stored schema. Free. Without `data`, the version's
359
+ * sample data is checked.
360
+ */
361
+ validate: (idOrSlug, input = {}, options = {}) => this.request("POST", `/templates/${encodeURIComponent(idOrSlug)}/validate`, input, options)
176
362
  };
177
363
  account = {
178
364
  get: (options = {}) => this.request("GET", "/account", void 0, options),
179
365
  /** Units of the current period, or of `YYYY-MM`, with a daily series and a breakdown per template. */
180
366
  usage: (period = "current", options = {}) => this.request("GET", `/usage?period=${encodeURIComponent(period)}`, void 0, options)
181
367
  };
368
+ /** Workspaces of the key's organisation. Needs `workspace:delete`, which no key has by default. */
369
+ workspaces = {
370
+ /**
371
+ * Deletes a workspace: it leaves the app and the API at once, its keys are revoked and its content
372
+ * is removed after 30 days. The organisation's last workspace is refused with `last_workspace`.
373
+ */
374
+ delete: (id, options = {}) => this.request("DELETE", `/workspaces/${encodeURIComponent(id)}`, void 0, options)
375
+ };
376
+ channelAction(idOrSlug, name, action, options) {
377
+ const { allowBreaking, ...rest } = options;
378
+ return this.request(
379
+ "POST",
380
+ `/templates/${encodeURIComponent(idOrSlug)}/channels/${encodeURIComponent(name)}/${action}`,
381
+ allowBreaking ? { allow_breaking: true } : void 0,
382
+ rest
383
+ );
384
+ }
182
385
  /** Raw request with auth, retries and problem mapping; use it for endpoints without a helper. */
183
386
  async request(method, path, body, options = {}) {
387
+ return (await this.send(method, path, body, options)).body;
388
+ }
389
+ /**
390
+ * `request` with the HTTP status, for endpoints whose answer depends on it (201 created, 200 updated).
391
+ * `bytes` returns a successful body as bytes (file downloads); problems are JSON either way.
392
+ */
393
+ async send(method, path, body, options = {}, as = "json") {
184
394
  const url = `${this.baseUrl}${path}`;
185
395
  const headers = {
186
396
  authorization: `Bearer ${this.apiKey}`,
187
- accept: "application/json",
188
- "user-agent": "formfeed-sdk-ts/0.1"
397
+ accept: as === "bytes" ? "*/*" : "application/json",
398
+ "user-agent": "formfeed-sdk-ts/0.3"
189
399
  };
190
- if (body !== void 0) headers["content-type"] = "application/json";
400
+ const isForm = typeof FormData !== "undefined" && body instanceof FormData;
401
+ if (body !== void 0 && !isForm) headers["content-type"] = "application/json";
191
402
  if (options.idempotencyKey) headers["idempotency-key"] = options.idempotencyKey;
192
403
  let attempt = 0;
193
404
  for (; ; ) {
@@ -199,7 +410,7 @@ var Formfeed = class {
199
410
  res = await this.fetchImpl(url, {
200
411
  method,
201
412
  headers,
202
- body: body === void 0 ? void 0 : JSON.stringify(body),
413
+ body: body === void 0 ? void 0 : isForm ? body : JSON.stringify(body),
203
414
  signal: controller.signal
204
415
  });
205
416
  } catch (e) {
@@ -217,9 +428,15 @@ var Formfeed = class {
217
428
  await sleep(this.backoff(attempt, res.headers.get("retry-after")), options.signal);
218
429
  continue;
219
430
  }
220
- if (res.status === 204) return void 0;
431
+ if (res.status === 204) return { status: 204, body: void 0 };
432
+ if (as === "bytes" && res.ok) return { status: res.status, body: new Uint8Array(await res.arrayBuffer()) };
221
433
  const text = await res.text();
222
- const json = text ? JSON.parse(text) : null;
434
+ let json = null;
435
+ try {
436
+ json = text ? JSON.parse(text) : null;
437
+ } catch {
438
+ if (res.ok) throw new FormfeedError("invalid_response", `the API answered HTTP ${res.status} with a body that is not JSON`, res.status);
439
+ }
223
440
  if (!res.ok) {
224
441
  const problem = json ?? {};
225
442
  throw new FormfeedError(
@@ -230,7 +447,7 @@ var Formfeed = class {
230
447
  res.headers.get("x-request-id")
231
448
  );
232
449
  }
233
- return json;
450
+ return { status: res.status, body: json };
234
451
  }
235
452
  }
236
453
  backoff(attempt, retryAfter) {