Skip to content

Commit

Permalink
chore(CON-462): replace superagent with built-in fetch (#66)
Browse files Browse the repository at this point in the history
* chore(CON-462): replace superagent with built-in fetch

* deps: fix package-lock

* chore: fix lint warnings
  • Loading branch information
arnoerpenbeck authored Oct 16, 2024
1 parent fc04be8 commit f4ceb60
Show file tree
Hide file tree
Showing 28 changed files with 4,060 additions and 2,358 deletions.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20.17.0
20.18.0
5,584 changes: 3,760 additions & 1,824 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 0 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,9 @@
"@babel/preset-env": "7.25.4",
"@babel/preset-typescript": "7.24.7",
"@tsconfig/node16": "16.1.3",
"@types/debug": "4.1.12",
"@types/jest": "29.5.12",
"@types/jest-when": "3.5.5",
"@types/node": "18.19.26",
"@types/superagent": "4.1.20",
"@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "5.62.0",
"auto-changelog": "2.4.0",
Expand All @@ -68,9 +66,6 @@
"tsup": "8.2.4",
"typescript": "5.5.4"
},
"dependencies": {
"superagent": "9.0.2"
},
"engines": {
"node": ">=20"
}
Expand Down
102 changes: 102 additions & 0 deletions src/common/httpClient/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
export enum ErrorType {
HTTP = 'http', // Generic HTTP error
CLIENT = 'client', // Generic client error
REQUEST = 'request', // Error preparing API request
RESPONSE = 'response', // Error response from API
PARSE = 'parse', // Error parsing API resource
}

export class FetchError extends Error {
static NAME = 'FetchError';

static isFetchError = (error: unknown): error is FetchError => {
return error !== null && typeof error === 'object' && error instanceof FetchError;
};

type: ErrorType;
status: number;
statusText: string;

constructor(status: number, statusText: string) {
super(statusText);
this.name = FetchError.NAME;
this.type = ErrorType.HTTP;
this.status = status;
this.statusText = statusText;
}
}

export class FftSdkError extends Error {
static NAME = 'SdkError';

static isSdkError(error: unknown): error is FftSdkError {
return (
error !== null &&
typeof error === 'object' &&
'name' in error &&
'type' in error &&
[(FftSdkError.NAME, FftApiError.NAME)].includes(error.name as string) &&
Object.values(ErrorType).includes(error.type as ErrorType)
);
}

type: ErrorType;
source?: Error;

constructor(error: { message: string; type?: ErrorType }) {
super(error.message);
this.name = FftSdkError.NAME;
this.type = error.type || ErrorType.CLIENT;
}
}

export class FftApiError extends FftSdkError {
static NAME = 'ApiError';

static isApiError(error: unknown): error is FftApiError {
return FftSdkError.isSdkError(error) && error.name === FftApiError.NAME && error.type === ErrorType.RESPONSE;
}

status?: number;
statusText?: string;

constructor(error: { message: string }) {
super({ ...error, type: ErrorType.RESPONSE });
this.name = FftApiError.NAME;
}
}

const isRequestError = (error: unknown): error is TypeError => {
return error !== null && typeof error === 'object' && error instanceof TypeError;
};

export const handleError = (error: unknown): never => {
if (FftSdkError.isSdkError(error) || FftApiError.isApiError(error)) {
throw error;
}

let sdkError;
if (error instanceof Error) {
sdkError = new FftSdkError({ message: error.message });
sdkError.source = error;
} else {
sdkError = new FftSdkError({ message: 'An error occurred' });
}

if (FetchError.isFetchError(error)) {
const apiError = new FftApiError(sdkError);
apiError.type = ErrorType.RESPONSE;
apiError.status = error.status;
apiError.statusText = error.statusText;
if (!apiError.message && apiError.statusText) {
apiError.message = apiError.statusText;
}
sdkError = apiError;
} else if (isRequestError(error)) {
sdkError.type = ErrorType.REQUEST;
} else {
sdkError.type = ErrorType.CLIENT;
}

throw sdkError;
};
87 changes: 58 additions & 29 deletions src/common/httpClient/httpClient.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,83 @@
import superagent from 'superagent';
import { HTTP_TIMEOUT_MS } from './constants';
import { USER_AGENT } from '../projectConstants';
import { BasicHttpClient, HttpRequestConfiguration, HttpResult, ResponseType } from './models';
import { BasicHttpClient, HttpRequestConfiguration, HttpResult } from './models';
import { serializeWithDatesAsIsoString } from './serialize';
import { ErrorType, FetchError, FftSdkError } from './error';

export class HttpClient implements BasicHttpClient {
private shouldLogHttpRequestAndResponse: boolean;

constructor(shouldLogHttpRequestAndResponse?: boolean) {
this.shouldLogHttpRequestAndResponse = shouldLogHttpRequestAndResponse ?? false;
}

public async request<TDto>(config: HttpRequestConfiguration): Promise<HttpResult<TDto>> {
const request = superagent(config.method, config.url)
.set('Content-Type', 'application/json')
.set('User-Agent', USER_AGENT)
.timeout(HTTP_TIMEOUT_MS)
.responseType(config.responseType ?? ResponseType.DEFAULT)
.retry(config.retries);
const url = new URL(config.url);
const requestHeaders = new Headers();
requestHeaders.set('Content-Type', 'application/json');
requestHeaders.set('User-Agent', USER_AGENT);

if (config.customHeaders) {
request.set(config.customHeaders);
Object.entries(config?.customHeaders).forEach(([key, value]) => {
requestHeaders.set(key, String(value));
});
}

if (config.params) {
request.query(config.params);
Object.entries(config?.params).forEach(([name, value]) => {
url.searchParams.append(name, String(value));
});
}

// eslint-disable-next-line no-undef
const requestOptions: RequestInit = {
headers: requestHeaders,
method: config.method,
body: config.body ? JSON.stringify(config.body, serializeWithDatesAsIsoString) : undefined,
};

if (AbortSignal?.timeout) {
requestOptions.signal = AbortSignal.timeout(HTTP_TIMEOUT_MS);
}

if (this.shouldLogHttpRequestAndResponse) {
console.debug(
`Sending request. Url: ${request.url}, Method: ${request.method}. Params: ${JSON.stringify(
config.params
)}, Body: ${JSON.stringify(config.body)}`
);
console.debug(`Sending request. Url: ${config.url}, Method: ${config.method}`, [
{
params: url.searchParams,
body: requestOptions.body,
headers: requestOptions.headers,
},
]);
}

const response = await request
.send(config.body)
.serialize((body) => JSON.stringify(body, serializeWithDatesAsIsoString));
const fetchClient = config?.fetch || fetch;

const response = await fetchClient(url, requestOptions);

const responseBody =
response.body && response.status !== 204
? await response.json().catch(() => {
if (response.ok) {
throw new FftSdkError({ message: 'Error parsing API response body', type: ErrorType.PARSE });
}
})
: undefined;

if (this.shouldLogHttpRequestAndResponse) {
console.debug(
`Received response. Url: ${request.url}, Method: ${request.method} - Response Status: ${
response.statusCode
}. Body: ${JSON.stringify(response.body)}`
);
console.debug(`Received response. Url: ${url}, Method: ${config.method} - Response Status: ${response.status}`, [
{
body: responseBody,
},
]);
}

if (!response.ok) {
throw new FetchError(response.status, response.statusText);
}

return {
statusCode: response.statusCode,
body: response.body as TDto,
statusCode: response.status,
body: responseBody as TDto,
};
}

constructor(shouldLogHttpRequestAndResponse?: boolean) {
this.shouldLogHttpRequestAndResponse = shouldLogHttpRequestAndResponse ?? false;
}
}
1 change: 1 addition & 0 deletions src/common/httpClient/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './constants';
export * from './error';
export * from './httpClient';
export type { HttpResult } from './models';
export type { HttpRequestConfiguration } from './models';
Expand Down
4 changes: 4 additions & 0 deletions src/common/httpClient/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export enum HttpMethod {

export type QueryParams = Record<string, string | string[]>;

// eslint-disable-next-line no-undef
export type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;

export interface HttpRequestConfiguration {
method: HttpMethod;
url: string;
Expand All @@ -17,6 +20,7 @@ export interface HttpRequestConfiguration {
body?: Record<string, unknown> | string;
retries?: number;
responseType?: ResponseType;
fetch?: Fetch;
}

export interface HttpResult<TDto> {
Expand Down
2 changes: 0 additions & 2 deletions src/common/utils/address/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,3 @@ export interface StreetAndHouseNumber {
street: string;
houseNumber: string;
}

export class AddressParsingError extends Error {}
7 changes: 4 additions & 3 deletions src/common/utils/address/parser.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { AddressParsingError, StreetAndHouseNumber } from './models';
import { ErrorType, FftSdkError } from '../../httpClient';
import { StreetAndHouseNumber } from './models';

export function parseStreetAndHouseNumber(address: string): StreetAndHouseNumber {
const regexp = '^([a-zA-ZÄäÖöÜüß\\s\\d.,-]+?)\\s*([\\d\\s]+(?:\\s?[-|+\\/]\\s?\\d+)?\\s*[a-zA-Z]?)?$';
const addressData = address.match(regexp);
if (!addressData) {
throw new Error(`Could not parse address '${address}'`);
throw new FftSdkError({ message: `Could not parse address '${address}'`, type: ErrorType.REQUEST });
}

if (addressData.length !== 3 || !addressData[1] || !addressData[2]) {
throw new AddressParsingError(address);
throw new FftSdkError({ message: `Invalid address '${address}'`, type: ErrorType.REQUEST });
}

return {
Expand Down
7 changes: 3 additions & 4 deletions src/fft-api/auth/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class AuthService {
private readonly authRefreshUrl: string;

private static readonly EXPIRY_TOLERANCE_MS = 5000;

constructor(private readonly authConfig: FftAuthConfig, private readonly httpClient: HttpClient) {
this.authLoginUrl = this.authConfig.authUrl;
this.authRefreshUrl = this.authConfig.refreshUrl;
Expand All @@ -31,8 +32,6 @@ export class AuthService {
}

public async getToken(): Promise<string> {
// this.log.debug(`Getting token for '${this.username}'`);

if (!this.idToken || !this.refreshToken || !this.expiresAt) {
try {
const tokenResponse = await this.httpClient.request<TokenResponse>({
Expand All @@ -49,7 +48,7 @@ export class AuthService {
this.refreshToken = tokenResponse.body.refreshToken;
this.expiresAt = this.calcExpiresAt(tokenResponse.body.expiresIn);
} catch (err) {
console.error(`Could not obtain token for '${this.username}': ${err}`);
console.error(`Could not obtain token for '${this.username}'.`, err);
throw err;
}
} else if (new Date().getTime() > this.expiresAt.getTime() - AuthService.EXPIRY_TOLERANCE_MS) {
Expand All @@ -67,7 +66,7 @@ export class AuthService {
this.refreshToken = refreshTokenResponse.body.refresh_token;
this.expiresAt = this.calcExpiresAt(refreshTokenResponse.body.expires_in);
} catch (err) {
console.error(`Could not refresh token for '${this.username}': ${err}`);
console.error(`Could not refresh token for '${this.username}'.`, err);
throw err;
}
}
Expand Down
34 changes: 20 additions & 14 deletions src/fft-api/common/fftApiClientService.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { AuthService } from '../auth';
import { HttpClient, HttpMethod, MAX_RETRIES, QueryParams } from '../../common';
import { handleError, HttpClient, HttpMethod, MAX_RETRIES, QueryParams } from '../../common';
import { ResponseType } from '../../common/httpClient/models';
import { AuthService } from '../auth';

export class FftApiClient {
private readonly baseUrl: string;
private readonly authService: AuthService;
private readonly httpClient: HttpClient;

constructor(projectId: string, username: string, password: string, apiKey: string, shouldEnableHttpLogging = false) {
this.baseUrl = `https://${projectId}.api.fulfillmenttools.com/api`;
this.httpClient = new HttpClient(shouldEnableHttpLogging);
Expand Down Expand Up @@ -63,17 +64,22 @@ export class FftApiClient {
params?: QueryParams,
responseType?: ResponseType
): Promise<T> {
const token = await this.authService.getToken();
const customHeaders = { Authorization: `Bearer ${token}` };
const result = await this.httpClient.request<T>({
method,
url: `${this.baseUrl}/${path}`,
body: data,
params,
customHeaders,
retries: method === HttpMethod.GET ? MAX_RETRIES : 0,
responseType,
});
return result.body as T;
try {
const token = await this.authService.getToken();
const customHeaders = { Authorization: `Bearer ${token}` };
const result = await this.httpClient.request<T>({
method,
url: `${this.baseUrl}/${path}`,
body: data,
params,
customHeaders,
retries: method === HttpMethod.GET ? MAX_RETRIES : 0,
responseType,
});
return result.body as T;
} catch (error) {
handleError(error);
}
return undefined as T;
}
}
Loading

0 comments on commit f4ceb60

Please sign in to comment.