-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
b81a61c
commit e55dcb6
Showing
155 changed files
with
8,984 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.