Skip to content

Commit

Permalink
Merge pull request #139 from hopinc/webhooks
Browse files Browse the repository at this point in the history
feat: Webhooks
  • Loading branch information
Looskie authored Oct 3, 2023
2 parents a508e89 + 85000d4 commit fc1e0c5
Show file tree
Hide file tree
Showing 11 changed files with 532 additions and 80 deletions.
5 changes: 5 additions & 0 deletions .changeset/blue-cars-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@onehop/js': minor
---

Adds webhooks SDK, types, and verification
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"dependencies": {
"@onehop/json-methods": "^1.2.0",
"cross-fetch": "^3.1.5",
"uncrypto": "^0.1.3",
"zod": "^3.21.4"
}
}
131 changes: 129 additions & 2 deletions src/rest/types/projects.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type {Empty, Id, Timestamp} from '../../util/types.ts';
import type {
Empty,
Id,
PossibleWebhookIDs,
Timestamp,
} from '../../util/types.ts';
import type {Endpoint} from '../endpoints.ts';
import type {User} from './users.ts';

Expand Down Expand Up @@ -204,6 +209,74 @@ export interface Secret {
in_use_by: Id<'deployment'>[];
}

/**
* Webhooks are used to send an event to an endpoint when something within Hop happens.
* @public
*/
export interface Webhook {
/**
* The time this webhook was created at
*/
created_at: Timestamp;
/**
* The events that this webhook is subscribed to
*/
events: PossibleWebhookIDs;
/**
* The ID of the webhook
*/
id: Id<'webhook'>;
/**
* The ID of the project this webhook is for
*/
project_id: Id<'project'>;
/**
* The secret for this webhook
* @warning This is censored after creation
* @example whsec_xxxxxxxx
*/
secret: string;
/**
* The type of the webhook
*/
type: 'http';
/**
* The URL that this webhook will send events to, acts as an endpoint
*/
webhook_url: string;
}

/**
* An event is sent from a webhook to an endpoint
*/
export interface Event<T extends PossibleWebhookIDs = PossibleWebhookIDs> {
/**
* The ID of the webhook that sent this event
*/
webhook_id: Id<'webhook'>;
/**
* The ID of the project that this event is for
*/
project_id: Id<'project'>;
/**
* The time this event occurred at
*/
occurred_at: string;
/**
* The ID of the event
*/
id: Id<'event'>;
/**
* The event that occurred
* @example ignite.deployment.container.updated
*/
event: T;
/**
* The data for this event
*/
data: unknown;
}

/**
* The endpoints for projects
* @public
Expand Down Expand Up @@ -259,4 +332,58 @@ export type ProjectsEndpoints =
| Endpoint<'GET', '/v1/projects/:project_id/secrets', {secrets: Secret[]}>
| Endpoint<'GET', '/v1/projects/@this/secrets', {secrets: Secret[]}>
| Endpoint<'DELETE', '/v1/projects/:project_id/secrets/:secret_id', Empty>
| Endpoint<'DELETE', '/v1/projects/@this/secrets/:secret_id', Empty>;
| Endpoint<'DELETE', '/v1/projects/@this/secrets/:secret_id', Empty>
| Endpoint<'GET', '/v1/projects/:project_id/webhooks', {webhooks: Webhook[]}>
| Endpoint<'GET', '/v1/projects/@this/webhooks', {webhooks: Webhook[]}>
| Endpoint<
'POST',
'/v1/projects/:project_id/webhooks',
{webhook: Webhook},
{
webhook_url: string;
events: PossibleWebhookIDs[];
}
>
| Endpoint<
'POST',
'/v1/projects/@this/webhooks',
{
webhook: Webhook;
},
{
webhook_url: string;
events: PossibleWebhookIDs[];
}
>
| Endpoint<
'PATCH',
'/v1/projects/:project_id/webhooks/:webhook_id',
{webhook: Webhook},
{
webhook_url: string | undefined;
events: PossibleWebhookIDs[] | undefined;
}
>
| Endpoint<
'PATCH',
'/v1/projects/@this/webhooks/:webhook_id',
{
webhook: Webhook;
},
{
webhook_url: string | undefined;
events: PossibleWebhookIDs[] | undefined;
}
>
| Endpoint<'DELETE', '/v1/projects/:project_id/webhooks/:webhook_id', Empty>
| Endpoint<'DELETE', '/v1/projects/@this/webhooks/:webhook_id', Empty>
| Endpoint<
'POST',
'/v1/projects/:project_id/webhooks/:webhook_id/regenerate',
{secret: Webhook['secret']}
>
| Endpoint<
'POST',
'/v1/projects/@this/webhooks/:webhook_id/regenerate',
{secret: Webhook['secret']}
>;
195 changes: 194 additions & 1 deletion src/sdks/projects.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {API, Endpoints, Id} from '../rest/index.ts';
import type {API, Endpoints, Event, Id} from '../rest/index.ts';
import {Request} from '../util/fetch.ts';
import {sdk} from './create.ts';
import type {PossibleWebhookIDs} from '../util/types.ts';
import {verifyHmac} from '../index.ts';

/**
* Projects SDK client
Expand Down Expand Up @@ -95,6 +97,195 @@ export const projects = sdk(client => {
},
};

const webhooks = {
/**
* Utility function that returns a type-safe webhook event, throws if signature is invalid.
*
* @param body The stringed body received from the request
* @param signature The signature from the X-Hop-Hooks-Signature
* @param secret The secret provided upon webhook creation to verify the signature. (e.x: whsec_xxxxx)
*/
async constructEvent(body: string, signature: string, secret: string) {
const hmacVerified = await verifyHmac(body, signature, secret);
if (!hmacVerified) {
throw new Error('Invalid signature');
}

const event = JSON.parse(body) as Event;
return event;
},
async getAll(projectId?: Id<'project'>) {
if (client.authType !== 'ptk' && !projectId) {
throw new Error(
'Project ID is required for bearer or PAT authentication to fetch all project members',
);
}

if (projectId) {
const {webhooks} = await client.get(
'/v1/projects/:project_id/webhooks',
{
project_id: projectId,
},
);

return webhooks;
}

const {webhooks} = await client.get('/v1/projects/@this/webhooks', {});

return webhooks;
},

async create(
webhook_url: string,
events: PossibleWebhookIDs[],
projectId?: Id<'project'>,
) {
if (client.authType !== 'ptk' && !projectId) {
throw new Error(
'Project ID is required for bearer or PAT authentication to create a webhook',
);
}

if (projectId) {
const {webhook} = await client.post(
'/v1/projects/:project_id/webhooks',
{
webhook_url,
events,
},
{
project_id: projectId,
},
);

return webhook;
}

const {webhook} = await client.post(
'/v1/projects/@this/webhooks',
{
webhook_url,
events,
},
{},
);

return webhook;
},

async edit(
webhookId: Id<'webhook'>,
{
events,
webhookUrl,
}: {
webhookUrl?: string | undefined;
events?: PossibleWebhookIDs[] | undefined;
},
projectId?: Id<'project'>,
) {
if (client.authType !== 'ptk' && !projectId) {
throw new Error(
'Project ID is required for bearer or PAT authentication to edit a webhook',
);
}

if (projectId) {
const {webhook} = await client.patch(
'/v1/projects/:project_id/webhooks/:webhook_id',
{
webhook_url: webhookUrl,
events,
},
{
project_id: projectId,
webhook_id: webhookId,
},
);

return webhook;
}

const {webhook} = await client.patch(
'/v1/projects/@this/webhooks/:webhook_id',
{
webhook_url: webhookUrl,
events,
},
{
webhook_id: webhookId,
},
);

return webhook;
},

async delete(webhookId: Id<'webhook'>, projectId?: Id<'project'>) {
if (client.authType !== 'ptk' && !projectId) {
throw new Error(
'Project ID is required for bearer or PAT authentication to delete a webhook',
);
}

if (projectId) {
await client.delete(
'/v1/projects/:project_id/webhooks/:webhook_id',
undefined,
{
project_id: projectId,
webhook_id: webhookId,
},
);

return;
}

await client.delete(
'/v1/projects/@this/webhooks/:webhook_id',
undefined,
{
webhook_id: webhookId,
},
);
},

async regenerateSecret(
webhookId: Id<'webhook'>,
projectId?: Id<'project'>,
) {
if (client.authType !== 'ptk' && !projectId) {
throw new Error(
'Project ID is required for bearer or PAT authentication to regenerate a webhook secret',
);
}

if (projectId) {
const {secret} = await client.post(
'/v1/projects/:project_id/webhooks/:webhook_id/regenerate',
undefined,
{
project_id: projectId,
webhook_id: webhookId,
},
);

return secret;
}

const {secret} = await client.post(
'/v1/projects/@this/webhooks/:webhook_id/regenerate',
undefined,
{
webhook_id: webhookId,
},
);

return secret;
},
};

const projectsSDK = {
async getAllMembers(projectId?: Id<'project'>) {
if (client.authType !== 'ptk' && !projectId) {
Expand Down Expand Up @@ -145,6 +336,8 @@ export const projects = sdk(client => {

tokens,

webhooks,

secrets: {
/**
* Gets all secrets in a project
Expand Down
4 changes: 4 additions & 0 deletions src/util/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import * as cryptoPonyfill from 'uncrypto';

export const HAS_NATIVE_CRYPTO = typeof globalThis.crypto !== 'undefined';
export const crypto = HAS_NATIVE_CRYPTO ? globalThis.crypto : cryptoPonyfill;
Loading

0 comments on commit fc1e0c5

Please sign in to comment.