Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ await relay.buckets.findByName("orders");
await relay.buckets.delete(bucket.id); // delete its inputs/outputs first
```

`update` is a partial update: fields you leave out keep their current values.
The API's `PUT /v1/buckets/{id}` replaces the whole bucket, so the SDK fetches
the bucket, merges your changes over its editable fields (`name`,
`description`, `stream`, `ephemeral`, `static_ip`, `large_webhooks`, and
`auth` field by field) and sends the result. `workspace_id` is only sent when
you pass it (`null` ungroups the bucket). `outputs.update`,
`functions.update` and the service-connection `update*` methods work the same
way.

## Inputs

Inputs are the public HTTPS endpoints that receive webhooks.
Expand Down Expand Up @@ -138,6 +147,7 @@ await relay.outputs.setRules(bucket.id, output.id, {
});

await relay.outputs.deleteRules(bucket.id, output.id); // forward everything
await relay.outputs.update(bucket.id, output.id, { disabled: true }); // other fields are kept
await relay.outputs.delete(bucket.id, output.id);
```

Expand Down Expand Up @@ -215,10 +225,12 @@ await relay.serviceConnections.list();
Register each customer as a consumer with the HTTPS endpoint they give you, then
publish events. Webhook Relay signs every delivery
([Standard Webhooks](https://www.standardwebhooks.com/)), retries failures durably
for up to 48 hours and keeps the delivery history. Outbound webhooks are in pilot:
the account needs the `outbound` feature.
for up to 48 hours and keeps the delivery history. Check that outbound webhooks
are enabled for your account (and its limits) with `relay.outbound.access()`.

```ts
const { enabled, limits } = await relay.outbound.access();

// Once per customer.
await relay.outbound.eventTypes.upsert("invoice.paid", { description: "An invoice was paid" });
await relay.outbound.consumers.upsert("customer_42", { name: "Acme" });
Expand Down
31 changes: 31 additions & 0 deletions src/params.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
type Params = Record<string, unknown>;

const bucketAliases: Record<string, string> = {
staticIp: "static_ip",
largeWebhooks: "large_webhooks",
workspaceId: "workspace_id",
};

const inputAliases: Record<string, string> = {
functionId: "function_id",
statusCode: "status_code",
Expand Down Expand Up @@ -57,6 +63,10 @@ const outboundMessageAliases: Record<string, string> = {
eventId: "event_id",
};

export function bucketParams<T extends object>(params: T): T {
return applyAliases(params, bucketAliases) as T;
}

export function inputParams<T extends object>(params: T): T {
return applyAliases(params, inputAliases) as T;
}
Expand Down Expand Up @@ -85,6 +95,27 @@ export function outboundMessageParams<T extends object>(params: T): T {
return applyAliases(params, outboundMessageAliases) as T;
}

/**
* Overlay update params on the current state of a resource whose `PUT`
* replaces it whole, so callers can send only the fields they change.
* `undefined` values in `params` keep the current value; keys in `omit`
* (read-only or server-owned fields) are dropped from `current`.
*/
export function mergeForReplace(
current: object,
params: object,
omit: readonly string[] = [],
): Params {
const out: Params = {};
for (const [key, value] of Object.entries(current)) {
if (value !== undefined && !omit.includes(key)) out[key] = value;
}
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) out[key] = value;
}
return out;
}

function applyAliases(params: object, aliases: Record<string, string>): Params {
const out: Params = {};
for (const [key, value] of Object.entries(params)) {
Expand Down
76 changes: 72 additions & 4 deletions src/resources/buckets.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,59 @@
import type { HttpClient } from "../http.js";
import { bucketParams } from "../params.js";
import type {
Bucket,
BucketAuth,
CreateBucketParams,
UpdateBucketParams,
} from "../types.js";

/** Top-level bucket fields `PUT /v1/buckets/{id}` reads (besides `auth`). */
const BUCKET_UPDATE_FIELDS = [
"name",
"description",
"stream",
"ephemeral",
"static_ip",
"large_webhooks",
] as const;

/** Auth fields `PUT /v1/buckets/{id}` reads. */
const BUCKET_AUTH_FIELDS = ["type", "username", "password", "token"] as const;

function pick(source: object | undefined, keys: readonly string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (!source) return out;
for (const key of keys) {
const value = (source as Record<string, unknown>)[key];
if (value !== undefined) out[key] = value;
}
return out;
}

/**
* Build the full-replace body for `PUT /v1/buckets/{id}`: the current bucket's
* editable fields with `params` merged over them. `workspace_id` is only sent
* when the caller gave it, because the API treats a present `workspace_id`
* (even `null`) as a request to move the bucket.
*/
function bucketUpdateBody(
current: Bucket,
params: UpdateBucketParams,
): Record<string, unknown> {
const changes = bucketParams(params) as Record<string, unknown>;
const body: Record<string, unknown> = {
...pick(current, BUCKET_UPDATE_FIELDS),
...pick(changes, BUCKET_UPDATE_FIELDS),
};
const auth = {
...pick(current.auth, BUCKET_AUTH_FIELDS),
...pick(changes.auth as BucketAuth | undefined, BUCKET_AUTH_FIELDS),
};
if (Object.keys(auth).length > 0) body.auth = auth;
if (changes.workspace_id !== undefined) body.workspace_id = changes.workspace_id;
return body;
}

/**
* Manage buckets — the top-level containers that receive webhooks and route
* them to outputs.
Expand All @@ -29,7 +78,7 @@ export class BucketsResource {
/** Create a bucket. */
create(params: CreateBucketParams): Promise<Bucket> {
return this.http.unwrap(
this.http.api.v1.bucketsCreate(params as never),
this.http.api.v1.bucketsCreate(bucketParams(params) as never),
"POST",
"/v1/buckets",
);
Expand All @@ -44,10 +93,29 @@ export class BucketsResource {
);
}

/** Update a bucket. */
update(bucketId: string, params: UpdateBucketParams): Promise<Bucket> {
/**
* Update a bucket. Only the fields you pass change; the rest keep their
* current values.
*
* The API's `PUT /v1/buckets/{id}` replaces the whole bucket (and rejects a
* body without `name`), so this fetches the bucket first, merges `params`
* over its editable fields (`name`, `description`, `stream`, `ephemeral`,
* `static_ip`, `large_webhooks`, and `auth` field by field) and sends the
* result. That costs one extra `GET`, and a change made by someone else
* between the two requests is overwritten. `workspace_id` is sent only when
* you pass it. To clear a text field, pass `""`.
*
* ```ts
* await relay.buckets.update(bucket.id, { description: "Production orders" });
* ```
*/
async update(bucketId: string, params: UpdateBucketParams): Promise<Bucket> {
const current = await this.get(bucketId);
return this.http.unwrap(
this.http.api.v1.bucketsUpdate(encodeURIComponent(bucketId), params as never),
this.http.api.v1.bucketsUpdate(
encodeURIComponent(bucketId),
bucketUpdateBody(current, params) as never,
),
"PUT",
"/v1/buckets/{id}",
);
Expand Down
24 changes: 16 additions & 8 deletions src/resources/functions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { HttpClient } from "../http.js";
import { functionParams } from "../params.js";
import { functionParams, mergeForReplace } from "../params.js";
import type {
CreateFunctionParams,
FunctionConfigVariable,
Expand Down Expand Up @@ -60,19 +60,27 @@ export class FunctionsResource {
);
}

/** Update a function's name or source. */
/**
* Update a function's name or source. Omitted fields keep their current
* values: the API's `PUT /v1/functions/{id}` replaces the name, driver and
* source together (a missing `payload` would empty the code), so unless all
* three are given the function is fetched first and merged.
*/
async update(
functionId: string,
params: UpdateFunctionParams,
): Promise<WebhookFunction> {
let body = params;
if (params.name === undefined || params.driver === undefined) {
if (
params.name === undefined ||
params.driver === undefined ||
params.payload === undefined
) {
const current = await this.get(functionId);
body = {
name: current.name,
driver: current.driver,
...params,
};
body = mergeForReplace(
{ name: current.name, driver: current.driver, payload: current.payload },
params,
) as UpdateFunctionParams;
}

await this.http.unwrap(
Expand Down
31 changes: 31 additions & 0 deletions src/resources/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ListAllOutboundEndpointsParams,
ListOutboundDeliveriesParams,
ListOutboundMessagesParams,
OutboundAccess,
OutboundConsumer,
OutboundDelivery,
OutboundEndpoint,
Expand Down Expand Up @@ -346,6 +347,36 @@ export class OutboundResource {
this.recoveryTasks = new OutboundRecoveryTasksResource(http);
}

/**
* Report whether outbound webhooks are enabled for the account, with its
* limits (consumers, endpoints per consumer, event types). Works even when
* they are not enabled, unlike the other outbound methods.
*
* ```ts
* const { enabled, limits } = await relay.outbound.access();
* ```
*/
access(): Promise<OutboundAccess> {
return this.http.unwrap(
this.http.api.v1.outboundAccessList(),
"GET",
"/v1/outbound/access",
);
}

/**
* Ask support to enable outbound webhooks by opening a support ticket.
* Asking again while that ticket is open returns it; nothing is opened when
* outbound webhooks are already enabled.
*/
requestAccess(): Promise<OutboundAccess> {
return this.http.unwrap(
this.http.api.v1.outboundAccessRequestCreate(),
"POST",
"/v1/outbound/access/request",
);
}

/**
* Count your endpoints by state and their delivery attempts and failures
* over the last 24 hours, to the hour. Attempts are counted within about
Expand Down
38 changes: 34 additions & 4 deletions src/resources/outputs.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import type { HttpClient } from "../http.js";
import { outputParams } from "../params.js";
import { WebhookRelayAPIError } from "../errors.js";
import { mergeForReplace, outputParams } from "../params.js";
import type {
CreateOutputParams,
Output,
Rules,
UpdateOutputParams,
} from "../types.js";

/** Server-owned output fields left out of the replace body (the API keeps them). */
const OUTPUT_READ_ONLY = [
"id",
"bucket_id",
"rules",
"cron_id",
"created_at",
"updated_at",
"created_by",
] as const;

/**
* Manage a bucket's outputs — the destinations webhooks are forwarded to.
* Control forwarding with {@link OutputsResource.setRules | forward rules} and
Expand Down Expand Up @@ -37,17 +49,35 @@ export class OutputsResource {
);
}

/** Update an output. */
update(
/**
* Update an output. Only the fields you pass change; the rest keep their
* current values.
*
* The API's `PUT /v1/buckets/{id}/outputs/{outputId}` replaces the whole
* output (a missing `destination` or `function_id` would be cleared), so
* this reads the output from its bucket first and merges `params` over it.
* Forward rules are untouched; manage them with {@link setRules}.
*/
async update(
bucketId: string,
outputId: string,
params: UpdateOutputParams,
): Promise<Output> {
const current = (await this.list(bucketId)).find((o) => o.id === outputId);
if (!current) {
throw new WebhookRelayAPIError({
status: 404,
message: `output '${outputId}' not found in bucket '${bucketId}'`,
body: undefined,
method: "PUT",
path: "/v1/buckets/{id}/outputs/{outputId}",
});
}
return this.http.unwrap(
this.http.api.v1.bucketsOutputsUpdate(
encodeURIComponent(bucketId),
encodeURIComponent(outputId),
outputParams(params),
mergeForReplace(current, outputParams(params), OUTPUT_READ_ONLY) as never,
),
"PUT",
"/v1/buckets/{id}/outputs/{outputId}",
Expand Down
Loading
Loading