Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: webhooks #22

Merged
merged 6 commits into from
Jun 27, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"@under_koen/bsm": "^1.3.3",
"@under_koen/bsm": "^1.5.0",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-no-relative-import-paths": "^1.5.3",
Expand Down
5 changes: 4 additions & 1 deletion package.scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ module.exports = {
$env: "file:.env",
_ci: "jest --runInBand --forceExit --detectOpenHandles",
_default: "jest",
coverage: "bsm ~ -- --coverage",
coverage: {
$alias: "cov",
_default: "bsm test -- --coverage",
},
},
},
};
71 changes: 71 additions & 0 deletions src/PrintOne.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ import { ICsvOrder } from "~/models/_interfaces/ICsvOrder";
import { Batch, CreateBatch } from "~/models/Batch";
import { IBatch } from "~/models/_interfaces/IBatch";
import { BatchStatus } from "~/enums/BatchStatus";
import * as crypto from "crypto";
import { Webhook } from "~/models/Webhook";
import { CreateWebhook, IWebhook } from "~/models/_interfaces/IWebhook";
import { WebhookRequest, webhookRequestFactory } from "~/models/WebhookRequest";
import { IWebhookRequest } from "~/models/_interfaces/IWebhookRequest";

export type RequestHandler = new (
token: string,
Expand Down Expand Up @@ -519,4 +524,70 @@ export class PrintOne {
(data) => new Batch(this.protected, data),
);
}

public isValidWebhook(
body: string,
headers: Record<string, string>,
secret: string,
): boolean {
const hmacHeader = headers["x-printone-hmac-sha256"];

const hmac = crypto
.createHmac("sha256", secret)
.update(body)
.digest("base64");

return hmac === hmacHeader;
}

public validateWebhook(
body: string,
headers: Record<string, string>,
secret: string,
): WebhookRequest {
if (!this.isValidWebhook(body, headers, secret)) {
throw new Error("Invalid webhook");
}

const webhook = JSON.parse(body) as IWebhookRequest;
return webhookRequestFactory(this.protected, webhook);
}

public async getWebhooks(): Promise<PaginatedResponse<Webhook>> {
const data =
await this.client.GET<IPaginatedResponse<IWebhook>>("webhooks");

return PaginatedResponse.safe(
this.protected,
data,
(data) => new Webhook(this.protected, data),
);
}

public async getWebhook(id: string): Promise<Webhook> {
const data = await this.client.GET<IWebhook>(`webhooks/${id}`);

return new Webhook(this.protected, data);
}

public async createWebhook(data: CreateWebhook): Promise<Webhook> {
const response = await this.client.POST<IWebhook>("webhooks", {
name: data.name,
url: data.url,
events: data.events,
active: data.active,
headers: data.headers,
secretHeaders: data.secretHeaders,
});

return new Webhook(this.protected, response);
}

public async getWebhookSecret(): Promise<string> {
const data = await this.client.GET<{
secret: string;
}>(`webhooks/secret`);

return data.secret;
}
}
6 changes: 6 additions & 0 deletions src/enums/WebhookEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const WebhookEvent = {
order_status_update: "order_status_update",
template_preview_rendered: "template_preview_rendered",
} as const;

export type WebhookEvent = (typeof WebhookEvent)[keyof typeof WebhookEvent];
73 changes: 73 additions & 0 deletions src/models/Webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Protected } from "~/PrintOne";
import { IWebhook } from "~/models/_interfaces/IWebhook";
import { WebhookLog } from "~/models/WebhookLog";
import { IWebhookLog } from "~/models/_interfaces/IWebhookLog";
import { WebhookEvent } from "~/enums/WebhookEvent";
import { PaginatedResponse } from "~/models/PaginatedResponse";
import { IPaginatedResponse } from "~/models/_interfaces/IPaginatedResponse";

export class Webhook {
private _data: IWebhook;

constructor(
private readonly _protected: Protected,
_data: IWebhook,
) {
this._data = _data;
}

public get id(): string {
return this._data.id;
}

public get name(): string {
return this._data.name;
}

public get events(): WebhookEvent[] {
return this._data.events;
}

public get active(): boolean {
return this._data.active;
}

public get headers(): Record<string, string> {
return this._data.headers;
}

public get secretHeaders(): Record<string, string> {
return this._data.secretHeaders;
}

public get url(): string {
return this._data.url;
}

public get successRate(): number | null {
return this._data.successRate;
}

public async update(data: Partial<Omit<IWebhook, "id">>): Promise<void> {
this._data = await this._protected.client.PATCH<IWebhook>(
`/webhooks/${this.id}`,
data,
);
}

public async delete(): Promise<void> {
await this._protected.client.DELETE<void>(`/webhooks/${this.id}`);
}

public async getLogs(): Promise<PaginatedResponse<WebhookLog>> {
const logs = await this._protected.client.GET<
IPaginatedResponse<IWebhookLog>
>(`/webhooks/${this.id}/logs`);

return PaginatedResponse.safe(
this._protected,
logs,
(log) => new WebhookLog(this._protected, log),
);
}
}
38 changes: 38 additions & 0 deletions src/models/WebhookLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Protected } from "~/PrintOne";
import {
IWebhookLog,
IWebhookLogResponse,
} from "~/models/_interfaces/IWebhookLog";
import { WebhookEvent } from "~/enums/WebhookEvent";
import { WebhookRequest, webhookRequestFactory } from "~/models/WebhookRequest";

export class WebhookLog {
constructor(
private readonly _protected: Protected,
private _data: IWebhookLog,
) {}

public get id(): string {
return this._data.id;
}

public get status(): "success" | "failed" {
return this._data.status;
}

public get event(): WebhookEvent {
return this._data.event;
}

public get request(): WebhookRequest {
return webhookRequestFactory(this._protected, this._data.request);
}

public get response(): IWebhookLogResponse {
return this._data.response;
}

public get createdAt(): Date {
return new Date(this._data.createdAt);
}
}
63 changes: 63 additions & 0 deletions src/models/WebhookRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {
IOrderStatusUpdateWebhookRequest,
ITemplatePreviewRenderedWebhookRequest,
IWebhookRequest,
} from "~/models/_interfaces/IWebhookRequest";
import { Protected } from "~/PrintOne";
import { Order } from "~/models/Order";
import { PreviewDetails } from "~/models/PreviewDetails";

abstract class AbstractWebhookRequest<T, E extends IWebhookRequest> {
constructor(
protected readonly _protected: Protected,
protected _data: E,
) {}

abstract data: T;

get event(): E["event"] {
return this._data.event;
}

get createdAt(): Date {
return new Date(this._data.createdAt);
}
}

export type WebhookRequest =
| OrderStatusUpdateWebhookRequest
| TemplatePreviewRenderedWebhookRequest;

export function webhookRequestFactory(
_protected: Protected,
data: IWebhookRequest,
): WebhookRequest {
const event = data.event;

switch (event) {
case "order_status_update":
return new OrderStatusUpdateWebhookRequest(_protected, data);
case "template_preview_rendered":
return new TemplatePreviewRenderedWebhookRequest(_protected, data);
default:
throw new Error(`Unknown webhook event: ${event}`);
}
}

export class OrderStatusUpdateWebhookRequest extends AbstractWebhookRequest<
Order,
IOrderStatusUpdateWebhookRequest
> {
get data(): Order {
return new Order(this._protected, this._data.data);
}
}

export class TemplatePreviewRenderedWebhookRequest extends AbstractWebhookRequest<
PreviewDetails,
ITemplatePreviewRenderedWebhookRequest
> {
get data(): PreviewDetails {
return new PreviewDetails(this._protected, this._data.data);
}
}
1 change: 1 addition & 0 deletions src/models/_interfaces/IPreviewDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export type IPreviewDetails = {
id: string;
errors: string[];
imageUrl: string;
templateId: string;
};
20 changes: 20 additions & 0 deletions src/models/_interfaces/IWebhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { WebhookEvent } from "~/enums/WebhookEvent";

export type IWebhook = {
id: string;
name: string;
events: WebhookEvent[];
active: boolean;
headers: Record<string, string>;
secretHeaders: Record<string, string>;
url: string;
successRate: number | null;
};

export type CreateWebhook = Omit<
IWebhook,
"id" | "headers" | "secretHeaders" | "successRate"
> & {
headers?: Record<string, string>;
secretHeaders?: Record<string, string>;
};
16 changes: 16 additions & 0 deletions src/models/_interfaces/IWebhookLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { WebhookEvent } from "~/enums/WebhookEvent";
import { IWebhookRequest } from "~/models/_interfaces/IWebhookRequest";

export type IWebhookLog = {
id: string;
status: "success" | "failed";
event: WebhookEvent;
request: IWebhookRequest;
response: IWebhookLogResponse;
createdAt: string;
};

export type IWebhookLogResponse = {
status: number;
body: string;
};
18 changes: 18 additions & 0 deletions src/models/_interfaces/IWebhookRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { IOrder } from "~/models/_interfaces/IOrder";
import { IPreviewDetails } from "~/models/_interfaces/IPreviewDetails";

export type IWebhookRequest =
| IOrderStatusUpdateWebhookRequest
| ITemplatePreviewRenderedWebhookRequest;

export type IOrderStatusUpdateWebhookRequest = {
data: IOrder;
event: "order_status_update";
createdAt: string;
};

export type ITemplatePreviewRenderedWebhookRequest = {
data: IPreviewDetails;
event: "template_preview_rendered";
createdAt: string;
};
6 changes: 4 additions & 2 deletions test/Batch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ describe("createOrder", function () {
expect((await batch.getOrders()).meta.total).toEqual(1);
});

it("should return status needs approval with 300+ orders", async function () {
//TODO enable this test once this can be stably tested
it.skip("should return status needs approval with 300+ orders", async function () {
// arrange
await addOrders(300);

Expand Down Expand Up @@ -265,7 +266,8 @@ describe("update", function () {
expect(batch.updatedAt).toBeAfterOrEqualTo(updatedAt);
});

it("should get status ready to sent with 300+ orders", async function () {
//TODO enable this test once this can be stably tested
it.skip("should get status ready to sent with 300+ orders", async function () {
// arrange
await addOrders(300);

Expand Down
Loading
Loading