diff --git a/projects/gitlab-client/README.md b/projects/gitlab-client/README.md index 6bdcde5..98a31ca 100644 --- a/projects/gitlab-client/README.md +++ b/projects/gitlab-client/README.md @@ -57,3 +57,95 @@ export class MyModule { } ``` +## Use Gitlab Service directly + +`GitlabService` is a common service without any relation to a special REST resource. +We can use it, when we + +- want to access a resource that is not provided by any of the other services +- want to get notified whenever a REST call was made (or an error occurred) + +## Simple calls + +Calls to Gitlab are made using the Angular `HttpClient`. To invoke a simple get request +(using the configured Gitlab connection configuration), we can use + +```typescript + gitlab + .call('projects/5/repository/commits') + .subscribe(commit => { + // ... + }) +``` + +We can also provide the HTTP method and some other options like additional headers: + +```typescript + gitlab + .call('projects/5/repository/commits', 'post', { + body: myNewCommit, + headers: { + myHeaderName: myHeaderValue + } + }) + .subscribe(result => { + // ... + }) +``` + +### Paginated calls + +For big data, Gitlab uses pagination. `GitlabService` is able to handle it +and provides lazy loading, i.e. it only calls the API when we need to read the data. + +We can simply use + +```typescript + gitlab + .callPaginated('projects/5/repository/commits') + .pipe(take(10)) // only read the first 10 entries, then skip + .subscribe(dataset => { + let myObj = dataset.payload; + let index = dataset.index; + let total = dataset.total; + // ... + }) +``` + +We have to be aware that the default page size is 20, so those 20 entries are read out with a single request. +If we already know the count of entries we want to read, we could also specify a different page size: + +```typescript + gitlab + .callPaginated('projects/5/repository/commits', null, 10) +``` + +### Notifications for Gitlab calls + +We can get notified when a call to Gitlab is made or when an error occurred. +E.g., this allows to provide a kind of component to display the current connection status: + +```typescript +export class GitlabConnectionStatusComponent implements OnInit, OnDestroy { + + private accesses?: Subscription; + private errors?: Subscription; + + constructor(private readonly gitlab: GitlabService) { + } + + ngOnInit(): void { + this.accesses = this.gitlab.accesses.subscribe(access => { + // ... (access is type of GitlabAccess) + }); + this.errors = this.gitlab.errors.subscribe(err => { + // ... (err is type of GitlabAccessError) + }); + } + + ngOnDestroy(): void { + this.accesses?.unsubscribe(); + this.errors?.unsubscribe() + } +} +``` diff --git a/projects/gitlab-client/src/lib/gitlab-client.module.ts b/projects/gitlab-client/src/lib/gitlab-client.module.ts index 0e8ac1f..5f75e5b 100644 --- a/projects/gitlab-client/src/lib/gitlab-client.module.ts +++ b/projects/gitlab-client/src/lib/gitlab-client.module.ts @@ -12,8 +12,8 @@ import {IssueExportService} from './services/issues/issue-export.service'; import {IssueExportModelMapperService} from './services/issues/issue-export-model-mapper.service'; /** - * Use this injection token to configure a Gitlab configuration provider. - * This allows dynamically resolving the Gitlab config by using a service, like shown here: + * Use this injection token to configure a Gitlab connection configuration provider. + * This allows dynamically resolving the Gitlab connection configuration by using a service: * *
  * {
@@ -26,6 +26,50 @@ import {IssueExportModelMapperService} from './services/issues/issue-export-mode
  */
 export const GITLAB_CONFIG_PROVIDER = new InjectionToken<() => GitlabConfig>("Gitlab Configuration Provider");
 
+/**
+ * The module that provides all available services to access Gitlab.
+ * If the Gitlab connection is static, use
+ * 
+ * @NgModule({
+ *   imports: [
+ *     GitlabClientModule.forRoot({
+ *       host: 'https://mygitlabhost/',
+ *       token: 'mygitlabtoken'
+ *     })
+ *   ]
+ * })
+ * export class MyModule {
+ * }
+ * 
+ * + * to import the module. If the Gitlab connection is resolved dynamically, e.g. by another service, + * import the module directly and provide a GITLAB_CONFIG_PROVIDER: + *
+ *   @Injectable({providedIn: 'root'})
+ *   export class MyGitlabConfigService {
+ *
+ *     readConfiguration(): GitlabConfig {
+ *       // ...
+ *     }
+ *
+ *   }
+ *
+ *   @NgModule({
+ *     imports: [
+ *       GitlabClientModule
+ *     ],
+ *     providers: [
+ *       {
+ *         provide: GITLAB_CONFIG_PROVIDER,
+ *         useFactory: (service: MyGitlabConfigService) => service.readConfiguration,
+ *         deps: [MyGitlabConfigService],
+ *       }
+ *    ]
+ *  })
+ *  export class MyModule {
+ *  }
+ * 
+ */ @NgModule({ imports: [ HttpClientModule diff --git a/projects/gitlab-client/src/lib/services/shared/gitlab.service.spec.ts b/projects/gitlab-client/src/lib/services/shared/gitlab.service.spec.ts index b5668e9..8c6406f 100644 --- a/projects/gitlab-client/src/lib/services/shared/gitlab.service.spec.ts +++ b/projects/gitlab-client/src/lib/services/shared/gitlab.service.spec.ts @@ -1,5 +1,5 @@ import {createHttpFactory, HttpMethod, SpectatorHttp} from '@ngneat/spectator/jest'; -import {DataSet, GitlabService} from './gitlab.service'; +import {DataSet, GitlabService, NoGitlabConnectionProviderError} from './gitlab.service'; import {HttpTestingController} from '@angular/common/http/testing'; import {map, merge, take, toArray} from 'rxjs'; import {GITLAB_CONFIG_PROVIDER} from '../../gitlab-client.module'; @@ -7,216 +7,230 @@ import {GitlabConfig} from '../../config/gitlab-config.model'; describe('GitlabService', () => { - // TODO test error when provider is null - - const createHttp = createHttpFactory({ - service: GitlabService, - providers: [ - { - provide: GITLAB_CONFIG_PROVIDER, - useValue: () => ({ - host: 'host', - token: 'token' - } as GitlabConfig) - } - ] - }); - - const errorResponseOptions = () => ({ - status: 500, - statusText: 'internal server error' - }); + describe('without Gitlab connection provider', () => { - let spectator: SpectatorHttp; - // Mock - let http: HttpTestingController; - // Class under Test - let gitlab: GitlabService; + const createHttp = createHttpFactory({ + service: GitlabService + }); + it('should throw an error when Gitlab config provider is not available', () => { + expect(() => createHttp()).toThrowError(NoGitlabConnectionProviderError); + }) - beforeEach(() => { - spectator = createHttp(); - gitlab = spectator.service; - http = spectator.controller; }); - describe('making a simple request', () => { + describe('with Gitlab config provider', () => { + + const createHttp = createHttpFactory({ + service: GitlabService, + providers: [ + { + provide: GITLAB_CONFIG_PROVIDER, + useValue: () => ({ + host: 'host', + token: 'token' + } as GitlabConfig) + } + ] + }); - it('should not fetch until subscription', () => { - gitlab.call('test'); - http.verify(); + const errorResponseOptions = () => ({ + status: 500, + statusText: 'internal server error' }); - it('should use GET as default method', () => { - gitlab.call('test').subscribe(); - http.expectOne(req => req.method === HttpMethod.GET); - http.verify(); + let spectator: SpectatorHttp; + // Mock + let http: HttpTestingController; + // Class under Test + let gitlab: GitlabService; + + + beforeEach(() => { + spectator = createHttp(); + gitlab = spectator.service; + http = spectator.controller; }); - it('should set options correctly', done => { - const responseBody = {test: 'test'}; - gitlab.call('test', 'delete', { - body: 'body', - params: { - param1: 'pValue1' - }, - headers: { - header1: 'hValue1' - } - }).subscribe(response => { - expect(response).toMatchObject(responseBody); - done(); + describe('making a simple request', () => { + + it('should not fetch until subscription', () => { + gitlab.call('test'); + http.verify(); }); - const req = http.expectOne('host/api/v4/test?param1=pValue1', HttpMethod.DELETE) - const request = req.request; - expect(request.body).toBe('body'); - expect(request.headers.get('header1')).toBe('hValue1'); - expect(request.headers.get('PRIVATE-TOKEN')).toBe('token'); - req.flush(responseBody); - }); - it('should notify observers', done => { - gitlab.accesses.subscribe(() => done()); - gitlab.call('test').subscribe(); - http.expectOne(() => true) - .flush(null); - http.verify(); - }); + it('should use GET as default method', () => { + gitlab.call('test').subscribe(); + http.expectOne(req => req.method === HttpMethod.GET); + http.verify(); + }); - it('should notify observers on error', done => { - gitlab.errors.subscribe(err => { - expect(err.status).toBe(500); - done(); + it('should set options correctly', done => { + const responseBody = {test: 'test'}; + gitlab.call('test', 'delete', { + body: 'body', + params: { + param1: 'pValue1' + }, + headers: { + header1: 'hValue1' + } + }).subscribe(response => { + expect(response).toMatchObject(responseBody); + done(); + }); + const req = http.expectOne('host/api/v4/test?param1=pValue1', HttpMethod.DELETE) + const request = req.request; + expect(request.body).toBe('body'); + expect(request.headers.get('header1')).toBe('hValue1'); + expect(request.headers.get('PRIVATE-TOKEN')).toBe('token'); + req.flush(responseBody); + }); + + it('should notify observers', done => { + gitlab.accesses.subscribe(() => done()); + gitlab.call('test').subscribe(); + http.expectOne(() => true) + .flush(null); + http.verify(); }); - gitlab.call('test').subscribe(); - http.expectOne(() => true) - .flush(null, errorResponseOptions()); - http.verify(); + + it('should notify observers on error', done => { + gitlab.errors.subscribe(err => { + expect(err.status).toBe(500); + done(); + }); + gitlab.call('test').subscribe(); + http.expectOne(() => true) + .flush(null, errorResponseOptions()); + http.verify(); + }); + }); - }); + describe('making paginated requests', () => { - describe('making paginated requests', () => { + const fakeBody = (countOfElements: number) => [...Array(countOfElements).keys()]; - const fakeBody = (countOfElements: number) => [...Array(countOfElements).keys()]; + const paginationResponseOptions = (total: number, totalPages: number) => ({ + headers: { + 'X-Total': `${total}`, + 'X-Total-Pages': `${totalPages}` + } + }); - const paginationResponseOptions = (total: number, totalPages: number) => ({ - headers: { - 'X-Total': `${total}`, - 'X-Total-Pages': `${totalPages}` - } - }); + it('should not fetch until subscription', () => { + gitlab.callPaginated('test'); + http.verify(); + }); - it('should not fetch until subscription', () => { - gitlab.callPaginated('test'); - http.verify(); - }); + it('should not fetch second page if not wanted', done => { + gitlab.callPaginated('test', undefined, 20) + .pipe(take(20), toArray()) + .subscribe(datasets => { + expect(datasets).toHaveLength(20); + datasets.forEach(ds => expect(ds.total).toBe(50)); + done(); + }); + http.expectOne('host/api/v4/test?page=1&per_page=20', HttpMethod.GET) + .flush(fakeBody(20), paginationResponseOptions(50, 3)); + http.verify(); + }); - it('should not fetch second page if not wanted', done => { - gitlab.callPaginated('test', undefined, 20) - .pipe(take(20), toArray()) - .subscribe(datasets => { - expect(datasets).toHaveLength(20); - datasets.forEach(ds => expect(ds.total).toBe(50)); + it('should not fetch third page if not wanted', done => { + gitlab.callPaginated('test', undefined, 20) + .pipe(take(25), toArray()) + .subscribe(datasets => { + expect(datasets).toHaveLength(25); + datasets.forEach(ds => expect(ds.total).toBe(50)); + done(); + }); + http.expectOne('host/api/v4/test?page=1&per_page=20', HttpMethod.GET) + .flush(fakeBody(20), paginationResponseOptions(50, 3)); + http.expectOne('host/api/v4/test?page=2&per_page=20', HttpMethod.GET) + .flush(fakeBody(20), paginationResponseOptions(50, 3)); + http.verify(); + }); + + it('should fetch all pages', done => { + gitlab.callPaginated('test', undefined, 20) + .pipe(take(50), toArray()) + .subscribe(datasets => { + expect(datasets).toHaveLength(50); + datasets.forEach(ds => expect(ds.total).toBe(50)); + done(); + }); + http.expectOne('host/api/v4/test?page=1&per_page=20', HttpMethod.GET) + .flush(fakeBody(20), paginationResponseOptions(50, 3)); + http.expectOne('host/api/v4/test?page=2&per_page=20', HttpMethod.GET) + .flush(fakeBody(20), paginationResponseOptions(50, 3)); + http.expectOne('host/api/v4/test?page=3&per_page=20', HttpMethod.GET) + .flush(fakeBody(10), paginationResponseOptions(50, 3)); + http.verify(); + }); + + it('should notify observers', done => { + gitlab.accesses.subscribe(() => done()); + gitlab.callPaginated('test').subscribe(); + http.expectOne(() => true) + .flush(fakeBody(2), paginationResponseOptions(2, 1)); + http.verify(); + }); + + it('should notify observers on error', done => { + gitlab.errors.subscribe(err => { + expect(err.status).toBe(500); done(); }); - http.expectOne('host/api/v4/test?page=1&per_page=20', HttpMethod.GET) - .flush(fakeBody(20), paginationResponseOptions(50, 3)); - http.verify(); - }); + gitlab.callPaginated('test').subscribe(); + http.expectOne(() => true) + .flush(null, errorResponseOptions()); + http.verify(); + }); - it('should not fetch third page if not wanted', done => { - gitlab.callPaginated('test', undefined, 20) - .pipe(take(25), toArray()) - .subscribe(datasets => { - expect(datasets).toHaveLength(25); - datasets.forEach(ds => expect(ds.total).toBe(50)); + it('should notify all observers on multiple pages', done => { + merge( + gitlab.accesses.pipe(map(() => true)), // simplify + gitlab.errors.pipe(map(() => false)) // simplify + ).pipe(take(2), toArray()).subscribe(result => { + expect(result).toMatchObject([true, false]) done(); - }); - http.expectOne('host/api/v4/test?page=1&per_page=20', HttpMethod.GET) - .flush(fakeBody(20), paginationResponseOptions(50, 3)); - http.expectOne('host/api/v4/test?page=2&per_page=20', HttpMethod.GET) - .flush(fakeBody(20), paginationResponseOptions(50, 3)); - http.verify(); - }); + }) + gitlab.callPaginated('test') + .pipe(take(50), toArray()) + .subscribe(); + http.expectOne('host/api/v4/test?page=1&per_page=20', HttpMethod.GET) + .flush(fakeBody(20), paginationResponseOptions(50, 3)); + http.expectOne('host/api/v4/test?page=2&per_page=20', HttpMethod.GET) + .flush(null, errorResponseOptions()); + http.verify(); + }); - it('should fetch all pages', done => { - gitlab.callPaginated('test', undefined, 20) - .pipe(take(50), toArray()) - .subscribe(datasets => { - expect(datasets).toHaveLength(50); - datasets.forEach(ds => expect(ds.total).toBe(50)); + it('should set options correctly', done => { + const responseBody = {test: 'test'}; + gitlab.callPaginated('test', { + body: 'body', + params: { + param1: 'pValue1' + }, + headers: { + header1: 'hValue1' + } + }, 20).subscribe(response => { + expect(response).toMatchObject({payload: responseBody, index: 0, total: 1} as DataSet); done(); }); - http.expectOne('host/api/v4/test?page=1&per_page=20', HttpMethod.GET) - .flush(fakeBody(20), paginationResponseOptions(50, 3)); - http.expectOne('host/api/v4/test?page=2&per_page=20', HttpMethod.GET) - .flush(fakeBody(20), paginationResponseOptions(50, 3)); - http.expectOne('host/api/v4/test?page=3&per_page=20', HttpMethod.GET) - .flush(fakeBody(10), paginationResponseOptions(50, 3)); - http.verify(); - }); - - it('should notify observers', done => { - gitlab.accesses.subscribe(() => done()); - gitlab.callPaginated('test').subscribe(); - http.expectOne(() => true) - .flush(fakeBody(2), paginationResponseOptions(2, 1)); - http.verify(); - }); - - it('should notify observers on error', done => { - gitlab.errors.subscribe(err => { - expect(err.status).toBe(500); - done(); + const req = http.expectOne('host/api/v4/test?page=1&per_page=20¶m1=pValue1', HttpMethod.DELETE) + const request = req.request; + expect(request.body).toBe('body'); + expect(request.headers.get('header1')).toBe('hValue1'); + expect(request.headers.get('PRIVATE-TOKEN')).toBe('token'); + req.flush([responseBody], paginationResponseOptions(1, 1)); }); - gitlab.callPaginated('test').subscribe(); - http.expectOne(() => true) - .flush(null, errorResponseOptions()); - http.verify(); - }); - it('should notify all observers on multiple pages', done => { - merge( - gitlab.accesses.pipe(map(() => true)), // simplify - gitlab.errors.pipe(map(() => false)) // simplify - ).pipe(take(2), toArray()).subscribe(result => { - expect(result).toMatchObject([true, false]) - done(); - }) - gitlab.callPaginated('test') - .pipe(take(50), toArray()) - .subscribe(); - http.expectOne('host/api/v4/test?page=1&per_page=20', HttpMethod.GET) - .flush(fakeBody(20), paginationResponseOptions(50, 3)); - http.expectOne('host/api/v4/test?page=2&per_page=20', HttpMethod.GET) - .flush(null, errorResponseOptions()); - http.verify(); }); - it('should set options correctly', done => { - const responseBody = {test: 'test'}; - gitlab.callPaginated('test', { - body: 'body', - params: { - param1: 'pValue1' - }, - headers: { - header1: 'hValue1' - } - }, 20).subscribe(response => { - expect(response).toMatchObject({payload: responseBody, total: 1} as DataSet); - done(); - }); - const req = http.expectOne('host/api/v4/test?page=1&per_page=20¶m1=pValue1', HttpMethod.DELETE) - const request = req.request; - expect(request.body).toBe('body'); - expect(request.headers.get('header1')).toBe('hValue1'); - expect(request.headers.get('PRIVATE-TOKEN')).toBe('token'); - req.flush([responseBody], paginationResponseOptions(1, 1)); - }); - - }); + }) }); diff --git a/projects/gitlab-client/src/lib/services/shared/gitlab.service.ts b/projects/gitlab-client/src/lib/services/shared/gitlab.service.ts index e54d522..b2a7753 100644 --- a/projects/gitlab-client/src/lib/services/shared/gitlab.service.ts +++ b/projects/gitlab-client/src/lib/services/shared/gitlab.service.ts @@ -8,6 +8,19 @@ import {GITLAB_CONFIG_PROVIDER} from '../../gitlab-client.module'; const TOTAL_HEADER = 'X-Total'; const TOTAL_PAGES_HEADER = 'X-Total-Pages' +/** + * This error is thrown then there isn't any Gitlab connection configuration provider. + */ +export class NoGitlabConnectionProviderError extends Error { + constructor() { + super( + 'There isn\'t any Gitlab connection configuration provider available for dependency injection. ' + + 'Please configure a GITLAB_CONFIG_PROVIDER or import the GitlabClientModule by ' + + 'using GitlabClientModule.forRoot(myGitlabConfig).' + ); + } +} + @Injectable({ providedIn: null }) @@ -22,11 +35,8 @@ export class GitlabService { private readonly http: HttpClient, @Inject(GITLAB_CONFIG_PROVIDER) @Optional() private readonly config: () => GitlabConfig ) { - if (this.config == null) { - throw new Error( - "A GITLAB_CONFIG_PROVIDER is not available for dependency injection. " + - "Please configure a GITLAB_CONFIG_PROVIDER or import the GitlabClientModule by " + - "using GitlabClientModule.forRoot(myGitlabConfig)."); + if (!this.config) { + throw new NoGitlabConnectionProviderError(); } } @@ -77,6 +87,7 @@ export class GitlabService { // uses for recursive call private callPaginatedSincePage(resource: string, page: number, pageSize: number, options?: CallOptions): Observable> { + const indexOffset = (page - 1) * pageSize; // deferring is important to only lazy fetch data from the server if the pipe limits the count of data return defer(() => { let config = this.config(); @@ -103,7 +114,7 @@ export class GitlabService { const totalPages = totalPagesHeader ? Number(totalPagesHeader) : page; // create Observables const itemsOfThisRequest$ = from( - body.map(payload => ({payload, total} as DataSet)) + body.map((payload, index) => ({payload, index: index + indexOffset, total} as DataSet)) ); const itemsOfNextPage$ = page === totalPages ? EMPTY : @@ -149,7 +160,7 @@ export interface DataSet { /** * The 0-based index of the data set. */ - //index: number; + index: number; /** * The total count of available data sets. */