diff --git a/README.md b/README.md index fa6c709..ccbc118 100644 --- a/README.md +++ b/README.md @@ -31,38 +31,49 @@ $ npm install --save @swan-io/request @swan-io/boxed import { Request, badStatusToError, emptyToError } from "@swan-io/request"; // Regular case -Request.make({ url: "/api/health" }).onResolve(console.log); +Request.make({ + url: "/api/health", + responseType: "text", +}).onResolve(console.log); // Result.Ok({status: 200, ok: true, response: Option.Some("{\"ok\":true}")}) // Timeout -Request.make({ url: "/api/health", timeout: 2000 }).onResolve(console.log); +Request.make({ + url: "/api/health", + responseType: "text", + timeout: 2000, +}).onResolve(console.log); // Result.Error(TimeoutError) // Network error -Request.make({ url: "/api/health" }).onResolve(console.log); +Request.make({ + url: "/api/health", + responseType: "text", +}).onResolve(console.log); // Result.Error(NetworkError) // Custom response type -Request.make({ url: "/api/health", responseType: "json" }).onResolve( - console.log, -); +Request.make({ + url: "/api/health", + responseType: "json", +}).onResolve(console.log); // Result.Ok({status: 200, ok: true, response: Option.Some({ok: true})}) // Handle empty response as an error -Request.make({ url: "/api/health" }) +Request.make({ url: "/api/health", responseType: "json" }) .mapOkToResult(emptyToError) .onResolve(console.log); // Result.Error(EmptyResponseError) // Handle bad status as an error -Request.make({ url: "/api/health" }) +Request.make({ url: "/api/health", responseType: "text" }) .mapOkToResult(badStatusToError) .onResolve(console.log); // Result.Error(BadStatusError) // Cancel request useEffect(() => { - const future = Request.make({ url: "/api/health" }); + const future = Request.make({ url: "/api/health", responseType: "text" }); return () => future.cancel(); }, []); ``` diff --git a/src/Request.ts b/src/Request.ts index 4cbab4a..db5464e 100644 --- a/src/Request.ts +++ b/src/Request.ts @@ -23,9 +23,11 @@ type Method = "GET" | "POST" | "OPTIONS" | "PATCH" | "PUT" | "DELETE"; export class NetworkError extends Error { url: string; + constructor(url: string) { super(`Request to ${url} failed`); Object.setPrototypeOf(this, NetworkError.prototype); + this.name = "NetworkError"; this.url = url; } @@ -34,13 +36,11 @@ export class NetworkError extends Error { export class TimeoutError extends Error { url: string; timeout: number | undefined; + constructor(url: string, timeout?: number) { - if (timeout == undefined) { - super(`Request to ${url} timed out`); - } else { - super(`Request to ${url} timed out (> ${timeout}ms)`); - } + super(`Request to ${url} timed out` + (timeout ? ` (> ${timeout}ms)` : "")); Object.setPrototypeOf(this, TimeoutError.prototype); + this.name = "TimeoutError"; this.url = url; this.timeout = timeout; @@ -49,34 +49,34 @@ export class TimeoutError extends Error { type Config = { url: string; - method?: Method; - responseType?: T; - body?: Document | XMLHttpRequestBodyInit; headers?: Record; + body?: Document | XMLHttpRequestBodyInit; + method?: Method; + responseType: T; + timeout?: number; withCredentials?: boolean; - onLoadStart?: (event: ProgressEvent) => void; onProgress?: (event: ProgressEvent) => void; - timeout?: number; + onLoadStart?: (event: ProgressEvent) => void; }; export type Response = { - url: string; - status: number; ok: boolean; response: Option; + status: number; + url: string; xhr: XMLHttpRequest; }; -const make = ({ +const make = ({ url, + headers, + body, method = "GET", responseType, - body, - headers, + timeout, withCredentials = false, - onLoadStart, onProgress, - timeout, + onLoadStart, }: Config): Future< Result, NetworkError | TimeoutError> > => { @@ -85,18 +85,20 @@ const make = ({ >((resolve) => { const xhr = new XMLHttpRequest(); xhr.withCredentials = withCredentials; + // Only allow asynchronous requests xhr.open(method, url, true); + // If `responseType` is unspecified, XHR defaults to `text` - if (responseType != undefined) { + if (responseType != null) { xhr.responseType = responseType; } - if (timeout != undefined) { + if (timeout != null) { xhr.timeout = timeout; } - if (headers != undefined) { + if (headers != null) { Dict.entries(headers).forEach(([key, value]) => xhr.setRequestHeader(key, value), ); @@ -114,6 +116,7 @@ const make = ({ const onLoad = () => { cleanupEvents(); + const status = xhr.status; // Response can be empty, which is why we represent it as an option. // We provide the `emptyToError` helper to handle this case. @@ -135,10 +138,11 @@ const make = ({ xhr.removeEventListener("error", onError); xhr.removeEventListener("load", onLoad); xhr.removeEventListener("timeout", onTimeout); - if (onLoadStart != undefined) { + + if (onLoadStart != null) { xhr.removeEventListener("loadstart", onLoadStart); } - if (onProgress != undefined) { + if (onProgress != null) { xhr.removeEventListener("progress", onProgress); } }; @@ -146,10 +150,11 @@ const make = ({ xhr.addEventListener("error", onError); xhr.addEventListener("load", onLoad); xhr.addEventListener("timeout", onTimeout); - if (onLoadStart != undefined) { + + if (onLoadStart != null) { xhr.addEventListener("loadstart", onLoadStart); } - if (onProgress != undefined) { + if (onProgress != null) { xhr.addEventListener("progress", onProgress); } @@ -167,9 +172,11 @@ export class BadStatusError extends Error { url: string; status: number; response: unknown; + constructor(url: string, status: number, response?: unknown) { super(`Request to ${url} gave status ${status}`); Object.setPrototypeOf(this, BadStatusError.prototype); + this.name = "BadStatusError"; this.url = url; this.status = status; @@ -193,9 +200,11 @@ export const badStatusToError = ( export class EmptyResponseError extends Error { url: string; + constructor(url: string) { super(`Request to ${url} gave an empty response`); Object.setPrototypeOf(this, EmptyResponseError.prototype); + this.name = "EmptyResponseError"; this.url = url; } @@ -205,6 +214,4 @@ export const emptyToError = (response: Response) => { return response.response.toResult(new EmptyResponseError(response.url)); }; -export const Request = { - make, -}; +export const Request = { make }; diff --git a/test/Request.test.ts b/test/Request.test.ts index dcdc964..38fef98 100644 --- a/test/Request.test.ts +++ b/test/Request.test.ts @@ -3,7 +3,10 @@ import { Request, emptyToError } from "../src/Request"; import { Option, Result } from "@swan-io/boxed"; test("Request: basic", async () => { - return Request.make({ url: "data:text/plain,hello!" }).tap((value) => { + return Request.make({ + url: "data:text/plain,hello!", + responseType: "text", + }).tap((value) => { expect(value.map((value) => value.status)).toEqual(Result.Ok(200)); expect(value.map((value) => value.response)).toEqual( Result.Ok(Option.Some("hello!")), @@ -13,7 +16,10 @@ test("Request: basic", async () => { }); test("Request: emptyToError", async () => { - return Request.make({ url: "data:text/plain,hello!" }) + return Request.make({ + url: "data:text/plain,hello!", + responseType: "text", + }) .mapOkToResult(emptyToError) .tap((value) => { expect(value).toEqual(Result.Ok("hello!")); @@ -21,7 +27,10 @@ test("Request: emptyToError", async () => { }); test("Request: JSON as text", async () => { - return Request.make({ url: 'data:text/json,{"ok":true}' }).tap((value) => { + return Request.make({ + url: 'data:text/json,{"ok":true}', + responseType: "text", + }).tap((value) => { expect(value.map((value) => value.status)).toEqual(Result.Ok(200)); expect(value.map((value) => value.response)).toEqual( Result.Ok(Option.Some('{"ok":true}')),