diff --git a/README.md b/README.md index 6497cf96e..934b5c812 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ This is a simple nestjs module that exposes the [got](https://www.npmjs.com/pack

npm Coveralls github - npm version + npm version LICENCE CircleCI build + npm bundle size (scoped)

@@ -142,14 +143,14 @@ The `GotModuleOptions` is an alias for the `got` package's `ExtendOptions` hence ## API Methods -The module currently only exposes the basic JSON HTTP verbs through the GotService i.e. `get`, `head`, `post`, `put`, `patch` and `delete`. +The module currently only exposes the basic JSON HTTP verbs, as well as the pagination methods through the `GotService`. -All these methods support the same argument inputs i.e.: +For all JSON HTTP verbs - `get`, `head`, `post`, `put`, `patch` and `delete` - which are also the exposed methods, below is the the method signature where `method: string` **MUST** be any of their corresponding verbs. ```ts // This is just used to explain the methods as this code doesn't exist in the package import { Observable } from 'rxjs'; -immport { Response, OptionsOfJSONResponseBody } from 'got'; +import { Response, OptionsOfJSONResponseBody } from 'got'; interface GotServiceInterface { [method: string]: ( @@ -159,13 +160,45 @@ interface GotServiceInterface { } ``` +For all pagination methods - `each` and `all`, below is the method signature each of them. + +```ts +// This is just used to explain the methods as this code doesn't exist in the package +import { Observable } from 'rxjs'; +import { Response, OptionsOfJSONResponseBody } from 'got'; + +interface GotServiceInterface { + [method: string]: ( + url: string | URL, + options?: OptionsWithPagination, + ) => Observable; +} +``` + +A usage example of would be: + +```ts +@Controller() +export class ExampleController { + constructor(private readonly gotService: GotService) {} + + controllerMethod() { + // ... + this.gotService.pagination.all(someUrl, withOptions); // Returns Observable + // or + this.gotService.pagination.each(someUrl, withOptions); // Returns Observable + // ... + } +} +``` + +For more information of the usage pattern, please check [here](https://www.npmjs.com/package/got#pagination-1) + ## ToDos As stated above, this module only support some http verbs, however, the following are still in progress: -1. Support for pagination - -2. Support for a `StreamService` which is supported by the **got** package itself. +1. Support for a `StreamService` which is supported by the **got** package itself. ## Contributing diff --git a/lib/abstrace.service.ts b/lib/abstrace.service.ts new file mode 100644 index 000000000..1e45d1f6f --- /dev/null +++ b/lib/abstrace.service.ts @@ -0,0 +1,9 @@ +import { Got, InstanceDefaults } from 'got'; + +export abstract class AbstractService { + readonly defaults: InstanceDefaults; + + constructor(protected readonly got: Got) { + this.defaults = this.got.defaults; + } +} diff --git a/lib/addons/index.ts b/lib/addons/index.ts new file mode 100644 index 000000000..928c8e546 --- /dev/null +++ b/lib/addons/index.ts @@ -0,0 +1 @@ +export * from './rxjs'; diff --git a/lib/addons/rxjs/index.ts b/lib/addons/rxjs/index.ts new file mode 100644 index 000000000..eab154b40 --- /dev/null +++ b/lib/addons/rxjs/index.ts @@ -0,0 +1 @@ +export * from './scheduleAsyncIterable'; diff --git a/lib/addons/rxjs/scheduleAsyncIterable.spec.ts b/lib/addons/rxjs/scheduleAsyncIterable.spec.ts new file mode 100644 index 000000000..ea830f6e5 --- /dev/null +++ b/lib/addons/rxjs/scheduleAsyncIterable.spec.ts @@ -0,0 +1,27 @@ +import { asapScheduler } from 'rxjs'; +import { take } from 'rxjs/operators'; + +import { scheduledAsyncIterable } from './scheduleAsyncIterable'; + +describe('scheduleAsyncIterable()', () => { + it('', () => { + const iterator = async function* () { + let i = 1; + while (true) { + yield i; + i += 1; + } + }; + + const iterable = iterator(); + let count = 1; + + scheduledAsyncIterable(iterable, asapScheduler) + .pipe(take(5)) + .subscribe({ + next(v) { + expect(v).toEqual(count++); + }, + }); + }); +}); diff --git a/lib/addons/rxjs/scheduleAsyncIterable.ts b/lib/addons/rxjs/scheduleAsyncIterable.ts new file mode 100644 index 000000000..4c5a981c8 --- /dev/null +++ b/lib/addons/rxjs/scheduleAsyncIterable.ts @@ -0,0 +1,28 @@ +import { Observable, Subscriber, Subscription, SchedulerLike } from 'rxjs'; + +export const scheduledAsyncIterable = ( + input: AsyncIterable | AsyncGenerator, + scheduler: SchedulerLike, +): Observable => { + return new Observable((subscriber: Subscriber) => { + const subscription = new Subscription(); + subscription.add( + scheduler.schedule(() => { + const iterator = input[Symbol.asyncIterator](); + subscription.add( + scheduler.schedule(function () { + iterator.next().then((result: IteratorResult) => { + if (result.done) { + subscriber.complete(); + } else { + subscriber.next(result.value); + this.schedule(); + } + }); + }), + ); + }), + ); + return subscription; + }); +}; diff --git a/lib/got.module.ts b/lib/got.module.ts index 54cf17c7b..283ab1df1 100644 --- a/lib/got.module.ts +++ b/lib/got.module.ts @@ -7,10 +7,11 @@ import { GotModuleAsyncOptions, GotModuleOptionsFactory, } from './got.interface'; +import { PaginationService } from './paginate.service'; import { GOT_INSTANCE, GOT_OPTIONS } from './got.constant'; @Module({ - providers: [GotService], + providers: [GotService, PaginationService], exports: [GotService], }) export class GotModule { diff --git a/lib/got.service.spec.ts b/lib/got.service.spec.ts index c0b8b4c45..8525dd363 100644 --- a/lib/got.service.spec.ts +++ b/lib/got.service.spec.ts @@ -1,9 +1,10 @@ import * as faker from 'faker'; -import { Got, HTTPError, Response } from 'got/dist/source'; +import { Got, HTTPError, Response } from 'got'; import { Test, TestingModule } from '@nestjs/testing'; import { GotService } from './got.service'; import { GOT_INSTANCE } from './got.constant'; +import { PaginationService } from './paginate.service'; describe('GotService', () => { let service: GotService; @@ -22,6 +23,10 @@ describe('GotService', () => { provide: GOT_INSTANCE, useValue: gotInstance, }, + { + provide: PaginationService, + useValue: {}, + }, ], }).compile(); diff --git a/lib/got.service.ts b/lib/got.service.ts index 729f979b1..c66d20016 100644 --- a/lib/got.service.ts +++ b/lib/got.service.ts @@ -1,7 +1,6 @@ import { Got, Response, - InstanceDefaults, CancelableRequest, OptionsOfJSONResponseBody, } from 'got'; @@ -9,14 +8,18 @@ import { Observable, Subscriber } from 'rxjs'; import { Inject, Injectable } from '@nestjs/common'; import { GOT_INSTANCE } from './got.constant'; +import { AbstractService } from './abstrace.service'; +import { PaginationService } from './paginate.service'; @Injectable() -export class GotService { - readonly defaults: InstanceDefaults; - private _request!: CancelableRequest; +export class GotService extends AbstractService { + protected _request!: CancelableRequest>; - constructor(@Inject(GOT_INSTANCE) private readonly got: Got) { - this.defaults = this.got.defaults; + constructor( + @Inject(GOT_INSTANCE) got: Got, + readonly pagination: PaginationService, + ) { + super(got); } head | []>( @@ -70,33 +73,35 @@ export class GotService { url: string | URL, options?: OptionsOfJSONResponseBody, ): Observable> { - this._request = this.got[method](url, { + this._request = this.got[method](url, { ...options, responseType: 'json', ...this.defaults, }); - return new Observable((subscriber: Subscriber) => { - this._request - .then(response => subscriber.next(response)) - .catch( - ( - error: Pick< - Got, - | 'ReadError' - | 'HTTPError' - | 'ParseError' - | 'CacheError' - | 'UploadError' - | 'CancelError' - | 'RequestError' - | 'TimeoutError' - | 'MaxRedirectsError' - | 'UnsupportedProtocolError' - >, - ) => subscriber.error(error), - ) - .finally(() => subscriber.complete()); - }); + return new Observable>( + (subscriber: Subscriber>) => { + this._request + .then((response: Response) => subscriber.next(response)) + .catch( + ( + error: Pick< + Got, + | 'ReadError' + | 'HTTPError' + | 'ParseError' + | 'CacheError' + | 'UploadError' + | 'CancelError' + | 'RequestError' + | 'TimeoutError' + | 'MaxRedirectsError' + | 'UnsupportedProtocolError' + >, + ) => subscriber.error(error), + ) + .finally(() => subscriber.complete()); + }, + ); } } diff --git a/lib/paginate.service.spec.ts b/lib/paginate.service.spec.ts new file mode 100644 index 000000000..6c55f02cb --- /dev/null +++ b/lib/paginate.service.spec.ts @@ -0,0 +1,81 @@ +import * as faker from 'faker'; +import { Got, HTTPError, Response } from 'got'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { GotService } from './got.service'; +import { GOT_INSTANCE } from './got.constant'; +import { PaginationService } from './paginate.service'; + +describe('GotService', () => { + let service: PaginationService; + const gotInstance: Partial = { + defaults: { + options: jest.fn(), + } as any, + }, + exemptedKeys = ['makeObservable', 'defaults', 'constructor']; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PaginationService, + { + provide: GOT_INSTANCE, + useValue: gotInstance, + }, + { + provide: GotService, + useValue: {}, + }, + ], + }).compile(); + + service = module.get(PaginationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + const methods = Object.getOwnPropertyNames( + PaginationService.prototype, + ).filter(key => !~exemptedKeys.indexOf(key)); + + methods.forEach((key, index) => { + it(`${key}()`, complete => { + const result: Partial = { body: {} }; + + gotInstance[key] = jest.fn().mockResolvedValueOnce(result); + + service[key](faker.internet.url()).subscribe({ + next(response) { + expect(response).toBe(result); + }, + complete, + }); + }); + + if (methods.length - 2 === index) { + it('check that defaults is set', () => + expect('options' in service.defaults).toBe(true)); + + it('should get request', () => { + service[key](faker.internet.url()); + }); + + it('should check error reporting', () => { + const result: any = { body: {}, statusCode: 400 }; + + gotInstance[key] = jest + .fn() + .mockRejectedValueOnce(new HTTPError(result)); + + service[key](faker.internet.url()).subscribe({ + error(error) { + expect(error).toBeInstanceOf(HTTPError); + }, + }); + }); + } + }); +}); diff --git a/lib/paginate.service.ts b/lib/paginate.service.ts new file mode 100644 index 000000000..49d157f2e --- /dev/null +++ b/lib/paginate.service.ts @@ -0,0 +1,62 @@ +// prettier-ignore +import { + Got, + OptionsWithPagination +} from 'got'; +import { Inject, Injectable } from '@nestjs/common'; +import { Observable, asapScheduler, scheduled } from 'rxjs'; + +import { GOT_INSTANCE } from './got.constant'; +import { scheduledAsyncIterable } from './addons'; +import { AbstractService } from './abstrace.service'; + +@Injectable() +export class PaginationService extends AbstractService { + constructor(@Inject(GOT_INSTANCE) got: Got) { + super(got); + } + + each( + url: string | URL, + options?: OptionsWithPagination, + ): Observable { + return this.makeObservable('each', url, options); + } + + all( + url: string | URL, + options?: OptionsWithPagination, + ): Observable { + return this.makeObservable('all', url, options); + } + + private makeObservable( + method: 'all', + url: string | URL, + options?: OptionsWithPagination, + ): Observable; + private makeObservable( + method: 'each', + url: string | URL, + options?: OptionsWithPagination, + ): Observable; + private makeObservable( + method: 'each' | 'all', + url: string | URL, + options?: OptionsWithPagination, + ): Observable { + options = { ...options, ...this.defaults }; + + if (method === 'all') { + return scheduled( + this.got.paginate.all(url, options), + asapScheduler, + ); + } + + return scheduledAsyncIterable( + this.got.paginate.each(url, options), + asapScheduler, + ); + } +} diff --git a/tests/e2e/module.e2e-spec.ts b/tests/e2e/module.e2e-spec.ts index f3fad2477..b80e557d1 100644 --- a/tests/e2e/module.e2e-spec.ts +++ b/tests/e2e/module.e2e-spec.ts @@ -3,9 +3,12 @@ import * as faker from 'faker'; import { Got, RequestError } from 'got'; import { Test, TestingModule } from '@nestjs/testing'; +import { getMethods } from '../src/utils'; import { AppModule } from '../src/app.module'; import { AppService } from '../src/app.service'; import { GOT_INSTANCE } from '../../lib/got.constant'; +import { PaginateService } from '../src/paginate.service'; +import { HttpStatus } from '@nestjs/common'; describe('GotModule', () => { let module: TestingModule, gotInstance: Got; @@ -54,13 +57,8 @@ describe('GotModule', () => { describe('test features', () => { describe('GotService', () => { - let appService: AppService, - exemptedKeys = [ - 'makeObservable', - 'request', - 'defaults', - 'constructor', - ]; + let appService: AppService; + beforeEach(async () => { module = await Test.createTestingModule({ imports: [AppModule.withRegister()], @@ -71,33 +69,29 @@ describe('GotModule', () => { appService = module.get(AppService); }); - const methods = Object.getOwnPropertyNames( - AppService.prototype, - ).filter(key => !~exemptedKeys.indexOf(key)); + const appMethods = getMethods(AppService); - methods.forEach((key, index) => { + appMethods.forEach((key, index) => { it(`${key}()`, complete => { const url = faker.internet.url(); const route = faker.internet.domainWord(); const res = { random: 'random' }; - nock(url)[key](`/${route}`).reply(200, res, { + nock(url)[key](`/${route}`).reply(HttpStatus.OK, res, { 'Content-Type': 'application/json', }); appService[key](`${url}/${route}`).subscribe({ next(response) { expect(response).toEqual( - expect.objectContaining({ - body: res, - }), + expect.objectContaining({ body: res }), ); }, complete, }); }); - if (methods.length - 2 === index) { + if (appMethods.length - 2 === index) { it('should check error reporting', complete => { const url = faker.internet.url(); const route = faker.internet.domainWord(); @@ -106,7 +100,7 @@ describe('GotModule', () => { get response() { return this.message; }, - code: 400, + code: HttpStatus.BAD_REQUEST, }; nock(url)[key](`/${route}`).replyWithError(res); @@ -122,6 +116,59 @@ describe('GotModule', () => { } }); }); + + describe('PaginationService', () => { + let paginateService: PaginateService; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [AppModule.withRegister()], + providers: [PaginateService], + exports: [PaginateService], + }).compile(); + + paginateService = module.get( + PaginateService, + ); + }); + + const paginateMethod = getMethods( + PaginateService, + ); + + paginateMethod.forEach(key => { + it(`${key}()`, async () => { + const url = faker.internet.url(); + const route = faker.internet.domainWord(); + const object = { + name: `${faker.name.firstName()} ${faker.name.lastName()}`, + }; + const response = [object]; + + nock(url) + .get(`/${route}`) + .reply(HttpStatus.OK, response, { + 'Content-Type': 'application/json', + }); + + if (key === 'all') { + paginateService.all(`${url}/${route}`).subscribe({ + next(res) { + expect(res).toEqual( + expect.arrayContaining(response), + ); + }, + }); + } else { + expect( + await paginateService + .each(`${url}/${route}`) + .toPromise(), + ).toEqual(expect.objectContaining(object)); + } + }); + }); + }); }); }); }); diff --git a/tests/src/app.service.ts b/tests/src/app.service.ts index 31cf44a91..c95989c9a 100644 --- a/tests/src/app.service.ts +++ b/tests/src/app.service.ts @@ -1,8 +1,11 @@ -import * as faker from 'faker'; +// prettier-ignore +import { + OptionsWithPagination, + OptionsOfJSONResponseBody, +} from 'got'; import { Injectable } from '@nestjs/common'; import { GotService } from '../../lib'; -import { OptionsOfJSONResponseBody } from 'got/dist/source'; @Injectable() export class AppService { diff --git a/tests/src/paginate.service.ts b/tests/src/paginate.service.ts new file mode 100644 index 000000000..7f46ff7c6 --- /dev/null +++ b/tests/src/paginate.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { OptionsWithPagination } from 'got'; + +import { GotService } from '../../lib'; + +@Injectable() +export class PaginateService { + constructor(private readonly gotService: GotService) {} + + each(url: string, options?: OptionsWithPagination) { + return this.gotService.pagination.each(url, options); + } + + all(url: string, options?: OptionsWithPagination) { + return this.gotService.pagination.all(url, options); + } +} diff --git a/tests/src/utils/index.ts b/tests/src/utils/index.ts new file mode 100644 index 000000000..753392d75 --- /dev/null +++ b/tests/src/utils/index.ts @@ -0,0 +1,8 @@ +import { Type } from '@nestjs/common'; + +const exemptedKeys = ['pagination', 'constructor']; + +export const getMethods = (cl: Type) => + Object.getOwnPropertyNames(cl.prototype).filter( + key => !~exemptedKeys.indexOf(key), + );