Skip to content

Commit

Permalink
SDK 2.0.0 - Rewrite with fetch (#2)
Browse files Browse the repository at this point in the history
* Update

* Add GET request client test

* Add request methods and error handling to
HttpClient class

* Add new FastCache client

* Update node test versions

* Update version number

* Add new MCSkinHistory client

* Add new Web Reputation API client

* Add new VAT API client

* Add new Whois API client

* Add tests template for http client

* Fix dependency vulnerability

* Add further tests for http client

* Update package exports

* Add FastCache document client

* Require Node.js 18
  • Loading branch information
Zeryther authored Nov 5, 2023
1 parent 24c08bc commit 45140e1
Show file tree
Hide file tree
Showing 21 changed files with 1,202 additions and 712 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:

- uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18

- name: Install dependencies
run: npm ci
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14, 16, 18]
node-version: [18, 20]
name: Node ${{ matrix.node-version }}
steps:
- uses: actions/checkout@v2
Expand Down
467 changes: 299 additions & 168 deletions package-lock.json

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"name": "@gigadrive/network-sdk",
"version": "1.1.4",
"version": "2.0.0",
"description": "Software development kit for Gigadrive Network APIs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "rimraf ./lib && tsc --project tsconfig.build.json --module commonjs --outDir dist",
"build": "rimraf ./dist && tsc --project tsconfig.build.json --module commonjs --outDir dist",
"lint": "eslint --cache \"src/**/*.{js,jsx,ts,tsx}\"",
"lint:fix": "eslint --cache --fix \"src/**/*.{js,jsx,ts,tsx}\"",
"size": "limit-size",
Expand All @@ -14,6 +14,18 @@
"files": [
"dist"
],
"engines": {
"node": ">=18.0.0"
},
"exports": {
"./client": "./dist/client/index.js",
"./fastcache": "./dist/modules/fastcache/index.js",
"./fastcache/document": "./dist/modules/fastcache/document.js",
"./mcskinhistory": "./dist/modules/mcskinhistory/index.js",
"./vat": "./dist/modules/vat/index.js",
"./web-reputation": "./dist/modules/web-reputation/index.js",
"./whois": "./dist/modules/whois/index.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Gigadrive/network-sdk-js.git"
Expand Down
163 changes: 163 additions & 0 deletions src/client/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { URLSearchParams } from 'url';
import { HttpClient } from '.';
import { mockFetch, mockFetchError } from '../tests/mock';

describe('HTTP Client', () => {
const http = new HttpClient('https://example.com');

it('should be able to do a GET request', async () => {
interface DataType {
foo: string;
}

mockFetch({
foo: 'bar',
});

// @ts-expect-error - ignore method being private
const data = await http.request<DataType>('/test', 'GET');

expect(data).toEqual({
foo: 'bar',
});
});

it('should be able to do a nullable GET request', async () => {
interface DataType {
foo: string;
}

mockFetchError('Not found');

// @ts-expect-error - ignore method being private
const data = await http.requestNullable<DataType>('/test', 'GET');

expect(data).toBeNull();
});

it('should be able to do a POST request', async () => {
interface DataType {
foo: string;
}

mockFetch({
foo: 'bar',
});

// @ts-expect-error - ignore method being private
const data = await http.post<DataType>('/test', {
foo: 'bar',
});

expect(data).toEqual({
foo: 'bar',
});
});

it('should be able to do a PUT request', async () => {
interface DataType {
foo: string;
}

mockFetch({
foo: 'bar',
});

// @ts-expect-error - ignore method being private
const data = await http.put<DataType>('/test', {
foo: 'bar',
});

expect(data).toEqual({
foo: 'bar',
});
});

it('should be able to do a PATCH request', async () => {
interface DataType {
foo: string;
}

mockFetch({
foo: 'bar',
});

// @ts-expect-error - ignore method being private
const data = await http.patch<DataType>('/test', {
foo: 'bar',
});

expect(data).toEqual({
foo: 'bar',
});
});

it('should be able to do a DELETE request', async () => {
mockFetch();

// @ts-expect-error - ignore method being private
await http.delete('/test');

// expect no error
expect(true).toBe(true);
});

it('should be able to handle a standardized error', async () => {
mockFetchError('Bad request');

http
// @ts-expect-error - ignore method being private
.post('/test', {
foo: 'bar',
})
.then(() => {
// eslint-disable-next-line no-console
console.warn('Standardized error test did not fail as expected.');
expect(true).toBe(false);
})
.catch((e) => {
expect(e.message).toBe("Encountered errors: 'Bad request'");
});
});

it('should be able to handle a non-standardized error', async () => {
mockFetch('Internal server error', false, 500, 'Internal server error');

// make sure handleError will be called
// @ts-expect-error - ignore method being private
const spy = jest.spyOn(http, 'handleError');

http
// @ts-expect-error - ignore method being private
.post('/test', {
foo: 'bar',
})
.then(() => {
// eslint-disable-next-line no-console
console.warn('Non-standardized error test did not fail as expected.');
expect(true).toBe(false);
})
.catch((e) => {
expect(e.message).toBe("Request failed with status code 500 and response text 'Internal server error'");

expect(spy).toHaveBeenCalledTimes(1);
});
});

it('should be able to parse query parameters properly', async () => {
// @ts-expect-error - ignore method being private
expect(http.parseQuery({ foo: 'bar' }).toString()).toEqual('foo=bar');

// @ts-expect-error - ignore method being private
expect(http.parseQuery({ foo: ['bar', 'baz'] }).toString()).toEqual('foo=bar&foo=baz');

// @ts-expect-error - ignore method being private
expect(http.parseQuery({ foo: 'bar', baz: 'qux' }).toString()).toEqual('foo=bar&baz=qux');

// @ts-expect-error - ignore method being private
expect(http.parseQuery({ foo: 123 }).toString()).toEqual('foo=123');

// @ts-expect-error - ignore method being private
expect(http.parseQuery(new URLSearchParams('foo=bar')).toString()).toEqual('foo=bar');
});
});
184 changes: 184 additions & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
export class HttpClient {
private readonly baseUrl: string;

constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}

protected async request<T>(path: string, method: HttpMethod, options: BaseRequestOptions = {}): Promise<T> {
if (options.query != null) {
const query = this.parseQuery(options.query);
const delimiter = path.includes('?') ? '&' : '?';
path += delimiter + query.toString();
}

const url = `${this.baseUrl}${path}`;
const headers = new Headers(options.headers);

const apiKey = options.apiKey ?? this.fetchApiKey();

if (apiKey != null) {
headers.append('Authorization', `Bearer ${apiKey}`);
}

const response = await fetch(url, { ...options, method, headers });
if (!response.ok) {
await this.handleError(response);
}

if (response.headers?.get('Content-Type')?.startsWith('application/json') === true) {
return await response.json();
}

return (await response.text()) as unknown as T;
}

protected async requestNullable<T>(
path: string,
method: HttpMethod,
options: BaseRequestOptions = {}
): Promise<T | null> {
try {
return await this.request<T>(path, method, options);
} catch (e) {
if (
(e.response != null && e.response.status === 404) ||
(e.response != null && typeof e.response.status === 'undefined' && e.response.ok === false) // response status is not available in tests
) {
return null;
}

throw e;
}
}

protected async post<T>(path: string, data: unknown, options: BaseRequestOptions = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.append('Content-Type', 'application/json');

options.headers = headers;

return await this.request<T>(path, 'POST', { ...options, body: JSON.stringify(data) });
}

protected async put<T>(path: string, data: unknown, options: BaseRequestOptions = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.append('Content-Type', 'application/json');

options.headers = headers;

return await this.request<T>(path, 'PUT', { ...options, body: JSON.stringify(data) });
}

protected async patch<T>(path: string, data: unknown, options: BaseRequestOptions = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.append('Content-Type', 'application/json');

options.headers = headers;

return await this.request<T>(path, 'PATCH', { ...options, body: JSON.stringify(data) });
}

protected async delete(path: string, options: BaseRequestOptions = {}): Promise<void> {
await this.request(path, 'DELETE', { ...options });
}

private async handleError(response: Response): Promise<void> {
const responseText = await response.text();

let errorMessage = `Request failed with status code ${response.status} and response text '${response.statusText}'`;

// check if response text is json with valid error structure
try {
const responseObject = JSON.parse(responseText);
if (
typeof responseObject === 'object' &&
// Object.prototype.hasOwnProperty.call(responseObject, 'errors') === true &&
responseObject.errors != null &&
Array.isArray(responseObject.errors) &&
responseObject.errors.length > 0
) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
errorMessage = `Encountered errors: '${responseObject.errors.map((error) => error.message).join("', '")}'`;
}
} catch (e) {}

// if not, throw generic error
throw new HttpClientError(errorMessage, response);
}

private parseQuery(query: QueryParameters): URLSearchParams {
if (typeof query === 'string') {
return new URLSearchParams(query);
}

if (Array.isArray(query)) {
return new URLSearchParams(query);
}

if (query instanceof URLSearchParams) {
return query;
}

if (typeof query === 'object' && query != null) {
const convertedParams: string[][] = [];

for (const [key, value] of Object.entries(query)) {
if (value == null) {
continue;
}

if (Array.isArray(value)) {
for (const item of value) {
convertedParams.push([key, item.toString()]);
}

continue;
}

convertedParams.push([key, value.toString()]);
}

return new URLSearchParams(convertedParams);
}

return new URLSearchParams();
}

protected fetchApiKey(): string | null {
// check if localStorage exists
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('gigadriveApiKey') ?? null;
}

// check if process.env exists
if (typeof process !== 'undefined' && process.env != null) {
return process.env.GIGADRIVE_API_KEY ?? null;
}

return null;
}
}

export class HttpClientError extends Error {
public readonly response: Response;

constructor(message: string, response: Response) {
super(message);
this.response = response;
}
}

export interface BaseRequestOptions extends Omit<RequestInit, 'method'> {
apiKey?: string;
query?: QueryParameters;
}

export type QueryParameters =
| string
| string[][]
| Record<string, string | number | boolean | null | undefined | Array<string | number | boolean>>
| URLSearchParams
| undefined;

export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
Loading

0 comments on commit 45140e1

Please sign in to comment.