Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Blackfaded committed Jul 25, 2024
1 parent b81a61c commit e55dcb6
Show file tree
Hide file tree
Showing 155 changed files with 8,984 additions and 7 deletions.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default [
files: ['**/*.{js,mjs,cjs,ts}'],
},
{
languageOptions: { globals: globals.browser },
languageOptions: { globals: globals.node },
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
Expand Down
Empty file added example-app/.gitkeep
Empty file.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "pcp-server-nodejs-sdk",
"version": "0.0.1",
"description": "",
"main": "index.js",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "ts-node src/index.ts",
Expand All @@ -23,5 +23,8 @@
"ts-node": "10.9.2",
"typescript": "5.5.4",
"typescript-eslint": "7.17.0"
},
"dependencies": {
"node-fetch": "^3.3.2"
}
}
23 changes: 23 additions & 0 deletions src/CommunicatorConfiguration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export class CommunicatorConfiguration {
private readonly apiKey: string;
private readonly apiSecret: string;
private readonly host: string;

constructor(apiKey: string, apiSecret: string, host: string) {
this.apiKey = apiKey;
this.apiSecret = apiSecret;
this.host = host;
}

public getApiKey(): string {
return this.apiKey;
}

public getApiSecret(): string {
return this.apiSecret;
}

public getHost(): string {
return this.host;
}
}
87 changes: 87 additions & 0 deletions src/RequestHeaderGenerator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as crypto from 'crypto';
import { Headers, RequestInit } from 'node-fetch';
import { CommunicatorConfiguration } from './CommunicatorConfiguration';
import { ServerMetaInfo } from './utils/ServerMetaInfo';

export class RequestHeaderGenerator {
public static readonly SERVER_META_INFO_HEADER_NAME = 'X-GCS-ServerMetaInfo';
public static readonly CLIENT_META_INFO_HEADER_NAME = 'X-GCS-ClientMetaInfo';
private static readonly ALGORITHM = 'HmacSHA256';
private static readonly WHITESPACE_REGEX = /\r?\n[h]*/g;
private readonly DATE_HEADER_NAME = 'Date';
private readonly AUTHORIZATION_HEADER_NAME = 'Authorization';

private readonly config: CommunicatorConfiguration;
private readonly hmac: crypto.Hmac;

constructor(config: CommunicatorConfiguration) {
this.config = config;
this.hmac = crypto.createHmac(RequestHeaderGenerator.ALGORITHM, config.getApiSecret());
}

public generateAdditionalRequestHeaders(url: string, request: RequestInit): RequestInit {
const headers = new Headers(request.headers);

if (!headers.has(this.DATE_HEADER_NAME)) {
headers.set(this.DATE_HEADER_NAME, new Date().toUTCString());
}
if (!headers.has(RequestHeaderGenerator.SERVER_META_INFO_HEADER_NAME)) {
headers.set(RequestHeaderGenerator.SERVER_META_INFO_HEADER_NAME, this.getServerMetaInfo());
}
if (!headers.has(RequestHeaderGenerator.CLIENT_META_INFO_HEADER_NAME)) {
headers.set(RequestHeaderGenerator.CLIENT_META_INFO_HEADER_NAME, this.getClientMetaInfo());
}
if (!headers.has(this.AUTHORIZATION_HEADER_NAME)) {
headers.set(this.AUTHORIZATION_HEADER_NAME, this.getAuthHeader(url, request, headers));
}

return {
...request,
headers,
};
}

private getAuthHeader(url: string, request: RequestInit, headers: Headers): string {
// 1. method
let stringToSign = `${request.method}\n`;
// 2. Content-Type
if (headers.has('Content-Type')) {
stringToSign += `${headers.get('Content-Type')}\n`;
}
stringToSign += '\n';
// 3. Date
stringToSign += `${headers.get(this.DATE_HEADER_NAME)}\n`;
// 4. Canonicalized Headers (starting with X-GCS, sorted by names)
if (headers.has(RequestHeaderGenerator.CLIENT_META_INFO_HEADER_NAME)) {
stringToSign += `${RequestHeaderGenerator.CLIENT_META_INFO_HEADER_NAME.toLowerCase()}:${headers.get(RequestHeaderGenerator.CLIENT_META_INFO_HEADER_NAME)!.replace(RequestHeaderGenerator.WHITESPACE_REGEX, ' ').trim()}\n`;
}
if (headers.has(RequestHeaderGenerator.SERVER_META_INFO_HEADER_NAME)) {
stringToSign += `${RequestHeaderGenerator.SERVER_META_INFO_HEADER_NAME.toLowerCase()}:${headers.get(RequestHeaderGenerator.SERVER_META_INFO_HEADER_NAME)!.replace(RequestHeaderGenerator.WHITESPACE_REGEX, ' ').trim()}\n`;
}
// 5. Canonicalized Resource (has to include query parameters)
const urlInternal = new URL(url);
stringToSign += urlInternal.pathname;
if (urlInternal.search) {
stringToSign += `${urlInternal.search}`;
}
stringToSign += '\n';
const signature = this.sign(stringToSign);
return `GCS v1HMAC:${this.config.getApiKey()}:${signature}`;
}

private sign(target: string): string {
const hash = this.hmac.update(target).digest();
return hash.toString('base64');
}

private getServerMetaInfo(): string {
const meta = new ServerMetaInfo();
const jsonString = JSON.stringify(meta);
return Buffer.from(jsonString, 'utf-8').toString('base64');
}

private getClientMetaInfo(): string {
const encodedBytes = Buffer.from('"[]"', 'utf-8').toString('base64');
return encodedBytes;
}
}
43 changes: 43 additions & 0 deletions src/endpoints/BaseApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import fetch, { RequestInit, Response } from 'node-fetch';
import { CommunicatorConfiguration } from '../CommunicatorConfiguration';
import { RequestHeaderGenerator } from '../RequestHeaderGenerator';
import { ApiResponseRetrievalException } from '../errors';
import { ErrorResponse } from '../models';

export class BaseApiClient {
private readonly JSON_PARSE_ERROR = 'Expected valid JSON response, but failed to parse';
protected readonly requestHeaderGenerator: RequestHeaderGenerator;
protected readonly config: CommunicatorConfiguration;

constructor(config: CommunicatorConfiguration) {
this.config = config;
this.requestHeaderGenerator = new RequestHeaderGenerator(config);
}

protected getRequestHeaderGenerator(): RequestHeaderGenerator | undefined {
return this.requestHeaderGenerator;
}

protected getConfig(): CommunicatorConfiguration {
return this.config;
}

protected async makeApiCall<T>(url: string, requestInit: RequestInit): Promise<T> {
requestInit = this.requestHeaderGenerator.generateAdditionalRequestHeaders(url, requestInit);

const response = await fetch(url, requestInit);
await this.handleError(response);
return response.json() as Promise<T>;
}

private async handleError(response: Response): Promise<void> {
if (response.ok) {
return;
}

const responseBody = (await response.json()) as Promise<ErrorResponse>;
if (!responseBody) {
throw new ApiResponseRetrievalException(response.status, responseBody);
}
}
}
154 changes: 154 additions & 0 deletions src/endpoints/CheckoutApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Headers, RequestInit } from 'node-fetch';
import { URLSearchParams } from 'url';
import { CommunicatorConfiguration } from '../CommunicatorConfiguration';
import {
CheckoutResponse,
CheckoutsResponse,
CreateCheckoutRequest,
CreateCheckoutResponse,
PatchCheckoutRequest,
} from '../models';

import { GetCheckoutsQuery } from '../queries';
import { BaseApiClient } from './BaseApiClient';

export class CheckoutApiClient extends BaseApiClient {
constructor(config: CommunicatorConfiguration) {
super(config);
}

public async createCheckoutRequest(
merchantId: string,
commerceCaseId: string,
payload: CreateCheckoutRequest,
): Promise<CreateCheckoutResponse> {
if (!merchantId) {
throw new Error('Merchant ID is required');
}
if (!commerceCaseId) {
throw new Error('Commerce Case ID is required');
}
if (!payload) {
throw new Error('Payload is required');
}

const url = new URL(`/v1/${merchantId}/commerce-cases/${commerceCaseId}/checkouts`, this.getConfig().getHost());

const requestInit: RequestInit = {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload),
};

return this.makeApiCall<CreateCheckoutResponse>(url.toString(), requestInit);
}

public async getCheckoutRequest(
merchantId: string,
commerceCaseId: string,
checkoutId: string,
): Promise<CheckoutResponse> {
if (!merchantId) {
throw new Error('Merchant ID is required');
}
if (!commerceCaseId) {
throw new Error('Commerce Case ID is required');
}
if (!checkoutId) {
throw new Error('Checkout ID is required');
}

const url = new URL(
`/v1/${merchantId}/commerce-cases/${commerceCaseId}/checkouts/${checkoutId}`,
this.getConfig().getHost(),
);

const requestInit: RequestInit = {
method: 'GET',
headers: new Headers(),
};

return this.makeApiCall<CheckoutResponse>(url.toString(), requestInit);
}

public async getCheckoutsRequest(merchantId: string, queryParams?: GetCheckoutsQuery): Promise<CheckoutsResponse> {
if (!merchantId) {
throw new Error('Merchant ID is required');
}

const url = new URL(`/v1/${merchantId}/checkouts`, this.getConfig().getHost());

if (queryParams) {
const params = new URLSearchParams(queryParams.toQueryMap());
url.search = params.toString();
}

const requestInit: RequestInit = {
method: 'GET',
headers: new Headers(),
};

return this.makeApiCall<CheckoutsResponse>(url.toString(), requestInit);
}

public async updateCheckoutRequest(
merchantId: string,
commerceCaseId: string,
checkoutId: string,
payload: PatchCheckoutRequest,
): Promise<void> {
if (!merchantId) {
throw new Error('Merchant ID is required');
}
if (!commerceCaseId) {
throw new Error('Commerce Case ID is required');
}
if (!checkoutId) {
throw new Error('Checkout ID is required');
}
if (!payload) {
throw new Error('Payload is required');
}

const url = new URL(
`/v1/${merchantId}/commerce-cases/${commerceCaseId}/checkouts/${checkoutId}`,
this.getConfig().getHost(),
);

const requestInit: RequestInit = {
method: 'PATCH',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(payload),
};

await this.makeApiCall(url.toString(), requestInit);
}

public async removeCheckoutRequest(merchantId: string, commerceCaseId: string, checkoutId: string): Promise<void> {
if (!merchantId) {
throw new Error('Merchant ID is required');
}
if (!commerceCaseId) {
throw new Error('Commerce Case ID is required');
}
if (!checkoutId) {
throw new Error('Checkout ID is required');
}

const url = new URL(
`/v1/${merchantId}/commerce-cases/${commerceCaseId}/checkouts/${checkoutId}`,
this.getConfig().getHost(),
);

const requestInit: RequestInit = {
method: 'DELETE',
headers: new Headers(),
};

await this.makeApiCall(url.toString(), requestInit);
}
}
Loading

0 comments on commit e55dcb6

Please sign in to comment.