-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add tool to decompress and parse.
feat: handle errors by stage refactor: use class to handle http error chore: export 'UndiciResponseHandler', 'HttpieOnHttpError' and 'HttpieHandlerError'
- Loading branch information
Showing
12 changed files
with
786 additions
and
151 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
// Import Third-party Dependencies | ||
import { IncomingHttpHeaders } from "undici/types/header"; | ||
|
||
type CommonResponseData = { | ||
statusCode: number; | ||
headers: IncomingHttpHeaders; | ||
} | ||
|
||
export interface IHttpieErrorOptions { | ||
response: CommonResponseData; | ||
} | ||
|
||
export class HttpieError extends Error { | ||
headers: IncomingHttpHeaders; | ||
statusCode: number; | ||
|
||
constructor(message: string, options: IHttpieErrorOptions) { | ||
super(message); | ||
|
||
this.statusCode = options.response.statusCode; | ||
this.headers = options.response.headers; | ||
} | ||
} |
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,71 @@ | ||
/* eslint-disable max-classes-per-file */ | ||
|
||
// Import Third-party Dependencies | ||
import { HttpieError, IHttpieErrorOptions } from "./HttpieCommonError"; | ||
import { getDecompressionError, getFetchError, getParserError } from "../common/errors"; | ||
|
||
interface IHttpieHandlerError<T extends string = string> extends IHttpieErrorOptions { | ||
/** @description original error */ | ||
error?: Error; | ||
message: T; | ||
} | ||
|
||
interface IHttpieDecompressionErrorOptions extends IHttpieHandlerError<Parameters<typeof getDecompressionError>[0]["message"]> { | ||
/** @description original body as buffer */ | ||
buffer: Buffer; | ||
/** @description encodings from 'content-encoding' header */ | ||
encodings: string[]; | ||
} | ||
|
||
// eslint-disable-next-line max-len | ||
interface IHttpieParserErrorOptions extends IHttpieHandlerError<Parameters<typeof getParserError>[0]["message"]> { | ||
/** @description content-type from 'content-type' header without params */ | ||
contentType: string; | ||
/** @description original body as buffer */ | ||
buffer: Buffer; | ||
/** @description body as string */ | ||
text?: string; | ||
} | ||
|
||
class HttpieHandlerError extends HttpieError { | ||
reason: Error | null; | ||
|
||
constructor(message: string, options: IHttpieHandlerError) { | ||
super(message, options); | ||
|
||
this.name = options.message; | ||
this.reason = options.error ?? null; | ||
} | ||
} | ||
|
||
export class HttpieFetchBodyError extends HttpieHandlerError { | ||
constructor(options: IHttpieHandlerError<Parameters<typeof getFetchError>[0]["message"]>, ...args) { | ||
super(getFetchError(options, ...args), options); | ||
} | ||
} | ||
|
||
export class HttpieDecompressionError extends HttpieHandlerError { | ||
buffer: Buffer; | ||
encodings: string[]; | ||
|
||
constructor(options: IHttpieDecompressionErrorOptions, ...args) { | ||
super(getDecompressionError(options, ...args), options); | ||
|
||
this.buffer = options.buffer; | ||
this.encodings = options.encodings; | ||
} | ||
} | ||
|
||
export class HttpieParserError extends HttpieHandlerError { | ||
contentType: string; | ||
buffer: Buffer; | ||
text: string | null; | ||
|
||
constructor(options: IHttpieParserErrorOptions, ...args) { | ||
super(getParserError(options, ...args), options); | ||
|
||
this.buffer = options.buffer; | ||
this.contentType = options.contentType; | ||
this.text = options.text ?? null; | ||
} | ||
} |
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,21 @@ | ||
// Import Internal Dependencies | ||
import { HttpieError } from "./HttpieCommonError"; | ||
import { RequestResponse } from "../request"; | ||
|
||
/** | ||
* @description Class to generate an Error with all the required properties from the response. | ||
* We attach them to the error so that they can be retrieved by the developer in a Catch block. | ||
*/ | ||
export class HttpieOnHttpError<T extends RequestResponse<any>> extends HttpieError { | ||
name = "HttpieOnHttpError"; | ||
|
||
statusMessage: string; | ||
data: T; | ||
|
||
constructor(response: T) { | ||
super(response.statusMessage, { response }); | ||
|
||
this.statusMessage = response.statusMessage; | ||
this.data = response.data; | ||
} | ||
} |
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,148 @@ | ||
/* eslint-disable no-dupe-class-members */ | ||
|
||
// Import Node.js Dependencies | ||
import { promisify } from "node:util"; | ||
import { inflate, brotliDecompress, gunzip } from "node:zlib"; | ||
|
||
// Import Third-party Dependencies | ||
import { Dispatcher } from "undici"; | ||
import * as contentType from "content-type"; | ||
|
||
// Import Internal Dependencies | ||
import { getEncodingCharset } from "../utils"; | ||
import { HttpieDecompressionError, HttpieFetchBodyError, HttpieParserError } from "./HttpieHandlerError"; | ||
|
||
const kAsyncGunzip = promisify(gunzip); | ||
const kDecompress = { | ||
gzip: kAsyncGunzip, | ||
"x-gzip": kAsyncGunzip, | ||
br: promisify(brotliDecompress), | ||
deflate: promisify(inflate) | ||
}; | ||
|
||
export type TypeOfDecompression = keyof typeof kDecompress; | ||
export type ModeOfHttpieResponseHandler = "decompress" | "parse" | "raw"; | ||
|
||
export class HttpieResponseHandler { | ||
response: Dispatcher.ResponseData; | ||
|
||
constructor(response: Dispatcher.ResponseData) { | ||
this.response = response; | ||
} | ||
|
||
getData<T>(): Promise<T>; | ||
getData(mode: "raw"): Promise<Buffer>; | ||
getData(mode: "decompress"): Promise<Buffer>; | ||
getData<T>(mode: "parse"): Promise<T>; | ||
getData<T>(mode: ModeOfHttpieResponseHandler = "parse") { | ||
if (mode === "parse") { | ||
return this.parseUndiciResponse<T>(); | ||
} | ||
|
||
if (mode === "decompress") { | ||
return this.getDecompressedBuffer(); | ||
} | ||
|
||
return this.getBuffer(); | ||
} | ||
|
||
private async getBuffer(): Promise<Buffer> { | ||
try { | ||
return Buffer.from(await this.response.body.arrayBuffer()); | ||
} | ||
catch (error) { | ||
throw new HttpieFetchBodyError({ | ||
message: "ResponseFetchError", | ||
error, | ||
response: this.response | ||
}); | ||
} | ||
} | ||
|
||
private async getDecompressedBuffer(): Promise<Buffer> { | ||
const buffer = await this.getBuffer(); | ||
const encodingHeader = this.response.headers["content-encoding"]; | ||
|
||
if (!encodingHeader) { | ||
return buffer; | ||
} | ||
|
||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding#syntax | ||
const encodings = Array.isArray(encodingHeader) ? | ||
encodingHeader.reverse() : | ||
encodingHeader.split(",").reverse(); | ||
|
||
let decompressedBuffer = Buffer.from(buffer); | ||
for (const rawEncoding of encodings) { | ||
const encoding = rawEncoding.trim() as TypeOfDecompression; | ||
const strategy = kDecompress[encoding]; | ||
|
||
if (!strategy) { | ||
throw new HttpieDecompressionError( | ||
{ | ||
message: "DecompressionNotSupported", | ||
buffer, | ||
encodings, | ||
response: this.response | ||
}, | ||
encoding | ||
); | ||
} | ||
|
||
try { | ||
decompressedBuffer = await strategy(decompressedBuffer); | ||
} | ||
catch (error) { | ||
throw new HttpieDecompressionError({ | ||
message: "UnexpectedDecompressionError", | ||
buffer, | ||
encodings, | ||
error, | ||
response: this.response | ||
}); | ||
} | ||
} | ||
|
||
return decompressedBuffer; | ||
} | ||
|
||
/** | ||
* @description Parse Undici a buffer based on 'Content-Type' header. | ||
* If the response as a content type equal to 'application/json' we automatically parse it with JSON.parse(). | ||
*/ | ||
private async parseUndiciResponse<T>(): Promise<T | Buffer | string> { | ||
const buffer = await this.getDecompressedBuffer(); | ||
const contentTypeHeader = this.response.headers["content-type"] as string; | ||
|
||
if (!contentTypeHeader) { | ||
return buffer; | ||
} | ||
|
||
let bodyAsString: string = void 0 as any; | ||
try { | ||
const { type, parameters } = contentType.parse(contentTypeHeader); | ||
bodyAsString = buffer.toString(getEncodingCharset(parameters.charset)); | ||
|
||
if (type === "application/json") { | ||
return JSON.parse(bodyAsString); | ||
} | ||
|
||
if (type.startsWith("text/")) { | ||
return bodyAsString; | ||
} | ||
} | ||
catch (error) { | ||
// Note: Even in case of an error we want to be able to recover the body that caused the JSON parsing error. | ||
throw new HttpieParserError({ | ||
message: "ResponseParsingError", | ||
contentType: contentTypeHeader, | ||
text: bodyAsString ?? null, | ||
buffer, | ||
error, | ||
response: this.response | ||
}); | ||
} | ||
|
||
return buffer; | ||
} | ||
} |
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,53 @@ | ||
/* eslint-disable max-len */ | ||
|
||
interface IGetErrorOptions<T> { | ||
error?: Error; | ||
message: keyof T; | ||
} | ||
|
||
// from myu-utils | ||
function taggedString(chaines: TemplateStringsArray, ...cles: string[] | number[]) { | ||
return function cur(...valeurs: any[]): string { | ||
const dict = valeurs[valeurs.length - 1] || {}; | ||
const resultat = [chaines[0]]; | ||
cles.forEach((cle, index) => { | ||
resultat.push( | ||
typeof cle === "number" ? valeurs[cle] : dict[cle], | ||
chaines[index + 1] | ||
); | ||
}); | ||
|
||
return resultat.join(""); | ||
}; | ||
} | ||
|
||
const kFetchBodyErrors = { | ||
ResponseFetchError: taggedString`An unexpected error occurred while trying to retrieve the response body (reason: '${0}').` | ||
}; | ||
|
||
const kDecompressionErrors = { | ||
UnexpectedDecompressionError: taggedString`An unexpected error occurred when trying to decompress the response body (reason: '${0}').`, | ||
DecompressionNotSupported: taggedString`Unsupported encoding '${0}'.` | ||
}; | ||
|
||
const kParserErrors = { | ||
ResponseParsingError: taggedString`An unexpected error occurred when trying to parse the response body (reason: '${0}').` | ||
}; | ||
|
||
function getErrorsByType<T extends Record<string, string | ReturnType<typeof taggedString>>>(errorDirectory: T) { | ||
return (options: IGetErrorOptions<T>, ...args: string[]) => { | ||
const { error, message: errorLabel } = options; | ||
const err = errorDirectory[errorLabel]; | ||
|
||
if (typeof err === "string") { | ||
return err; | ||
} | ||
|
||
return err(...(args.length === 0 ? [error?.message] : args)); | ||
}; | ||
} | ||
|
||
export const getFetchError = getErrorsByType(kFetchBodyErrors); | ||
export const getDecompressionError = getErrorsByType(kDecompressionErrors); | ||
export const getParserError = getErrorsByType(kParserErrors); | ||
|
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
Oops, something went wrong.