Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fetch-related utils #18

Merged
merged 4 commits into from Jun 5, 2019
Merged
Changes from 2 commits
Commits
File filter...
Filter file types
Jump to…
Jump to file or symbol
Failed to load files and symbols.

Always

Just for now

@@ -28,16 +28,20 @@
"@babel/preset-typescript": "^7.3.3",
"@babel/register": "^7.4.4",
"@betit/rollup-plugin-rename-extensions": "^0.0.4",
"@types/content-type": "^1.1.3",
"@types/enzyme": "^3.1.16",
"@types/enzyme-adapter-react-16": "^1.0.3",
"@types/jest": "^23.0.0",
"@types/jest": "^24.0.13",
"@types/jest-when": "^2.4.1",
"babel-plugin-lodash": "^3.3.4",
"content-type": "^1.0.4",
"core-js": "^3.1.3",
"coveralls": "^3.0.2",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.9.1",
"jest": "^23.1.0",
"jsdom": "^13.2.0",
"jest": "^24.8.0",
"jest-when": "^2.5.0",
"jsdom": "^15.1.1",
"jsdom-global": "^3.0.2",
"npm-run-all": "^4.1.5",
"prettier": "^1.16.4",
@@ -47,13 +51,15 @@
"rollup": "^1.13.1",
"rollup-plugin-babel": "^4.3.2",
"rollup-plugin-node-resolve": "^5.0.1",
"ts-jest": "^23.10.5",
"ts-jest": "^24.0.2",
"typescript": "^3.5.1"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "jsdom",
"setupTestFrameworkScriptFile": "./testSetup.ts",
"setupFilesAfterEnv": [
"./testSetup.ts"
],
"globals": {
"ts-jest": {
"diagnostics": false
@@ -1,6 +1,6 @@
{
"name": "@kablamo/kerosene",
"version": "0.0.2",
"version": "0.0.4",
"repository": {
"type": "git",
"url": "https://github.com/KablamoOSS/kerosene.git",
@@ -60,6 +60,22 @@ A 0-indexed enum for the months of the Gregorian Calendar

A 0-indexed enum for the days of the week

## Fetch

### `isNetworkError(error)`

Returns whether or not `error` is a `fetch()` Network Error, accounting for browser differences.

Can be used to detect when the network is not available, although may be falsely triggered by CORS.

### `transform(response)`

Returns a Promise which transforms the body of a `fetch()` request according to content type header.

### `transformAndCheckStatus(response)`

Returns a Promise which transforms the body of a `fetch()` request according to content type header and rejects if the status is not 2xx.

## Function

### `timeout(delay)`
@@ -0,0 +1,30 @@
import { isNetworkError } from "./isNetworkError";

describe("isNetworkError", () => {
[
{ error: new TypeError("Failed to fetch"), userAgent: "Chrome or Opera" },
{ error: new TypeError("NetworkError"), userAgent: "Firefox or MS Edge" },
{ error: new TypeError("Network error"), userAgent: "Safari" },
{
error: new TypeError("Network request failed"),
userAgent: "Whatwg fetch polyfill",
},
].forEach(({ error, userAgent }) => {
it(`should detect a network failure for ${userAgent} correctly`, () => {
expect(isNetworkError(error)).toBe(true);
});
});

[
{ description: "a regular error", error: new Error("Not Found") },
{
description: "a TypeError with a message that does not match",
error: new TypeError("Incorrect parameters"),
},
{ description: "a string", error: "rejected" },
].forEach(({ description, error }) => {
it(`should return false for ${description}`, () => {
expect(isNetworkError(error)).toBe(false);
});
});
});
@@ -0,0 +1,24 @@
/**
* Regex to match the error message for Network Errors thrown by `fetch()`
*
* Different browsers have different messages as follows:
* - Chrome/Opera: Failed to fetch
* - Firefox/Edge: NetworkError
* - Safari: Network error
* - Fetch polyfill: Network request failed
*/
const NETWORK_ERROR_MESSAGE_REGEX = /Failed to fetch|NetworkError|Network error|Network request failed/i;

/**
* Returns whether or not `error` is a fetch() Network Error, accounting for browser differences
*
* Note: This will trigger false-positives for CORS errors as there is no cross-browser way to detect a CORS error
*
* @param error
*/
export function isNetworkError(error: unknown) {
return (
error instanceof TypeError &&
NETWORK_ERROR_MESSAGE_REGEX.test(error.message)
);
}
@@ -0,0 +1,72 @@
import _contentType, { ParsedMediaType } from "content-type";
import { when } from "jest-when";
import { DeepPartial } from "../types";
import transform from "./transform";

jest.mock("content-type");
const contentType = (_contentType as unknown) as jest.Mocked<
typeof _contentType
>;

describe("transform", () => {
it("should resolve a 204 status as null", async () => {
await expect(
transform(({ status: 204 } as Partial<Response>) as Response),
).resolves.toBe(null);
});

it("should resolve a response without a content type as text", async () => {
const text = "Text";
await expect(
transform(({
status: 200,
headers: {
get: jest.fn().mockReturnValue(null),
},
async text() {
return text;
},
} as DeepPartial<Response>) as Response),
).resolves.toBe(text);
});

[
{
type: "application/json",
method: "json",
content: { some: "prop" },
},
{
type: "text/plain",
method: "text",
content: "Text",
},
{
type: "application/pdf",
method: "blob",
content: Symbol("Blob"),
},
].forEach(({ type, method, content }) => {
it(`should use ${method}() for ${type}`, async () => {
const header = `${type}; charset=utf-8`;
when(contentType.parse)
.calledWith(header)
.mockReturnValue(({
type,
} as Partial<ParsedMediaType>) as ParsedMediaType);
const getHeaders = jest.fn();
when(getHeaders)
.calledWith("Content-Type")
.mockReturnValue(header);
await expect(
transform({
status: 200,
headers: {
get: getHeaders,
},
[method]: async () => content,
} as any),
).resolves.toEqual(content);
});
});
});
@@ -0,0 +1,32 @@
import { parse } from "content-type";

/**
* Takes a fetch response and attempts to transform the response automatically according to the status code and
* Content-Type header (if provided)
* @param response
*/
export default function transform(response: Response) {
if (response.status === 204) {
return Promise.resolve(null);
}

const contentType = response.headers.get("Content-Type");

if (!contentType) {
return response.text();
}

const { type } = parse(contentType);

switch (type) {
case "application/json":
return response.json();

case "application/pdf":
return response.blob();

case "text/plain":
default:
return response.text();
}
}
@@ -0,0 +1,45 @@
import { when } from "jest-when";
import transformAndCheckStatus from "./transformAndCheckStatus";
import _transform from "./transform";

jest.mock("./transform");
const transform = (_transform as unknown) as jest.MockInstance<
ReturnType<typeof _transform>,
Parameters<typeof _transform>
>;

describe("transformAndCheckStatus", () => {
it("should resolve a transformed response for 2xx", async () => {
const transformed = { key: "value" };
const response = ({
status: 200,
} as Partial<Response>) as Response;
when(transform)
.calledWith(response)
.mockResolvedValue(transformed);
await expect(transformAndCheckStatus(response)).resolves.toEqual(
transformed,
);
});

it("should reject for a non-2xx response, but transform the response anyway", async () => {
const transformed = { error: "An Error" };
const response = ({
status: 400,
statusText: "Bad Request",
} as Partial<Response>) as Response;
when(transform)
.calledWith(response)
.mockResolvedValue(transformed);
await transformAndCheckStatus(response).then(
() => {
throw new Error("Expected transformAndCheckStatus to be rejected");
},
error => {
expect(error.message).toBe(response.statusText);
expect(error.status).toBe(response.status);
expect(error.response).toBe(transformed);
},
);
});
});
@@ -0,0 +1,32 @@
import transform from "./transform";

declare global {
This conversation was marked as resolved by ojkelly

This comment has been minimized.

Copy link
@ojkelly

ojkelly Jun 5, 2019

Member

Is there a way to do this without polluting global scope?

This comment has been minimized.

Copy link
@nhardy

nhardy Jun 5, 2019

Author Member

Updated

interface Error {
/**
* Response status code
*/
status?: number;
/**
* Transformed response
*/
response?: any;
}
}

/**
* Transforms the response, rejecting if the status is not 2xx
* @param response
*/
export default function transformAndCheckStatus(
response: Response,
): Promise<unknown> {
return transform(response).then(transformed => {
if (response.status >= 200 && response.status < 300) return transformed;

const error = new Error(response.statusText);
error.status = response.status;
error.response = transformed;

throw error;
});
}
@@ -2,6 +2,12 @@ export * from "./array";

export * from "./datetime";

export { default as isNetworkError } from "./fetch/isNetworkError";
export { default as transform } from "./fetch/transform";
export {
default as transformAndCheckStatus,
} from "./fetch/transformAndCheckStatus";

export { default as timeout } from "./function/timeout";
export {
default as waitForEventLoopToDrain,
@@ -32,13 +32,11 @@ React components to help with some common tasks.

Like lodash, but it's ours. Basically some pure functions that do stuff useful for anybody

_`Kerosene` is not on npm yet._

## FAQ

### How do I create a new package?

Copy an existing one and change what you need.
Copy an existing one and change what you need.

Manual intervention is required the first time you want to publish to npm.

ProTip! Use n and p to navigate between commits in a pull request.
You can’t perform that action at this time.