Skip to content

Commit

Permalink
fix(core): Independent abort signals
Browse files Browse the repository at this point in the history
Resolves #41
  • Loading branch information
JoseLion committed Jun 23, 2024
1 parent 8c78650 commit cef9699
Show file tree
Hide file tree
Showing 10 changed files with 108 additions and 181 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
nodejs: [18, 19, 20, 21]
rxjs: [5, 6, 7]
rxjs: [6, 7]
axios: ["1.0", 1.1, 1.2, 1.3, 1.4, 1.6]

steps:
Expand Down
3 changes: 2 additions & 1 deletion .mocharc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"recursive": true,
"require": [
"ts-node/register",
"test/hooks.ts"
"./test/hooks.ts",
"./test/setup.ts"
],
"spec": [
"test/**/*.test.*"
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
},
"packageManager": "[email protected]",
"devDependencies": {
"@assertive-ts/core": "^2.0.0",
"@assertive-ts/core": "^2.1.0",
"@assertive-ts/sinon": "^1.0.0",
"@eslint/compat": "^1.1.0",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.5.0",
Expand Down
81 changes: 11 additions & 70 deletions src/lib/RxjsAxios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,6 @@ interface Interceptors {
response: AxiosInterceptorManager<AxiosResponse<unknown>>;
}

interface Abortable {
controller: AbortController;
signal: AbortSignal;
}

const logger = pino({ browser: { asObject: false } });

export class RxjsAxios {
Expand Down Expand Up @@ -95,64 +90,44 @@ export class RxjsAxios {
config: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.request<T, R, D>({ ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.request<T, R, D>({ ...reqConfig, signal }));
}

public get<T, R extends AxiosResponse<T> = AxiosResponse<T>, D = unknown>(
url: string,
config?: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.get<T, R, D>(url, { ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.get<T, R, D>(url, { ...reqConfig, signal }));
}

public delete<T, R extends AxiosResponse<T> = AxiosResponse<T>, D = unknown>(
url: string,
config?: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.delete<T, R, D>(url, { ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.delete<T, R, D>(url, { ...reqConfig, signal }));
}

public head<T, R extends AxiosResponse<T> = AxiosResponse<T>, D = unknown>(
url: string,
config?: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.head<T, R, D>(url, { ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.head<T, R, D>(url, { ...reqConfig, signal }));
}

public options<T, R extends AxiosResponse<T> = AxiosResponse<T>, D = unknown>(
url: string,
config?: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.options<T, R, D>(url, { ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.options<T, R, D>(url, { ...reqConfig, signal }));
}

public post<T, R extends AxiosResponse<T> = AxiosResponse<T>, D = unknown>(
Expand All @@ -161,12 +136,8 @@ export class RxjsAxios {
config?: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.post<T, R, D>(url, data, { ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.post<T, R, D>(url, data, { ...reqConfig, signal }));
}

public put<T, R extends AxiosResponse<T> = AxiosResponse<T>, D = unknown>(
Expand All @@ -175,12 +146,8 @@ export class RxjsAxios {
config?: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.put<T, R, D>(url, data, { ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.put<T, R, D>(url, data, { ...reqConfig, signal }));
}

public patch<T, R extends AxiosResponse<T> = AxiosResponse<T>, D = unknown>(
Expand All @@ -189,12 +156,8 @@ export class RxjsAxios {
config?: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.patch<T, R, D>(url, data, { ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.patch<T, R, D>(url, data, { ...reqConfig, signal }));
}

public postForm<T, R extends AxiosResponse<T> = AxiosResponse<T>, D = unknown>(
Expand All @@ -203,12 +166,8 @@ export class RxjsAxios {
config?: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.postForm<T, R, D>(url, data, { ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.postForm<T, R, D>(url, data, { ...reqConfig, signal }));
}

public putForm<T, R extends AxiosResponse<T> = AxiosResponse<T>, D = unknown>(
Expand All @@ -217,12 +176,8 @@ export class RxjsAxios {
config?: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.putForm<T, R, D>(url, data, { ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.putForm<T, R, D>(url, data, { ...reqConfig, signal }));
}

public patchForm<T, R extends AxiosResponse<T> = AxiosResponse<T>, D = unknown>(
Expand All @@ -231,12 +186,8 @@ export class RxjsAxios {
config?: AxiosRequestConfig<D>,
): Observable<R> {
const reqConfig = this.validateConfig(config);
const { controller, signal } = this.makeCancellable();

return observify(
() => this.axios.patchForm<T, R, D>(url, data, { ...reqConfig, signal }),
controller,
);
return observify(signal => this.axios.patchForm<T, R, D>(url, data, { ...reqConfig, signal }));
}

private validateConfig<D>(config?: AxiosRequestConfig<D>): AxiosRequestConfig<D> | undefined {
Expand All @@ -257,14 +208,4 @@ export class RxjsAxios {

return config;
}

private makeCancellable(): Abortable {
const controller = new AbortController();
const signal = controller.signal;

return {
controller,
signal,
};
}
}
13 changes: 7 additions & 6 deletions src/lib/observify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@ import { Observable } from "rxjs";

import type { AxiosResponse } from "./RxjsAxios";

export function observify<T, R extends AxiosResponse<T>>(
makeRequest: () => Promise<R>,
controller: AbortController,
): Observable<R> {
type RequestFn<R> = (signal: AbortSignal) => Promise<R>;

export function observify<T, R extends AxiosResponse<T>>(makeRequest: RequestFn<R>): Observable<R> {
return new Observable(subscriber => {
makeRequest()
const controller = new AbortController();

makeRequest(controller.signal)
.then(response => subscriber.next(response))
.catch((error: unknown) => subscriber.error(error))
.finally(() => subscriber.complete());

return { unsubscribe: () => controller.abort() };
return () => controller.abort();
});
}
2 changes: 1 addition & 1 deletion test/helpers/mocks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpResponse, PathParams, http } from "msw";

interface User {
export interface User {
id?: number;
lastname: string;
name: string;
Expand Down
22 changes: 22 additions & 0 deletions test/lib/RxjsAxios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { TypeFactories, expect } from "@assertive-ts/core";
import originalAxios from "axios";
import FormData from "form-data";
import { Observable } from "rxjs";
import { map, repeat } from "rxjs/operators";
import Sinon from "sinon";

import { axios } from "../../src";
import { RxjsAxios } from "../../src/lib/RxjsAxios";
import { User } from "../helpers/mocks";

describe("[Unit] RxjsAxios.test.ts", () => {
describe(".of", () => {
Expand Down Expand Up @@ -150,6 +152,26 @@ describe("[Unit] RxjsAxios.test.ts", () => {
done();
});
});

context("when the data is fetched repeatedly", () => {
it("returns the data on each rquest", done => {
const ref = { count: 0 };

axios.get<User[]>("http://localhost:8080/users")
.pipe(
map(({ data }) => data),
repeat({ count: 3, delay: 200 }),
)
.subscribe(users => {
ref.count++;
expect(users).not.toBeEmpty();

if (ref.count >= 3) {
done();
}
});
});
});
});

describe(".delete", () => {
Expand Down
46 changes: 18 additions & 28 deletions test/lib/observify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,12 @@ const RESPONSE: AxiosResponse<string> = {

const REQUEST_ERROR = new AxiosError("Something went wrong", "Bad Request");

const controller = new AbortController();

describe("[Unit] observify.test.ts", () => {
describe(".observify", () => {
context("when the request promise is resolved", () => {
context("and the observable is not unsubscribed", () => {
it("sets the axios response on the next value and completes the observable", done => {
const observable = observify(
() => Promise.resolve<AxiosResponse<unknown>>(RESPONSE),
controller,
);
const observable = observify(() => Promise.resolve<AxiosResponse<unknown>>(RESPONSE));

observable.subscribe({
complete: done,
Expand All @@ -38,35 +33,31 @@ describe("[Unit] observify.test.ts", () => {

context("and the observable is unsubscribed", () => {
it("does not set the next value and cancels the request", done => {
const abort = Sinon.spy(controller, "abort");
const observable = observify(
() => delay(10).then(() => RESPONSE),
controller,
);
const spy = Sinon.spy();
const observable = observify(signal => {
signal.addEventListener("abort", spy);
return delay(10).then(() => RESPONSE);
});

const subscription = observable.subscribe({
complete: done,
error: error => expect(error).not.toBePresent(),
next: response => expect(response).not.toBePresent(),
});

expect(spy).toNeverBeCalled();
subscription.unsubscribe();

delay(20)
.then(() => Sinon.assert.calledOnce(abort))
.then(done)
.catch(done);
expect(spy).toBeCalledOnce();
done();
});
});
});

context("when the request promise is rejected", () => {
context("and the observable is not unsubscribed", () => {
it("sets the axios error on the error value and completes the observable", done => {
const observable = observify(
() => Promise.reject(REQUEST_ERROR),
controller,
);
const observable = observify(() => Promise.reject(REQUEST_ERROR));

observable.subscribe({
complete: done,
Expand All @@ -81,24 +72,23 @@ describe("[Unit] observify.test.ts", () => {

context("and the observable is unsubscribed", () => {
it("does not set the error value and cancels the request", done => {
const abort = Sinon.spy(controller, "abort");
const observable = observify(
() => delay(10).then(() => Promise.reject(REQUEST_ERROR)),
controller,
);
const spy = Sinon.spy();
const observable = observify(signal => {
signal.addEventListener("abort", spy);
return delay(10).then(() => Promise.reject(REQUEST_ERROR));
});

const subscription = observable.subscribe({
complete: done,
error: error => expect(error).not.toBePresent(),
next: response => expect(response).not.toBePresent(),
});

expect(spy).toNeverBeCalled();
subscription.unsubscribe();

delay(20)
.then(() => Sinon.assert.calledOnce(abort))
.then(done)
.catch(done);
expect(spy).toBeCalledOnce();
done();
});
});
});
Expand Down
4 changes: 4 additions & 0 deletions test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { usePlugin } from "@assertive-ts/core";
import { SinonPlugin } from "@assertive-ts/sinon";

usePlugin(SinonPlugin);
Loading

0 comments on commit cef9699

Please sign in to comment.