-
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.
* 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
Showing
21 changed files
with
1,202 additions
and
712 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
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
Large diffs are not rendered by default.
Oops, something went wrong.
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,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'); | ||
}); | ||
}); |
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,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'; |
Oops, something went wrong.