Skip to content

Commit

Permalink
Merge pull request #3 from swan-io/next-use-fetch
Browse files Browse the repository at this point in the history
Use fetch
  • Loading branch information
bloodyowl authored Jan 8, 2025
2 parents b9e7a9c + 638d1ff commit 112ab0e
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 108 deletions.
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ $ npm install --save @swan-io/request @swan-io/boxed
- Has a **strong contract** with data-structures from [Boxed](https://swan-io.github.io/boxed/) (`Future`, `Result` & `Option`)
- Makes the request **easily cancellable** with `Future` API
- Gives **freedom of interpretation for response status**
- Handles `onLoadStart` & `onProgress` events
- Handles **timeouts**
- Types the response using the provided `responseType`
- Types the response using the provided `type`

## Getting started

Expand Down Expand Up @@ -75,17 +74,15 @@ useEffect(() => {

- `url`: string
- `method`: `GET` (default), `POST`, `OPTIONS`, `PATCH`, `PUT` or `DELETE`
- `responseType`:
- `type`:
- `text`: (default) response will be a `string`
- `arraybuffer`: response will be a `ArrayBuffer`
- `document`: response will be `Document`
- `blob`: response will be `Blob`
- `json`: response will be a JSON value
- `body`: request body
- `headers`: a record containing the headers
- `withCredentials`: boolean
- `creatials`: `omit`, `same-origin` or `include`
- `onLoadStart`: event triggered on load start
- `onProgress`: event triggered at different times when the payload is being sent
- `timeout`: number

#### Return value
Expand Down
183 changes: 101 additions & 82 deletions src/Request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Dict, Future, Option, Result } from "@swan-io/boxed";
import { Future, Option, Result } from "@swan-io/boxed";

// Copied from type-fest, to avoid adding a dependency
type JsonObject = { [Key in string]: JsonValue } & {
Expand All @@ -9,12 +9,11 @@ type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonObject | JsonArray;

// The type system allows us infer the response type from the requested `responseType`
type ResponseType = "text" | "arraybuffer" | "document" | "blob" | "json";
type ResponseType = "text" | "arraybuffer" | "blob" | "json";

type ResponseTypeMap = {
text: string;
arraybuffer: ArrayBuffer;
document: Document;
blob: Blob;
json: JsonValue;
};
Expand Down Expand Up @@ -47,118 +46,138 @@ export class TimeoutError extends Error {
}
}

export class CanceledError extends Error {
constructor() {
super();
Object.setPrototypeOf(this, CanceledError.prototype);
this.name = "CanceledError";
}
}

type Config<T extends ResponseType> = {
url: string;
method?: Method;
responseType?: T;
body?: Document | XMLHttpRequestBodyInit;
type: T;
body?: BodyInit | null;
headers?: Record<string, string>;
withCredentials?: boolean;
onLoadStart?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
onProgress?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
credentials?: RequestCredentials;
timeout?: number;
cache?: RequestCache;
integrity?: string;
keepalive?: boolean;
mode?: RequestMode;
priority?: RequestPriority;
redirect?: RequestRedirect;
referrer?: string;
referrerPolicy?: ReferrerPolicy;
window?: null;
};

export type Response<T> = {
url: string;
status: number;
ok: boolean;
response: Option<T>;
xhr: XMLHttpRequest;
};

const make = <T extends ResponseType = "text">({
const resolvedPromise = Promise.resolve();

const make = <T extends ResponseType>({
url,
method = "GET",
responseType,
method,
type,
body,
headers,
withCredentials = false,
onLoadStart,
onProgress,
credentials,
timeout,
cache,
integrity,
keepalive,
mode,
priority,
redirect,
referrer,
referrerPolicy,
window,
}: Config<T>): Future<
Result<Response<ResponseTypeMap[T]>, NetworkError | TimeoutError>
> => {
return Future.make<
Result<Response<ResponseTypeMap[T]>, NetworkError | TimeoutError>
>((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) {
xhr.responseType = responseType;
}
const controller = new AbortController();

if (timeout != undefined) {
xhr.timeout = timeout;
if (timeout) {
setTimeout(() => {
controller.abort(new TimeoutError(url, timeout));
}, timeout);
}

if (headers != undefined) {
Dict.entries(headers).forEach(([key, value]) =>
xhr.setRequestHeader(key, value),
);
}

const onError = () => {
cleanupEvents();
resolve(Result.Error(new NetworkError(url)));
};

const onTimeout = () => {
cleanupEvents();
resolve(Result.Error(new TimeoutError(url, timeout)));
};
const init = async () => {
const res = await fetch(url, {
method,
credentials,
headers,
signal: controller.signal,
body,
cache,
integrity,
keepalive,
mode,
priority,
redirect,
referrer,
referrerPolicy,
window,
});

let payload;
try {
if (type === "arraybuffer") {
payload = Option.Some(await res.arrayBuffer());
}
if (type === "blob") {
payload = Option.Some(await res.blob());
}
if (type === "json") {
payload = Option.Some(await res.json());
}
if (type === "text") {
payload = Option.Some(await res.text());
}
} catch {
payload = Option.None();
}

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.
const response = Option.fromNullable(xhr.response);

resolve(
Result.Ok({
url,
status,
// Uses the same heuristics as the built-in `Response`
ok: status >= 200 && status < 300,
response,
xhr,
}),
);
};
const status = res.status;
const ok = res.ok;

const cleanupEvents = () => {
xhr.removeEventListener("error", onError);
xhr.removeEventListener("load", onLoad);
xhr.removeEventListener("timeout", onTimeout);
if (onLoadStart != undefined) {
xhr.removeEventListener("loadstart", onLoadStart);
}
if (onProgress != undefined) {
xhr.removeEventListener("progress", onProgress);
}
const response: Response<ResponseTypeMap[T]> = {
url,
status,
ok,
response: payload as Option<ResponseTypeMap[T]>,
};
return response;
};

xhr.addEventListener("error", onError);
xhr.addEventListener("load", onLoad);
xhr.addEventListener("timeout", onTimeout);
if (onLoadStart != undefined) {
xhr.addEventListener("loadstart", onLoadStart);
}
if (onProgress != undefined) {
xhr.addEventListener("progress", onProgress);
}

xhr.send(body);
init().then(
(response) => resolve(Result.Ok(response)),
(error) => {
if (error instanceof CanceledError) {
return resolvedPromise;
}
if (error instanceof TimeoutError) {
resolve(Result.Error(error));
return resolvedPromise;
}
resolve(Result.Error(new NetworkError(url)));
return resolvedPromise;
},
);

// Given we're using a Boxed Future, we have cancellation for free!
return () => {
cleanupEvents();
xhr.abort();
controller.abort(new CanceledError());
};
});
};
Expand Down
54 changes: 35 additions & 19 deletions test/Request.test.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,43 @@
import { expect, test } from "vitest";
import { beforeEach, expect, test } from "vitest";
import { Request, emptyToError } from "../src/Request";
import { Future, Option, Result } from "@swan-io/boxed";
import { Option, Result } from "@swan-io/boxed";

test("Request: basic", async () => {
return Request.make({ url: "data:text/plain,hello!" }).tap((value) => {
expect(value.map((value) => value.status)).toEqual(Result.Ok(200));
expect(value.map((value) => value.response)).toEqual(
Result.Ok(Option.Some("hello!")),
);
expect(value.map((value) => value.ok)).toEqual(Result.Ok(true));
});
return Request.make({ url: "data:text/plain,hello!", type: "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!")),
);
expect(value.map((value) => value.ok)).toEqual(Result.Ok(true));
},
);
});

test("Request: emptyToError", async () => {
return Request.make({ url: "data:text/plain,hello!" })
return Request.make({ url: "data:text/plain,hello!", type: "text" })
.mapOkToResult(emptyToError)
.tap((value) => {
expect(value).toEqual(Result.Ok("hello!"));
});
});

test("Request: JSON as text", async () => {
return Request.make({ url: 'data:text/json,{"ok":true}' }).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}')),
);
expect(value.map((value) => value.ok)).toEqual(Result.Ok(true));
});
return Request.make({ url: 'data:text/json,{"ok":true}', type: "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}')),
);
expect(value.map((value) => value.ok)).toEqual(Result.Ok(true));
},
);
});

test("Request: JSON as JSON", async () => {
return Request.make({
url: 'data:text/json,{"ok":true}',
responseType: "json",
type: "json",
}).tap((value) => {
expect(value.map((value) => value.status)).toEqual(Result.Ok(200));
expect(value.map((value) => value.response)).toEqual(
Expand All @@ -46,7 +50,7 @@ test("Request: JSON as JSON", async () => {
test("Request: invalid JSON as JSON", async () => {
return Request.make({
url: 'data:text/json,{"ok":UNKNOWN}',
responseType: "json",
type: "json",
}).tap((value) => {
expect(value.map((value) => value.status)).toEqual(Result.Ok(200));
expect(value.map((value) => value.response)).toEqual(
Expand All @@ -55,3 +59,15 @@ test("Request: invalid JSON as JSON", async () => {
expect(value.map((value) => value.ok)).toEqual(Result.Ok(true));
});
});

test("Request: invalid JSON as JSON", async () => {
const request = Request.make({
url: "https://api.punkapi.com/v2/beers",
type: "text",
});

request.cancel();

// @ts-expect-error
expect(request._state.tag).toBe("Cancelled");
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
// https://www.typescriptlang.org/tsconfig#Type_Checking_6248
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"exactOptionalPropertyTypes": true,
"exactOptionalPropertyTypes": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": false,
Expand Down

0 comments on commit 112ab0e

Please sign in to comment.