Skip to content

Commit

Permalink
Add cancelable decorator
Browse files Browse the repository at this point in the history
  • Loading branch information
dokmic committed Oct 31, 2021
1 parent d25c14f commit f65f930
Show file tree
Hide file tree
Showing 5 changed files with 360 additions and 29 deletions.
78 changes: 59 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# TypeScript Async Method Decorators
# TypeScript Async Method Decorators 🧰
[![NPM](https://img.shields.io/npm/v/ts-async-decorators.svg)](https://www.npmjs.com/package/ts-async-decorators)
[![Downloads](https://img.shields.io/npm/dm/ts-async-decorators)](https://www.npmjs.com/package/ts-async-decorators)
[![Tests](https://github.com/dokmic/ts-async-decorators/actions/workflows/tests.yaml/badge.svg?branch=master)](https://github.com/dokmic/ts-async-decorators/actions/workflows/tests.yaml)
[![Code Coverage](https://codecov.io/gh/dokmic/ts-async-decorators/badge.svg?branch=master)](https://codecov.io/gh/dokmic/ts-async-decorators?branch=master)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

This package provides a collection of asynchronous method decorators.
This package provides a collection of asynchronous method decorators with an elegant declarative API.

## What's Inside?
- `after` - post action.
- `before` - pre action.
- `cancelable` - canceling execution.
- `debounce` - delaying execution by timeout since the last call.
- `retry` - retrying on fail.
- `semaphore` - limiting the number of simultaneous calls.
Expand Down Expand Up @@ -64,6 +65,54 @@ class SomeClass {
}
```

### `cancelable`
Wraps a call inside a cancelable promise.

```typescript
cancelable({ onCancel = undefined }): Decorator
```
- `onCancel: (instance) => void` - The action to call on canceling the returned promise.

There is also an option to set the cancelation callback from within the decorated method.

```typescript
onCancel(callback): void
```
- `callback: (instance) => void` - The callback that will be called on promise cancelation.

#### Example using parameters
```typescript
import { cancelable } from 'ts-async-decorators';

class SomeClass {
@cancelable({ onCancel() { this.stop(); } })
start() {
// ...
}

stop() {
// ...
}
}
```

#### Example using `onCancel`
```typescript
import { cancelable, onCancel } from 'ts-async-decorators';

class SomeClass {
@cancelable()
fetch() {
const controller = new AbortController();
const { signal } = controller;
onCancel(() => controller.abort());

return fetch('http://example.com', { signal });
}
}
```


### `debounce`
Delays execution by timeout since the last call.

Expand Down Expand Up @@ -164,29 +213,20 @@ timeout({ timeout, reason = 'Operation timeout.' }): Decorator

#### Example
```typescript
import PCancelable from 'p-cancelable';
import { timeout } from 'ts-async-decorators';
import { cancelable, timeout, onCancel } from 'ts-async-decorators';

class SomeClass {
@timeout({ timeout: 10000, reason = 'Fetch timeout.' })
fetchOne() {
@cancelable()
fetch() {
const controller = new AbortController();
const { signal } = controller;
onCancel(() => controller.abort());

const promise = fetch('http://example.com', { signal });

return Object.assign(promise, { cancel: () => controller.abort() });
}

@timeout({ timeout: 10000, reason = 'Fetch timeout.' })
fetchTwo() {
return new PCancelable((resolve, reject, onCancel) => {
const controller = new AbortController();
const { signal } = controller;

fetch('http://example.com', { signal }).then(resolve, reject);
onCancel(() => controller.abort());
});
return fetch('http://example.com', { signal });
}
}
```

## Examples
- [Bluetooth Low-Energy Peripheral Device](https://github.com/dokmic/bluetooth-device).
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
{
"name": "ts-async-decorators",
"version": "0.2.0",
"version": "0.3.0",
"description": "TypeScript Async Method Decorators",
"keywords": [
"typescript",
"decorator",
"async",
"promise",
"abortable",
"cancelable",
"debounce",
"retry",
"mutex",
"semaphore",
"throttle",
"timeout"
Expand Down
204 changes: 197 additions & 7 deletions src/cancelable.spec.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,212 @@
// eslint-disable-next-line max-classes-per-file
import PCancelable from 'p-cancelable';
import { isCancelable } from './cancelable';
import { cancelable, isCancelable, onCancel, CancelError } from './cancelable';

describe('isCancelable', () => {
it('should return true for a cancelable promise', () => {
const cancelable = new PCancelable((resolve) => resolve());
const promise = new PCancelable((resolve) => resolve());

expect(isCancelable(cancelable)).toBeTrue();
expect(isCancelable(promise)).toBeTrue();
});

it('should return true for a custom cancelable promise', () => {
const cancelable = Object.assign(Promise.resolve(), { cancel: jest.fn() });
const promise = Object.assign(Promise.resolve(), { cancel: jest.fn() });

expect(isCancelable(cancelable)).toBeTrue();
expect(isCancelable(promise)).toBeTrue();
});

it('should return false for a non-promise value', () => {
const cancelable = { cancel: jest.fn() };
const promise = { cancel: jest.fn() };

expect(isCancelable(cancelable)).toBeFalse();
expect(isCancelable(promise)).toBeFalse();
});
});

describe('cancelable', () => {
const callback = jest.fn();
const payload = jest.fn();

class TestClass {
@cancelable({ onCancel: callback })
method1() {
return payload();
}

@cancelable()
method2() {
return payload();
}
}

let instance: TestClass;

beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();

instance = new TestClass();
});

it('should return a cancelable promise', () => {
const result = instance.method1();

expect(result).toBeInstanceOf(Promise);
expect(result).toHaveProperty('cancel', expect.any(Function));
});

it('should resolve with the decorated method result', async () => {
payload.mockReturnValueOnce('something');

await expect(instance.method1()).resolves.toBe('something');
});

it('should reject with the thrown value', async () => {
payload.mockImplementationOnce(() => {
throw new Error('something');
});

await expect(instance.method1()).rejects.toEqual(new Error('something'));
});

it('should wrap a returned promise', async () => {
payload.mockResolvedValueOnce('something');

await expect(instance.method1()).resolves.toBe('something');
});

it('should call a callback on cancelation', async () => {
payload.mockReturnValueOnce(new Promise(jest.fn()));
const result = instance.method1();
result.cancel();

await expect(result).rejects.toBeInstanceOf(CancelError);
expect(callback).toHaveBeenCalled();
});

it('should cancel a wrapped cancelable promise', async () => {
const cancel = jest.fn();
const promise = Object.assign(new Promise(jest.fn()), { cancel });

payload.mockReturnValueOnce(promise);
const result = instance.method1();
result.cancel();

await expect(result).rejects.toBeInstanceOf(CancelError);
expect(cancel).toHaveBeenCalled();
});

it('should not fail when there is no cancelation callback', async () => {
payload.mockReturnValueOnce(new Promise(jest.fn()));
const result = instance.method2();
result.cancel();

await expect(result).rejects.toBeInstanceOf(CancelError);
});
});

describe('onCancel', () => {
const payload = jest.fn();

class TestClass {
@cancelable()
method1() {
return payload();
}

@cancelable()
method2() {
return payload();
}
}

let instance: TestClass;

beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();

instance = new TestClass();
});

it('should set cancelation callback from within a cancelable method', async () => {
const callback = jest.fn();
payload.mockImplementation(() => onCancel(callback));

const result = instance.method1();
result.cancel();

await expect(result).rejects.toBeInstanceOf(CancelError);
expect(callback).toHaveBeenCalled();
});

it('should set cancelation callback from within an asynchronous method', async () => {
const cancel = jest.fn();
payload.mockImplementation(
async () =>
new Promise(() => {
onCancel(cancel);
}),
);

const result = instance.method1();
result.cancel();

await expect(result).rejects.toBeInstanceOf(CancelError);
expect(cancel).toHaveBeenCalled();
});

it('should throw if called outside a cancelable method', () => {
expect(() => onCancel(jest.fn())).toThrow();
});

it('should keep context for nested calls', async () => {
const cancel1 = jest.fn();
const cancel2 = jest.fn();

payload.mockImplementationOnce(() => {
instance.method2();
onCancel(cancel1);
});
payload.mockImplementationOnce(() => {
onCancel(cancel2);
});

const result = instance.method1();
result.cancel();

await expect(result).rejects.toBeInstanceOf(CancelError);
expect(cancel2).toHaveBeenCalledAfter(cancel1);
});

it('should not lose context when a method throws', async () => {
const cancel = jest.fn();

payload.mockImplementationOnce(() => {
instance.method2().catch(jest.fn());
onCancel(cancel);
});
payload.mockImplementationOnce(() => {
throw new Error();
});

const result = instance.method1();
result.cancel();

await expect(result).rejects.toBeInstanceOf(CancelError);
expect(cancel).toHaveBeenCalled();
});

it('should not reject after cancelation', async () => {
payload.mockImplementation(
() =>
new Promise((resolve, reject) => {
onCancel(() => reject(new Error()));
}),
);

const result = instance.method1();
result.cancel();

await expect(result).rejects.toBeInstanceOf(CancelError);
});
});
Loading

0 comments on commit f65f930

Please sign in to comment.