Skip to content

Commit

Permalink
feat: add tool to decompress and parse.
Browse files Browse the repository at this point in the history
feat: handle errors by stage

refactor: use class to handle http error

chore: export 'UndiciResponseHandler', 'HttpieOnHttpError' and 'HttpieHandlerError'
  • Loading branch information
SofianD committed Feb 5, 2024
1 parent 514bbd3 commit 3791587
Show file tree
Hide file tree
Showing 12 changed files with 786 additions and 151 deletions.
23 changes: 23 additions & 0 deletions src/class/HttpieCommonError.ts
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;
}
}
71 changes: 71 additions & 0 deletions src/class/HttpieHandlerError.ts
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;
}
}
21 changes: 21 additions & 0 deletions src/class/HttpieOnHttpError.ts
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;
}
}
148 changes: 148 additions & 0 deletions src/class/undiciResponseHandler.ts
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;
}
}
53 changes: 53 additions & 0 deletions src/common/errors.ts
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);

4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export * as policies from "./policies";
export { agents, computeURI, CustomHttpAgent } from "./agents";
export { DEFAULT_HEADER } from "./utils";

export * from "./class/undiciResponseHandler";
export * from "./class/HttpieOnHttpError";
export * from "./class/HttpieHandlerError";

export {
Agent,
ProxyAgent,
Expand Down
33 changes: 25 additions & 8 deletions src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import status from "statuses";
// Import Internal Dependencies
import * as Utils from "./utils";
import { computeURI } from "./agents";
import { HttpieResponseHandler, ModeOfHttpieResponseHandler } from "./class/undiciResponseHandler";
import { HttpieOnHttpError } from "./class/HttpieOnHttpError";

export type WebDavMethod = "MKCOL" | "COPY" | "MOVE" | "LOCK" | "UNLOCK" | "PROPFIND" | "PROPPATCH";
export type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH" ;
Expand All @@ -23,9 +25,9 @@ export interface RequestError<E> extends Error {
}

export interface RequestOptions {
/** Default: 0 */
/** @default 0 */
maxRedirections?: number;
/** Default: { "user-agent": "httpie" } */
/** @default{ "user-agent": "httpie" } */
headers?: IncomingHttpHeaders;
querystring?: string | URLSearchParams;
body?: any;
Expand All @@ -34,6 +36,10 @@ export interface RequestOptions {
agent?: undici.Agent | undici.ProxyAgent | undici.MockAgent;
// API limiter from a package like "p-ratelimit"
limit?: InlineCallbackAction;
/** @default "parse" */
mode?: ModeOfHttpieResponseHandler;
/** @default true */
throwOnHttpError?: boolean;
}

export interface RequestResponse<T> {
Expand Down Expand Up @@ -78,18 +84,29 @@ export async function request<T>(
await limit(() => undici.request(computedURI.url, requestOptions));

const statusCode = requestResponse.statusCode;
const responseHandler = new HttpieResponseHandler(requestResponse);

let data;
if (options.mode === "parse" || !options.mode) {
data = await responseHandler.getData<T>("parse");
}
else if (options.mode === "decompress") {
data = await responseHandler.getData("decompress");
}
else {
data = await responseHandler.getData("raw");
}

const RequestResponse = {
headers: requestResponse.headers,
statusMessage: status.message[requestResponse.statusCode]!,
statusCode,
data: void 0 as any
data
};

const data = await Utils.parseUndiciResponse<T>(requestResponse);
RequestResponse.data = data;

if (statusCode >= 400) {
throw Utils.toError(RequestResponse);
const shouldThrowOnHttpError = options.throwOnHttpError || true;
if (shouldThrowOnHttpError && statusCode >= 400) {
throw new HttpieOnHttpError(RequestResponse);
}

return RequestResponse;
Expand Down
Loading

0 comments on commit 3791587

Please sign in to comment.