Skip to content

Commit

Permalink
Merge pull request #39 from Jyrno42/aborting
Browse files Browse the repository at this point in the history
Implement abortable requests
  • Loading branch information
Jyrno42 authored Dec 26, 2018
2 parents 22d88c4 + e9963f6 commit 3e8d60e
Show file tree
Hide file tree
Showing 25 changed files with 695 additions and 87 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ endpoints. It's still possible to use Resources without a router(see [Resource a
source of `ResponseWrapper`, `SuperagentResponse` and `Resource::ensureStatusAndJson` for guidance.
- ``withCredentials`` *(bool)*: Allow request backend to send cookies/authentication headers, useful when using same API for server-side rendering.
- ``allowAttachments`` *(bool)*: Allow POST like methods to send attachments.
- ``signal``: *(AbortSignal)*: Pass in an [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) object to abort the request when desired. Default: [null].

## Error handling

Expand All @@ -132,6 +133,13 @@ const errorHandler = (error) => {
type: 'NETWORK_FAILED',
error,
});
} else if (error.isAbortError) {
// Request was aborted
console.error({
type: 'ABORTED',
error,
});

} else if (error.isValidationError) {
// Validation error occurred (e.g.: wrong credentials)
console.error({
Expand Down Expand Up @@ -275,6 +283,18 @@ Error class used for all network related errors

- ``error`` *(Error)*: Original Error object that occured during network transport

### ``AbortError``

Error class used when a request is aborted

#### Extends ``ResourceErrorInterface`` and overwrites:

- ``isAbortError`` *(bool)*: Always ``true``

#### Attributes

- ``error`` *(Error)*: Original Error object that was raised by the request engine

### ``InvalidResponseCode``

Error class used when unexpected response code occurs
Expand Down
4 changes: 4 additions & 0 deletions examples/example-usage/src/error_handling.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const errorHandler = (error) => {
// Network error occurred
console.error({ type: 'NETWORK_FAILED', error });

} else if (error.isAbortError) {
// Request was aborted
console.error({ type: 'ABORTED', error });

} else if (error.isValidationError) {
// Validation error occurred (e.g.: wrong credentials)
console.error({ type: 'VALIDATION_ERROR', error });
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
"rollup-plugin-replace": "2.0.0",
"rollup-plugin-typescript2": "0.17.0",
"semver": "^5.6.0",
"superagent": "^3.6.0",
"superagent": "^4.0.0",
"temp-dir": "1.0.0",
"ts-jest": "23.10.3",
"tslib": "1.9.3",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const DEFAULTS: ConfigType = {
defaultAcceptHeader: 'application/json',

allowAttachments: false,

signal: null,
};

export default DEFAULTS;
20 changes: 20 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ import { parseErrors, prepareError } from './ValidationError';

export type ResponseText = string | boolean | undefined | any;

export class AbortError extends ResourceErrorInterface {
public readonly error: any;

public readonly name: string;
public readonly type: string;

constructor(error: any) {
super('AbortError: The user aborted a request.');

this.error = error;

this.name = 'AbortError';
this.type = (error ? error.type : null) || 'aborted';
}

get isAbortError() {
return true;
}
}

export class NetworkError extends ResourceErrorInterface {
public readonly error: any;

Expand Down
20 changes: 12 additions & 8 deletions packages/core/src/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { hasValue, isFunction, isObject, isStatusCode } from '@tg-resources/is';
import renderTemplate from 'lodash.template';

import DEFAULTS from './constants';
import { InvalidResponseCode, NetworkError, RequestValidationError } from './errors';
import { AbortError, InvalidResponseCode, NetworkError, RequestValidationError } from './errors';
import { Route } from './route';
import {
AllowedFetchMethods,
Expand Down Expand Up @@ -137,10 +137,6 @@ export abstract class Resource extends Route implements ResourceInterface {
return this._post<R, D, Params>(kwargs, data, query, attachments, requestConfig, 'del');
};

// When Abort controller is fully implemented and supported
// Add correct implementation
// public abstract abort(): void;

public renderPath<
Params extends { [K in keyof Params]?: string } = {}
>(urlParams: Params | null = null, requestConfig: RequestConfig = null): string {
Expand Down Expand Up @@ -284,11 +280,19 @@ export abstract class Resource extends Route implements ResourceInterface {
);
}
} else {
// res.hasError should only be true if network level errors occur (not statuscode errors)
const message = res && res.hasError ? res.error : '';
let error;

if (res && res.wasAborted) {
error = new AbortError(res.error);
} else {
// res.hasError should only be true if network level errors occur (not statuscode errors)
const message = res && res.hasError ? res.error : '';

error = new NetworkError(message || 'Something went awfully wrong with the request, check network log.');
}

throw this.mutateError(
new NetworkError(message || 'Something went awfully wrong with the request, check network log.'),
error,
res,
requestConfig,
);
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export interface ConfigType {
*/
allowAttachments: boolean;

/**
* signal allows passing in an AbortSignal object that allows you
* to abort one or more requests as and when desired.
*/
signal: Optional<AbortSignal>;

// allow Index Signature
[key: string]: any;
}
Expand Down Expand Up @@ -305,14 +311,18 @@ export abstract class ResourceErrorInterface {
return false;
}

// istanbul ignore next: Tested in package that implement Resource
// istanbul ignore next: Tested in packages that implement Resource
public get isInvalidResponseCode() {
return false;
}

public get isValidationError() {
return false;
}

public get isAbortError() {
return false;
}
}

export abstract class ResponseInterface {
Expand Down Expand Up @@ -353,6 +363,8 @@ export abstract class ResponseInterface {

public abstract get contentType(): Optional<string>;

public abstract get wasAborted(): boolean;

protected readonly _response: Optional<any>;
protected readonly _error: Optional<any>;
protected readonly _request: Optional<any>;
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { hasValue, isArray, isNumber, isObject, isString } from '@tg-resources/is';
import { hasValue, isAbortSignal, isArray, isNumber, isObject, isString } from '@tg-resources/is';
import cookie from 'cookie';

import { ConfigType, ObjectMap, RequestConfig, ResourceFetchMethods, ResourcePostMethods } from './types';
Expand Down Expand Up @@ -30,6 +30,10 @@ export function mergeConfig(...config: RequestConfig[]): ConfigType {
}
}

if (res.signal && !isAbortSignal(res.signal)) {
throw new Error(`Expected signal to be an instanceof AbortSignal`);
}

// Expect to be filled by now - we use default config which will fill all the right data
return res as ConfigType;
}
Expand Down
53 changes: 53 additions & 0 deletions packages/core/test/AbortError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { AbortError } from '../src';


let instance: AbortError;
const errObject = {
text: 'im an error, for real!',
};

beforeEach(() => {
instance = new AbortError(errObject);
});


describe('AbortError api', () => {
test('instance.error is correct', () => {
expect(instance.error).toEqual(errObject);
});

test('instance.name is correct', () => {
expect(instance.name).toEqual('AbortError');
});

test('instance.type is correct', () => {
expect(instance.type).toEqual('aborted');
expect(new AbortError(null).type).toEqual('aborted');
});

test('instance.type is inherited from error', () => {
expect(new AbortError({
type: 'foo',
}).type).toEqual('foo');
});

test('toString works', () => {
expect(instance.toString()).toEqual('AbortError: The user aborted a request.');
});

test('isValidationError is false', () => {
expect(instance.isValidationError).toEqual(false);
});

test('isInvalidResponseCode is false', () => {
expect(instance.isInvalidResponseCode).toEqual(false);
});

test('isNetworkError is false', () => {
expect(instance.isNetworkError).toEqual(false);
});

test('isAbortError is true', () => {
expect(instance.isAbortError).toEqual(true);
});
});
4 changes: 4 additions & 0 deletions packages/core/test/DummyResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export class DummyResponse extends ResponseInterface {
return this._data.headers;
}

public get wasAborted(): boolean {
return false;
}

get contentType(): string {
return 'application/json';
}
Expand Down
4 changes: 4 additions & 0 deletions packages/core/test/InvalidResponseCode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ describe('InvalidResponseCode api', () => {
expect(instance.toString()).toEqual('InvalidResponseCode 500: Internal Server Error');
});

test('isAbortError is false', () => {
expect(instance.isAbortError).toEqual(false);
});

test('isNetworkError is false', () => {
expect(instance.isNetworkError).toEqual(false);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/core/test/NetworkError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ describe('NetworkError api', () => {
expect(instance.isInvalidResponseCode).toEqual(false);
});

test('isAbortError is false', () => {
expect(instance.isAbortError).toEqual(false);
});

test('isNetworkError is true', () => {
expect(instance.isNetworkError).toEqual(true);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/core/test/RequestValidationError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ describe('RequestValidationError api', () => {
expect(instance.toString()).toEqual(`RequestValidationError 400: ${responseText}`);
});

test('isAbortError is false', () => {
expect(instance.isAbortError).toEqual(false);
});

test('isNetworkError is false', () => {
expect(instance.isNetworkError).toEqual(false);
});
Expand Down
8 changes: 8 additions & 0 deletions packages/core/test/mergeOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ describe('mergeConfig api', () => {
});
});

test('signal is typechecked', () => {
expect(() => {
mergeConfig(
{ signal: new Error('fake') as any, },
);
}).toThrow(/Expected signal to be an instanceof AbortSignal/);
});

test('overwrite order is LTR', () => {
expect(mergeConfig(
{ apiRoot: '' },
Expand Down
2 changes: 1 addition & 1 deletion packages/fetch-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"polyfill"
],
"dependencies": {
"cross-fetch": "^2.2.3",
"cross-fetch": "^3.0.0",
"form-data": "^2.3.3",
"formdata-polyfill": "^3.0.12"
},
Expand Down
9 changes: 9 additions & 0 deletions packages/is/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ export const isStatusCode = (statusCodes: number | number[], status: any): statu
)
);

export const isAbortSignal = (signal: any): signal is AbortSignal => {
const proto = (
signal
&& typeof signal === 'object'
&& Object.getPrototypeOf(signal)
);
return !!(proto && proto.constructor.name === 'AbortSignal');
};


export interface Constructable<T> {
new(...args: any[]): T;
Expand Down
36 changes: 35 additions & 1 deletion packages/is/test/typeChecks.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
/* tslint:disable no-empty */
import { hasValue, isArray, isFunction, isNumber, isObject, isStatusCode, isString, isStringArray } from '../src';
import {
hasValue,
isAbortSignal,
isArray,
isFunction,
isNumber,
isObject,
isStatusCode,
isString,
isStringArray,
} from '../src';


const mockFn = jest.fn(<T>(value: T) => value);
Expand Down Expand Up @@ -180,4 +190,28 @@ describe('typeChecks api', () => {

expect(isStringArray(['is', 'string', 'array'])).toEqual(true);
});

test('isAbortSignal works', () => {
expect(isAbortSignal(null)).toEqual(false);
expect(isAbortSignal(undefined)).toEqual(false);
expect(isAbortSignal(false)).toEqual(false);
expect(isAbortSignal(true)).toEqual(false);
expect(isAbortSignal(1)).toEqual(false);
expect(isAbortSignal(NaN)).toEqual(false);
expect(isAbortSignal(function f() {})).toEqual(false);
expect(isAbortSignal(() => 1)).toEqual(false);
expect(isAbortSignal(isFunction)).toEqual(false);
expect(isAbortSignal({})).toEqual(false);
expect(isAbortSignal(Object())).toEqual(false);
expect(isAbortSignal('is string')).toEqual(false);
expect(isAbortSignal([])).toEqual(false);
expect(isAbortSignal(AbortController)).toEqual(false);

expect(isAbortSignal(new AbortController().signal)).toEqual(true);

class AbortSignal {

}
expect(isAbortSignal(new AbortSignal())).toEqual(true);
});
});
4 changes: 4 additions & 0 deletions packages/test-resource/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export class DummyResponse extends ResponseInterface {
return this._data.headers;
}

public get wasAborted(): boolean {
return false;
}

get contentType(): string {
return 'application/json';
}
Expand Down
Loading

0 comments on commit 3e8d60e

Please sign in to comment.