diff --git a/config/config.example.yml b/config/config.example.yml index ae733e0be5..4814c826f1 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -258,3 +258,10 @@ mediaViewer: info: enableEndUserAgreement: true enablePrivacyStatement: true +# Home Page +homePage: + recentSubmissions: + # The number of item showing in recent submission components + pageSize: 5 + # Sort record of recent submission + sortField: 'dc.date.accessioned' diff --git a/cypress/.gitignore b/cypress/.gitignore index 99bd2a6312..645beff45f 100644 --- a/cypress/.gitignore +++ b/cypress/.gitignore @@ -1,2 +1,3 @@ screenshots/ videos/ +downloads/ diff --git a/scripts/test-rest.ts b/scripts/test-rest.ts index b2a3ebd1af..51822cf939 100644 --- a/scripts/test-rest.ts +++ b/scripts/test-rest.ts @@ -22,7 +22,13 @@ console.log(`...Testing connection to REST API at ${restUrl}...\n`); if (appConfig.rest.ssl) { const req = https.request(restUrl, (res) => { console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); - res.on('data', (data) => { + // We will keep reading data until the 'end' event fires. + // This ensures we don't just read the first chunk. + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { checkJSONResponse(data); }); }); @@ -35,7 +41,13 @@ if (appConfig.rest.ssl) { } else { const req = http.request(restUrl, (res) => { console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); - res.on('data', (data) => { + // We will keep reading data until the 'end' event fires. + // This ensures we don't just read the first chunk. + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { checkJSONResponse(data); }); }); diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index 4957958658..a96c238f93 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -177,7 +177,7 @@ describe('EPersonFormComponent', () => { }); groupsDataService = jasmine.createSpyObj('groupsDataService', { - findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), getGroupRegistryRouterLink: '' }); diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts index 05fc3189d0..7d607647e3 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -265,7 +265,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { this.formGroup = this.formBuilderService.createFormGroup(this.formModel); this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { if (eperson != null) { - this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, { + this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, { currentPage: 1, elementsPerPage: this.config.pageSize }); @@ -297,7 +297,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { }), switchMap(([eperson, findListOptions]) => { if (eperson != null) { - return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); + return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); } return observableOf(undefined); }) @@ -554,7 +554,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ private updateGroups(options) { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { - this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options); + this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, options); })); } } diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts index 0b19b17100..8d0ddf0a85 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts @@ -53,7 +53,7 @@ describe('MembersListComponent', () => { activeGroup: activeGroup, epersonMembers: epersonMembers, subgroupMembers: subgroupMembers, - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); }, searchByScope(scope: string, query: string): Observable>> { diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts index d777c2ca52..d136bfdb23 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts @@ -130,7 +130,7 @@ export class MembersListComponent implements OnInit, OnDestroy { this.subs.set(SubKey.MembersDTO, this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( switchMap((currentPagination) => { - return this.ePersonDataService.findAllByHref(this.groupBeingEdited._links.epersons.href, { + return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, { currentPage: currentPagination.currentPage, elementsPerPage: currentPagination.pageSize } @@ -172,7 +172,7 @@ export class MembersListComponent implements OnInit, OnDestroy { return this.groupDataService.getActiveGroup().pipe(take(1), mergeMap((group: Group) => { if (group != null) { - return this.ePersonDataService.findAllByHref(group._links.epersons.href, { + return this.ePersonDataService.findListByHref(group._links.epersons.href, { currentPage: 1, elementsPerPage: 9999 }) diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts index bee5126e09..1ca6c88c5f 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts @@ -65,7 +65,7 @@ describe('SubgroupsListComponent', () => { getSubgroups(): Group { return this.activeGroup; }, - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { return this.subgroups$.pipe( map((currentGroups: Group[]) => { return createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), currentGroups)); diff --git a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts index 9930dd61ea..5f1700e07d 100644 --- a/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts +++ b/src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.ts @@ -115,7 +115,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { this.subs.set( SubKey.Members, this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( - switchMap((config) => this.groupDataService.findAllByHref(this.groupBeingEdited._links.subgroups.href, { + switchMap((config) => this.groupDataService.findListByHref(this.groupBeingEdited._links.subgroups.href, { currentPage: config.currentPage, elementsPerPage: config.pageSize }, @@ -139,7 +139,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy { if (activeGroup.uuid === possibleSubgroup.uuid) { return observableOf(false); } else { - return this.groupDataService.findAllByHref(activeGroup._links.subgroups.href, { + return this.groupDataService.findListByHref(activeGroup._links.subgroups.href, { currentPage: 1, elementsPerPage: 9999 }) diff --git a/src/app/access-control/group-registry/groups-registry.component.spec.ts b/src/app/access-control/group-registry/groups-registry.component.spec.ts index 99586ee5f0..239939e70d 100644 --- a/src/app/access-control/group-registry/groups-registry.component.spec.ts +++ b/src/app/access-control/group-registry/groups-registry.component.spec.ts @@ -69,7 +69,7 @@ describe('GroupRegistryComponent', () => { mockGroups = [GroupMock, GroupMock2]; mockEPeople = [EPersonMock, EPersonMock2]; ePersonDataServiceStub = { - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { switch (href) { case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons': return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ @@ -97,7 +97,7 @@ describe('GroupRegistryComponent', () => { }; groupsDataServiceStub = { allGroups: mockGroups, - findAllByHref(href: string): Observable>> { + findListByHref(href: string): Observable>> { switch (href) { case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/groups': return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ diff --git a/src/app/access-control/group-registry/groups-registry.component.ts b/src/app/access-control/group-registry/groups-registry.component.ts index 1770762a34..6ba501fcc4 100644 --- a/src/app/access-control/group-registry/groups-registry.component.ts +++ b/src/app/access-control/group-registry/groups-registry.component.ts @@ -213,7 +213,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { * @param group */ getMembers(group: Group): Observable>> { - return this.ePersonDataService.findAllByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); + return this.ePersonDataService.findListByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); } /** @@ -221,7 +221,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy { * @param group */ getSubgroups(group: Group): Observable>> { - return this.groupService.findAllByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); + return this.groupService.findListByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); } /** diff --git a/src/app/collection-page/collection-form/collection-form.component.ts b/src/app/collection-page/collection-form/collection-form.component.ts index bb84153835..23698de84e 100644 --- a/src/app/collection-page/collection-form/collection-form.component.ts +++ b/src/app/collection-page/collection-form/collection-form.component.ts @@ -16,7 +16,7 @@ import { CommunityDataService } from '../../core/data/community-data.service'; import { AuthService } from '../../core/auth/auth.service'; import { RequestService } from '../../core/data/request.service'; import { ObjectCacheService } from '../../core/cache/object-cache.service'; -import { EntityTypeService } from '../../core/data/entity-type.service'; +import { EntityTypeDataService } from '../../core/data/entity-type-data.service'; import { ItemType } from '../../core/shared/item-relationships/item-type.model'; import { MetadataValue } from '../../core/shared/metadata.models'; import { getFirstSucceededRemoteListPayload } from '../../core/shared/operators'; @@ -61,7 +61,7 @@ export class CollectionFormComponent extends ComColFormComponent imp protected dsoService: CommunityDataService, protected requestService: RequestService, protected objectCache: ObjectCacheService, - protected entityTypeService: EntityTypeService) { + protected entityTypeService: EntityTypeDataService) { super(formService, translate, notificationsService, authService, requestService, objectCache); } diff --git a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts index 142604c9b2..8304b01daa 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.spec.ts @@ -94,7 +94,7 @@ describe('CollectionItemMapperComponent', () => { const emptyList = createSuccessfulRemoteDataObject(createPaginatedList([])); const itemDataServiceStub = { mapToCollection: () => createSuccessfulRemoteDataObject$({}), - findAllByHref: () => observableOf(emptyList) + findListByHref: () => observableOf(emptyList), }; const activatedRouteStub = { parent: { @@ -152,7 +152,7 @@ describe('CollectionItemMapperComponent', () => { }); const groupDataService = jasmine.createSpyObj('groupsDataService', { - findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), getGroupRegistryRouterLink: '', getUUIDFromString: '', }); diff --git a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts index 3172616efc..7911be8e64 100644 --- a/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts +++ b/src/app/collection-page/collection-item-mapper/collection-item-mapper.component.ts @@ -143,7 +143,7 @@ export class CollectionItemMapperComponent implements OnInit { if (shouldUpdate === true) { this.shouldUpdate$.next(false); } - return this.itemDataService.findAllByHref(collectionRD.payload._links.mappedItems.href, Object.assign(options, { + return this.itemDataService.findListByHref(collectionRD.payload._links.mappedItems.href, Object.assign(options, { sort: this.defaultSortOptions }),!shouldUpdate, false, followLink('owningCollection')).pipe( getAllSucceededRemoteData() diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts index 2b473bf037..79e7a465e1 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.spec.ts @@ -13,7 +13,7 @@ import { Item } from '../../../core/shared/item.model'; import { ItemTemplateDataService } from '../../../core/data/item-template-data.service'; import { Collection } from '../../../core/shared/collection.model'; import { RequestService } from '../../../core/data/request.service'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; describe('CollectionMetadataComponent', () => { @@ -39,8 +39,8 @@ describe('CollectionMetadataComponent', () => { const itemTemplateServiceStub = jasmine.createSpyObj('itemTemplateService', { findByCollectionID: createSuccessfulRemoteDataObject$(template), - create: createSuccessfulRemoteDataObject$(template), - deleteByCollectionID: observableOf(true), + createByCollectionID: createSuccessfulRemoteDataObject$(template), + delete: observableOf(true), getCollectionEndpoint: observableOf(collectionTemplateHref), }); @@ -91,12 +91,12 @@ describe('CollectionMetadataComponent', () => { describe('deleteItemTemplate', () => { beforeEach(() => { - (itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(true)); + (itemTemplateService.delete as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({})); comp.deleteItemTemplate(); }); - it('should call ItemTemplateService.deleteByCollectionID', () => { - expect(itemTemplateService.deleteByCollectionID).toHaveBeenCalledWith(template, 'collection-id'); + it('should call ItemTemplateService.delete', () => { + expect(itemTemplateService.delete).toHaveBeenCalledWith(template.uuid); }); describe('when delete returns a success', () => { @@ -107,7 +107,7 @@ describe('CollectionMetadataComponent', () => { describe('when delete returns a failure', () => { beforeEach(() => { - (itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(false)); + (itemTemplateService.delete as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$()); comp.deleteItemTemplate(); }); diff --git a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts index d4396fce17..8e534a0829 100644 --- a/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts +++ b/src/app/collection-page/edit-collection-page/collection-metadata/collection-metadata.component.ts @@ -7,12 +7,14 @@ import { ItemTemplateDataService } from '../../../core/data/item-template-data.s import { combineLatest as combineLatestObservable, Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model'; -import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; -import { switchMap } from 'rxjs/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { map, switchMap } from 'rxjs/operators'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { RequestService } from '../../../core/data/request.service'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; +import { NoContent } from '../../../core/shared/NoContent.model'; +import { hasValue } from '../../../shared/empty.util'; /** * Component for editing a collection's metadata @@ -65,7 +67,7 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent this.itemTemplateService.create(new Item(), collection.uuid).pipe( + switchMap((collection: Collection) => this.itemTemplateService.createByCollectionID(new Item(), collection.uuid).pipe( getFirstSucceededRemoteDataPayload(), )), ); @@ -83,18 +85,15 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent this.itemTemplateService.findByCollectionID(collection.uuid).pipe( - getFirstSucceededRemoteDataPayload(), - )), - ); - combineLatestObservable(collection$, template$).pipe( - switchMap(([collection, template]) => { - return this.itemTemplateService.deleteByCollectionID(template, collection.uuid); - }) + switchMap((collection: Collection) => this.itemTemplateService.findByCollectionID(collection.uuid)), + getFirstSucceededRemoteDataPayload(), + switchMap((template) => { + return this.itemTemplateService.delete(template.uuid); + }), + getFirstCompletedRemoteData(), + map((response: RemoteData) => hasValue(response) && response.hasSucceeded), ).subscribe((success: boolean) => { if (success) { this.notificationsService.success(null, this.translate.get('collection.edit.template.notifications.delete.success')); diff --git a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts index c0ce5369ff..60b1c78f8c 100644 --- a/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts +++ b/src/app/community-page/sub-collection-list/community-page-sub-collection-list.component.spec.ts @@ -135,7 +135,7 @@ describe('CommunityPageSubCollectionList Component', () => { }); const groupDataService = jasmine.createSpyObj('groupsDataService', { - findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), getGroupRegistryRouterLink: '', getUUIDFromString: '', }); diff --git a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts index 3392ada994..c3db1bd17d 100644 --- a/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts +++ b/src/app/community-page/sub-community-list/community-page-sub-community-list.component.spec.ts @@ -131,7 +131,7 @@ describe('CommunityPageSubCommunityListComponent Component', () => { }); const groupDataService = jasmine.createSpyObj('groupsDataService', { - findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), + findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), getGroupRegistryRouterLink: '', getUUIDFromString: '', }); diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts index 650bbd3301..8be4e5e099 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts @@ -2,23 +2,26 @@ import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; -import { DataService } from '../data/data.service'; -import { getRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators'; import { map } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { DSpaceObject } from '../shared/dspace-object.model'; import { ChildHALResource } from '../shared/child-hal-resource.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { hasValue } from '../../shared/empty.util'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; /** * The class that resolves the BreadcrumbConfig object for a DSpaceObject */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export abstract class DSOBreadcrumbResolver implements Resolve> { - constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: DataService) { + protected constructor( + protected breadcrumbService: DSOBreadcrumbsService, + protected dataService: IdentifiableDataService, + ) { } /** @@ -36,7 +39,7 @@ export abstract class DSOBreadcrumbResolver { let service: BrowseDefinitionDataService; - const dataServiceImplSpy = jasmine.createSpyObj('dataService', { + const findAllDataSpy = jasmine.createSpyObj('findAllData', { findAll: EMPTY, - findByHref: EMPTY, - findAllByHref: EMPTY, - findById: EMPTY, }); - const hrefAll = 'https://rest.api/server/api/discover/browses'; - const hrefSingle = 'https://rest.api/server/api/discover/browses/author'; - const id = 'author'; const options = new FindListOptions(); const linksToFollow = [ followLink('entries'), @@ -21,35 +15,14 @@ describe(`BrowseDefinitionDataService`, () => { ]; beforeEach(() => { - service = new BrowseDefinitionDataService(null, null, null, null, null, null, null, null); - (service as any).dataService = dataServiceImplSpy; + service = new BrowseDefinitionDataService(null, null, null, null); + (service as any).findAllData = findAllDataSpy; }); describe(`findAll`, () => { - it(`should call findAll on DataServiceImpl`, () => { + it(`should call findAll on findAllData`, () => { service.findAll(options, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow); - }); - }); - - describe(`findByHref`, () => { - it(`should call findByHref on DataServiceImpl`, () => { - service.findByHref(hrefSingle, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findByHref).toHaveBeenCalledWith(hrefSingle, true, false, ...linksToFollow); - }); - }); - - describe(`findAllByHref`, () => { - it(`should call findAllByHref on DataServiceImpl`, () => { - service.findAllByHref(hrefAll, options, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findAllByHref).toHaveBeenCalledWith(hrefAll, options, true, false, ...linksToFollow); - }); - }); - - describe(`findById`, () => { - it(`should call findById on DataServiceImpl`, () => { - service.findAllByHref(id, options, true, false, ...linksToFollow); - expect(dataServiceImplSpy.findAllByHref).toHaveBeenCalledWith(id, options, true, false, ...linksToFollow); + expect(findAllDataSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow); }); }); }); diff --git a/src/app/core/browse/browse-definition-data.service.ts b/src/app/core/browse/browse-definition-data.service.ts index 6a27bb3f7a..32c3b44e14 100644 --- a/src/app/core/browse/browse-definition-data.service.ts +++ b/src/app/core/browse/browse-definition-data.service.ts @@ -1,125 +1,56 @@ -/* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; -import { dataService } from '../cache/builders/build-decorators'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; -import { DataService } from '../data/data.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Observable } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from '../data/find-list-options.model'; +import { IdentifiableDataService } from '../data/base/identifiable-data.service'; +import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data'; +import { dataService } from '../data/base/data-service.decorator'; - -class DataServiceImpl extends DataService { - protected linkPath = 'browses'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); - } -} - +/** + * Data service responsible for retrieving browse definitions from the REST server + */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) @dataService(BROWSE_DEFINITION) -export class BrowseDefinitionDataService { - /** - * A private DataService instance to delegate specific methods to. - */ - private dataService: DataServiceImpl; +export class BrowseDefinitionDataService extends IdentifiableDataService implements FindAllData { + private findAllData: FindAllDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + ) { + super('browses', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded * info should be added to the objects * - * @param options Find list options object + * @param options Find list options object * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's * no valid cached version. Defaults to true * @param reRequestOnStale Whether or not the request should automatically be re- * requested after the response becomes stale * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved - * @return {Observable>>} + * @return {Observable>>} * Return an observable that emits object list */ findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.dataService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns an observable of {@link RemoteData} of an {@link BrowseDefinition}, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the {@link BrowseDefinition} - * @param href The url of {@link BrowseDefinition} we want to retrieve - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns a list of observables of {@link RemoteData} of {@link BrowseDefinition}s, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the {@link BrowseDefinition} - * @param href The url of object we want to retrieve - * @param findListOptions Find list options object - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of - * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object - * @param id ID of object we want to retrieve - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } } diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index db802dcbdd..46ac8c44f4 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -139,13 +139,13 @@ describe('BrowseService', () => { }); describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { - it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { + it('should call hrefOnlyDataService.findListByHref with the expected href', () => { const expected = browseDefinitions[1]._links.entries.href; scheduler.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); - expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { + expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { a: expected })); }); @@ -153,20 +153,20 @@ describe('BrowseService', () => { }); describe('when findList is called with a valid browse definition id', () => { - it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { + it('should call hrefOnlyDataService.findListByHref with the expected href', () => { const expected = browseDefinitions[1]._links.items.href + '?filterValue=' + encodeURIComponent(mockAuthorName); scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, undefined, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); - expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { + expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { a: expected })); }); }); describe('when getBrowseItemsFor is called with a valid filter value and authority key', () => { - it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { + it('should call hrefOnlyDataService.findListByHref with the expected href', () => { const expected = browseDefinitions[1]._links.items.href + '?filterValue=' + encodeURIComponent(mockAuthorName) + '&filterAuthority=' + encodeURIComponent(mockAuthorityKey); @@ -174,7 +174,7 @@ describe('BrowseService', () => { scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, mockAuthorityKey, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); - expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { + expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { a: expected })); }); @@ -267,11 +267,11 @@ describe('BrowseService', () => { describe('when getFirstItemFor is called with a valid browse definition id', () => { const expectedURL = browseDefinitions[1]._links.items.href + '?page=0&size=1'; - it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { + it('should call hrefOnlyDataService.findListByHref with the expected href', () => { scheduler.schedule(() => service.getFirstItemFor(browseDefinitions[1].id).subscribe()); scheduler.flush(); - expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { + expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', { a: expectedURL })); }); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 05e625d6c1..8fd11ad048 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -96,7 +96,7 @@ export class BrowseService { return href; }) ); - return this.hrefOnlyDataService.findAllByHref(href$); + return this.hrefOnlyDataService.findListByHref(href$); } /** @@ -141,7 +141,7 @@ export class BrowseService { return href; }), ); - return this.hrefOnlyDataService.findAllByHref(href$); + return this.hrefOnlyDataService.findListByHref(href$); } /** @@ -172,7 +172,7 @@ export class BrowseService { }) ); - return this.hrefOnlyDataService.findAllByHref(href$).pipe( + return this.hrefOnlyDataService.findListByHref(href$).pipe( getFirstSucceededRemoteData(), getFirstOccurrence() ); @@ -184,7 +184,7 @@ export class BrowseService { * @param items */ getPrevBrowseItems(items: RemoteData>): Observable>> { - return this.hrefOnlyDataService.findAllByHref(items.payload.prev); + return this.hrefOnlyDataService.findListByHref(items.payload.prev); } /** @@ -192,7 +192,7 @@ export class BrowseService { * @param items */ getNextBrowseItems(items: RemoteData>): Observable>> { - return this.hrefOnlyDataService.findAllByHref(items.payload.next); + return this.hrefOnlyDataService.findListByHref(items.payload.next); } /** @@ -200,7 +200,7 @@ export class BrowseService { * @param entries */ getPrevBrowseEntries(entries: RemoteData>): Observable>> { - return this.hrefOnlyDataService.findAllByHref(entries.payload.prev); + return this.hrefOnlyDataService.findListByHref(entries.payload.prev); } /** @@ -208,7 +208,7 @@ export class BrowseService { * @param entries */ getNextBrowseEntries(entries: RemoteData>): Observable>> { - return this.hrefOnlyDataService.findAllByHref(entries.payload.next); + return this.hrefOnlyDataService.findListByHref(entries.payload.next); } /** diff --git a/src/app/core/cache/builders/build-decorators.spec.ts b/src/app/core/cache/builders/build-decorators.spec.ts index 064a2b3f83..150a07f006 100644 --- a/src/app/core/cache/builders/build-decorators.spec.ts +++ b/src/app/core/cache/builders/build-decorators.spec.ts @@ -1,14 +1,7 @@ -/* eslint-disable max-classes-per-file */ import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; -import { dataService, getDataServiceFor, getLinkDefinition, link, } from './build-decorators'; - -class TestService { -} - -class AnotherTestService { -} +import { getLinkDefinition, link } from './build-decorators'; class TestHALResource implements HALResource { _links: { @@ -25,31 +18,6 @@ describe('build decorators', () => { beforeEach(() => { testType = new ResourceType('testType-' + new Date().getTime()); }); - describe('@dataService/getDataServiceFor', () => { - - it('should register a resourcetype for a dataservice', () => { - dataService(testType)(TestService); - expect(getDataServiceFor(testType)).toBe(TestService); - }); - - describe(`when the resource type isn't specified`, () => { - it(`should throw an error`, () => { - expect(() => { - dataService(undefined)(TestService); - }).toThrow(); - }); - }); - - describe(`when there already is a registered dataservice for a resourcetype`, () => { - it(`should throw an error`, () => { - dataService(testType)(TestService); - expect(() => { - dataService(testType)(AnotherTestService); - }).toThrow(); - }); - }); - - }); describe(`@link/getLinkDefinitions`, () => { it(`should register a link`, () => { diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index 193eeb57e8..9e5ebaed85 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -3,20 +3,13 @@ import { hasNoValue, hasValue } from '../../../shared/empty.util'; import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; -import { - getResourceTypeValueFor -} from '../object-cache.reducer'; +import { getResourceTypeValueFor } from '../object-cache.reducer'; import { InjectionToken } from '@angular/core'; -import { CacheableObject } from '../cacheable-object.model'; import { TypedObject } from '../typed-object.model'; -export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>('getDataServiceFor', { - providedIn: 'root', - factory: () => getDataServiceFor -}); export const LINK_DEFINITION_FACTORY = new InjectionToken<(source: GenericConstructor, linkName: keyof T['_links']) => LinkDefinition>('getLinkDefinition', { providedIn: 'root', - factory: () => getLinkDefinition + factory: () => getLinkDefinition, }); export const LINK_DEFINITION_MAP_FACTORY = new InjectionToken<(source: GenericConstructor) => Map>>('getLinkDefinitions', { providedIn: 'root', @@ -27,7 +20,6 @@ const resolvedLinkKey = Symbol('resolvedLink'); const resolvedLinkMap = new Map(); const typeMap = new Map(); -const dataServiceMap = new Map(); const linkMap = new Map(); /** @@ -46,39 +38,6 @@ export function getClassForType(type: string | ResourceType) { return typeMap.get(getResourceTypeValueFor(type)); } -/** - * A class decorator to indicate that this class is a dataservice - * for a given resource type. - * - * "dataservice" in this context means that it has findByHref and - * findAllByHref methods. - * - * @param resourceType the resource type the class is a dataservice for - */ -export function dataService(resourceType: ResourceType): any { - return (target: any) => { - if (hasNoValue(resourceType)) { - throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`); - } - const existingDataservice = dataServiceMap.get(resourceType.value); - - if (hasValue(existingDataservice)) { - throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`); - } - - dataServiceMap.set(resourceType.value, target); - }; -} - -/** - * Return the dataservice matching the given resource type - * - * @param resourceType the resource type you want the matching dataservice for - */ -export function getDataServiceFor(resourceType: ResourceType) { - return dataServiceMap.get(resourceType.value); -} - /** * A class to represent the data that can be set by the @link decorator */ diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts index 5e71a45053..0ddfe05870 100644 --- a/src/app/core/cache/builders/link.service.spec.ts +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -6,9 +6,10 @@ import { HALLink } from '../../shared/hal-link.model'; import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; import { LinkService } from './link.service'; -import { DATA_SERVICE_FACTORY, LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators'; +import { LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators'; import { isEmpty } from 'rxjs/operators'; import { FindListOptions } from '../../data/find-list-options.model'; +import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; const TEST_MODEL = new ResourceType('testmodel'); let result: any; @@ -32,8 +33,8 @@ class TestModel implements HALResource { @Injectable() class TestDataService { - findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { - return 'findAllByHref'; + findListByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { + return 'findListByHref'; } findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { @@ -64,7 +65,7 @@ describe('LinkService', () => { } }); testDataService = new TestDataService(); - spyOn(testDataService, 'findAllByHref').and.callThrough(); + spyOn(testDataService, 'findListByHref').and.callThrough(); spyOn(testDataService, 'findByHref').and.callThrough(); TestBed.configureTestingModule({ providers: [LinkService, { @@ -118,8 +119,8 @@ describe('LinkService', () => { }); service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor'))); }); - it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => { - expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); + it('should call dataservice.findListByHref with the correct href, findListOptions, and nested links', () => { + expect(testDataService.findListByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); }); }); describe('either way', () => { diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index af616332c0..b7af54abf7 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -3,28 +3,30 @@ import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; +import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator'; import { - DATA_SERVICE_FACTORY, LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY, - LinkDefinition + LinkDefinition, } from './build-decorators'; import { RemoteData } from '../../data/remote-data'; import { EMPTY, Observable } from 'rxjs'; import { ResourceType } from '../../shared/resource-type'; +import { HALDataService } from '../../data/base/hal-data-service.interface'; +import { PaginatedList } from '../../data/paginated-list.model'; /** * A Service to handle the resolving and removing * of resolved {@link HALLink}s on HALResources */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class LinkService { constructor( protected parentInjector: Injector, - @Inject(DATA_SERVICE_FACTORY) private getDataServiceFor: (resourceType: ResourceType) => GenericConstructor, + @Inject(DATA_SERVICE_FACTORY) private getDataServiceFor: (resourceType: ResourceType) => GenericConstructor>, @Inject(LINK_DEFINITION_FACTORY) private getLinkDefinition: (source: GenericConstructor, linkName: keyof T['_links']) => LinkDefinition, @Inject(LINK_DEFINITION_MAP_FACTORY) private getLinkDefinitions: (source: GenericConstructor) => Map>, ) { @@ -51,7 +53,7 @@ export class LinkService { * @param model the {@link HALResource} to resolve the link for * @param linkToFollow the {@link FollowLinkConfig} to resolve */ - public resolveLinkWithoutAttaching(model, linkToFollow: FollowLinkConfig): Observable> { + public resolveLinkWithoutAttaching(model, linkToFollow: FollowLinkConfig): Observable>> { const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name); if (hasValue(matchingLinkDef)) { @@ -61,9 +63,9 @@ export class LinkService { throw new Error(`The @link() for ${linkToFollow.name} on ${model.constructor.name} models uses the resource type ${matchingLinkDef.resourceType.value.toUpperCase()}, but there is no service with an @dataService(${matchingLinkDef.resourceType.value.toUpperCase()}) annotation in order to retrieve it`); } - const service = Injector.create({ + const service: HALDataService = Injector.create({ providers: [], - parent: this.parentInjector + parent: this.parentInjector, }).get(provider); const link = model._links[matchingLinkDef.linkName]; @@ -72,7 +74,7 @@ export class LinkService { try { if (matchingLinkDef.isList) { - return service.findAllByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); + return service.findListByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); } else { return service.findByHref(href, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); } diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config-data.service.spec.ts similarity index 84% rename from src/app/core/config/config.service.spec.ts rename to src/app/core/config/config-data.service.spec.ts index be354ddc6f..38340d1ad5 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config-data.service.spec.ts @@ -1,7 +1,7 @@ import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { ConfigService } from './config.service'; +import { ConfigDataService } from './config-data.service'; import { RequestService } from '../data/request.service'; import { GetRequest } from '../data/request.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -9,23 +9,26 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { FindListOptions } from '../data/find-list-options.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; const LINK_NAME = 'test'; const BROWSE = 'search/findByCollection'; -class TestService extends ConfigService { +class TestService extends ConfigDataService { protected linkPath = LINK_NAME; protected browseEndpoint = BROWSE; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected halService: HALEndpointService) { - super(requestService, rdbService, null, null, halService, null, null, null, BROWSE); + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(BROWSE, requestService, rdbService, objectCache, halService); } } -describe('ConfigService', () => { +describe('ConfigDataService', () => { let scheduler: TestScheduler; let service: TestService; let requestService: RequestService; @@ -45,7 +48,8 @@ describe('ConfigService', () => { return new TestService( requestService, rdbService, - halService + null, + halService, ); } diff --git a/src/app/core/config/config-data.service.ts b/src/app/core/config/config-data.service.ts new file mode 100644 index 0000000000..9ef2f11ad1 --- /dev/null +++ b/src/app/core/config/config-data.service.ts @@ -0,0 +1,40 @@ +import { Observable } from 'rxjs'; +import { ConfigObject } from './models/config.model'; +import { RemoteData } from '../data/remote-data'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { map } from 'rxjs/operators'; +import { BaseDataService } from '../data/base/base-data.service'; + +/** + * Abstract data service to retrieve configuration objects from the REST server. + * Common logic for configuration objects should be implemented here. + */ +export abstract class ConfigDataService extends BaseDataService { + /** + * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * Throws an error if a configuration object cannot be retrieved. + * + * @param href The url of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( + getFirstCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.hasFailed) { + throw new Error(`Couldn't retrieve the config`); + } else { + return rd; + } + }), + ); + } +} diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts deleted file mode 100644 index 3bc87c8de0..0000000000 --- a/src/app/core/config/config.service.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint-disable max-classes-per-file */ -import { Observable } from 'rxjs'; -import { RequestService } from '../data/request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ConfigObject } from './models/config.model'; -import { RemoteData } from '../data/remote-data'; -import { DataService } from '../data/data.service'; -import { Store } from '@ngrx/store'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { map } from 'rxjs/operators'; -import { CoreState } from '../core-state.model'; - -class DataServiceImpl extends DataService { - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer, - protected linkPath: string - ) { - super(); - } -} - -export abstract class ConfigService { - /** - * A private DataService instance to delegate specific methods to. - */ - private dataService: DataServiceImpl; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer, - protected linkPath: string - ) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, this.linkPath); - } - - public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe( - getFirstCompletedRemoteData(), - map((rd: RemoteData) => { - if (rd.hasFailed) { - throw new Error(`Couldn't retrieve the config`); - } else { - return rd; - } - }) - ); - } -} diff --git a/src/app/core/config/submission-accesses-config.service.ts b/src/app/core/config/submission-accesses-config-data.service.ts similarity index 55% rename from src/app/core/config/submission-accesses-config.service.ts rename to src/app/core/config/submission-accesses-config-data.service.ts index 7c2d2046d9..d2da0fce42 100644 --- a/src/app/core/config/submission-accesses-config.service.ts +++ b/src/app/core/config/submission-accesses-config-data.service.ts @@ -1,41 +1,46 @@ import { Injectable } from '@angular/core'; -import { ConfigService } from './config.service'; +import { ConfigDataService } from './config-data.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { dataService } from '../cache/builders/build-decorators'; import { SUBMISSION_ACCESSES_TYPE } from './models/config-type'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { ConfigObject } from './models/config.model'; import { SubmissionAccessesModel } from './models/config-submission-accesses.model'; import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { CoreState } from '../core-state.model'; +import { dataService } from '../data/base/data-service.decorator'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. */ @Injectable() @dataService(SUBMISSION_ACCESSES_TYPE) -export class SubmissionAccessesConfigService extends ConfigService { +export class SubmissionAccessesConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer ) { - super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionaccessoptions'); + super('submissionaccessoptions', requestService, rdbService, objectCache, halService); } + /** + * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * Throws an error if a configuration object cannot be retrieved. + * + * @param href The url of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable> { return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as FollowLinkConfig[]) as Observable>; } diff --git a/src/app/core/config/submission-forms-config.service.ts b/src/app/core/config/submission-forms-config-data.service.ts similarity index 51% rename from src/app/core/config/submission-forms-config.service.ts rename to src/app/core/config/submission-forms-config-data.service.ts index 1db5c2fa01..f4c0690685 100644 --- a/src/app/core/config/submission-forms-config.service.ts +++ b/src/app/core/config/submission-forms-config-data.service.ts @@ -1,39 +1,46 @@ import { Injectable } from '@angular/core'; - -import { ConfigService } from './config.service'; +import { ConfigDataService } from './config-data.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { ConfigObject } from './models/config.model'; -import { dataService } from '../cache/builders/build-decorators'; import { SUBMISSION_FORMS_TYPE } from './models/config-type'; import { SubmissionFormsModel } from './models/config-submission-forms.model'; import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { CoreState } from '../core-state.model'; +import { dataService } from '../data/base/data-service.decorator'; +/** + * Data service to retrieve submission form configuration objects from the REST server. + */ @Injectable() @dataService(SUBMISSION_FORMS_TYPE) -export class SubmissionFormsConfigService extends ConfigService { +export class SubmissionFormsConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer ) { - super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionforms'); + super('submissionforms', requestService, rdbService, objectCache, halService); } + /** + * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * Throws an error if a configuration object cannot be retrieved. + * + * @param href The url of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as FollowLinkConfig[]) as Observable>; } diff --git a/src/app/core/config/submission-uploads-config.service.ts b/src/app/core/config/submission-uploads-config-data.service.ts similarity index 63% rename from src/app/core/config/submission-uploads-config.service.ts rename to src/app/core/config/submission-uploads-config-data.service.ts index 8ad17749bd..8f838352a9 100644 --- a/src/app/core/config/submission-uploads-config.service.ts +++ b/src/app/core/config/submission-uploads-config-data.service.ts @@ -1,39 +1,30 @@ import { Injectable } from '@angular/core'; -import { ConfigService } from './config.service'; +import { ConfigDataService } from './config-data.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { dataService } from '../cache/builders/build-decorators'; import { SUBMISSION_UPLOADS_TYPE } from './models/config-type'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; import { ConfigObject } from './models/config.model'; import { SubmissionUploadsModel } from './models/config-submission-uploads.model'; import { RemoteData } from '../data/remote-data'; import { Observable } from 'rxjs'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { CoreState } from '../core-state.model'; +import { dataService } from '../data/base/data-service.decorator'; /** * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. */ @Injectable() @dataService(SUBMISSION_UPLOADS_TYPE) -export class SubmissionUploadsConfigService extends ConfigService { +export class SubmissionUploadsConfigDataService extends ConfigDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer ) { - super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionuploads'); + super('submissionuploads', requestService, rdbService, objectCache, halService); } findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable> { diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index b16930e819..3ba0b39a0e 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -36,7 +36,7 @@ import { SubmissionDefinitionsModel } from './config/models/config-submission-de import { SubmissionFormsModel } from './config/models/config-submission-forms.model'; import { SubmissionSectionModel } from './config/models/config-submission-section.model'; import { SubmissionUploadsModel } from './config/models/config-submission-uploads.model'; -import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; +import { SubmissionFormsConfigDataService } from './config/submission-forms-config-data.service'; import { coreEffects } from './core.effects'; import { coreReducers } from './core.reducers'; import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; @@ -49,8 +49,8 @@ import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; import { DSOResponseParsingService } from './data/dso-response-parsing.service'; import { DSpaceObjectDataService } from './data/dspace-object-data.service'; import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; -import { EntityTypeService } from './data/entity-type.service'; -import { ExternalSourceService } from './data/external-source.service'; +import { EntityTypeDataService } from './data/entity-type-data.service'; +import { ExternalSourceDataService } from './data/external-source-data.service'; import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service'; import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service'; import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; @@ -58,9 +58,9 @@ import { ItemDataService } from './data/item-data.service'; import { LookupRelationService } from './data/lookup-relation.service'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; -import { RelationshipTypeService } from './data/relationship-type.service'; -import { RelationshipService } from './data/relationship.service'; -import { ResourcePolicyService } from './resource-policy/resource-policy.service'; +import { RelationshipTypeDataService } from './data/relationship-type-data.service'; +import { RelationshipDataService } from './data/relationship-data.service'; +import { ResourcePolicyDataService } from './resource-policy/resource-policy-data.service'; import { SearchResponseParsingService } from './data/search-response-parsing.service'; import { SiteDataService } from './data/site-data.service'; import { DspaceRestService } from './dspace-rest/dspace-rest.service'; @@ -170,14 +170,16 @@ import { SubmissionAccessesModel } from './config/models/config-submission-acces import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model'; import { AccessStatusDataService } from './data/access-status-data.service'; import { LinkHeadService } from './services/link-head.service'; -import { ResearcherProfileService } from './profile/researcher-profile.service'; +import { ResearcherProfileDataService } from './profile/researcher-profile-data.service'; import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; import { ResearcherProfile } from './profile/model/researcher-profile.model'; -import { OrcidQueueService } from './orcid/orcid-queue.service'; +import { OrcidQueueDataService } from './orcid/orcid-queue-data.service'; import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; import { OrcidQueue } from './orcid/model/orcid-queue.model'; import { OrcidHistory } from './orcid/model/orcid-history.model'; import { OrcidAuthService } from './orcid/orcid-auth.service'; +import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service'; +import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -223,7 +225,7 @@ const PROVIDERS = [ MetadataService, ObjectCacheService, PaginationComponentOptions, - ResourcePolicyService, + ResourcePolicyDataService, RegistryService, BitstreamFormatDataService, RemoteDataBuildService, @@ -238,7 +240,7 @@ const PROVIDERS = [ AccessStatusDataService, SubmissionCcLicenseDataService, SubmissionCcLicenseUrlDataService, - SubmissionFormsConfigService, + SubmissionFormsConfigDataService, SubmissionRestService, SubmissionResponseParsingService, SubmissionJsonPatchOperationsService, @@ -259,7 +261,7 @@ const PROVIDERS = [ MenuService, ObjectUpdatesService, SearchService, - RelationshipService, + RelationshipDataService, MyDSpaceGuard, RoleService, TaskResponseParsingService, @@ -267,7 +269,7 @@ const PROVIDERS = [ PoolTaskDataService, BitstreamDataService, DsDynamicTypeBindRelationService, - EntityTypeService, + EntityTypeDataService, ContentSourceResponseParsingService, ItemTemplateDataService, SearchService, @@ -276,8 +278,8 @@ const PROVIDERS = [ SearchFilterService, SearchConfigurationService, SelectableListService, - RelationshipTypeService, - ExternalSourceService, + RelationshipTypeDataService, + ExternalSourceDataService, LookupRelationService, VersionDataService, VersionHistoryDataService, @@ -300,14 +302,16 @@ const PROVIDERS = [ FilteredDiscoveryPageResponseParsingService, { provide: NativeWindowService, useFactory: NativeWindowFactory }, VocabularyService, + VocabularyDataService, + VocabularyEntryDetailsDataService, VocabularyTreeviewService, SequenceService, GroupDataService, FeedbackDataService, - ResearcherProfileService, + ResearcherProfileDataService, ProfileClaimService, OrcidAuthService, - OrcidQueueService, + OrcidQueueDataService, OrcidHistoryDataService, ]; diff --git a/src/app/core/data/access-status-data.service.spec.ts b/src/app/core/data/access-status-data.service.spec.ts index d81b9384f3..18b8cb5d65 100644 --- a/src/app/core/data/access-status-data.service.spec.ts +++ b/src/app/core/data/access-status-data.service.spec.ts @@ -76,6 +76,6 @@ describe('AccessStatusDataService', () => { }); halService = new HALEndpointServiceStub(url); notificationsService = new NotificationsServiceStub(); - service = new AccessStatusDataService(null, halService, null, notificationsService, objectCache, rdbService, requestService, null); + service = new AccessStatusDataService(requestService, rdbService, objectCache, halService); } }); diff --git a/src/app/core/data/access-status-data.service.ts b/src/app/core/data/access-status-data.service.ts index 09843fac9b..2f641456fa 100644 --- a/src/app/core/data/access-status-data.service.ts +++ b/src/app/core/data/access-status-data.service.ts @@ -1,43 +1,35 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; import { RequestService } from './request.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { CoreState } from '../core-state.model'; import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type'; import { Observable } from 'rxjs'; import { RemoteData } from './remote-data'; import { Item } from '../shared/item.model'; +import { BaseDataService } from './base/base-data.service'; +import { dataService } from './base/data-service.decorator'; +/** + * Data service responsible for retrieving the access status of Items + */ @Injectable() @dataService(ACCESS_STATUS) -export class AccessStatusDataService extends DataService { - - protected linkPath = 'accessStatus'; +export class AccessStatusDataService extends BaseDataService { constructor( - protected comparator: DefaultChangeAnalyzer, - protected halService: HALEndpointService, - protected http: HttpClient, - protected notificationsService: NotificationsService, - protected objectCache: ObjectCacheService, - protected rdbService: RemoteDataBuildService, protected requestService: RequestService, - protected store: Store, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, ) { - super(); + super('accessStatus', requestService, rdbService, objectCache, halService); } /** * Returns {@link RemoteData} of {@link AccessStatusObject} that is the access status of the given item - * @param item Item we want the access status of + * @param item Item we want the access status of */ findAccessStatusFor(item: Item): Observable> { return this.findByHref(item._links.accessStatus.href); diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/base/base-data.service.spec.ts similarity index 59% rename from src/app/core/data/data.service.spec.ts rename to src/app/core/data/base/base-data.service.spec.ts index dc661e12d7..cd8b8c3e32 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/base/base-data.service.spec.ts @@ -1,54 +1,38 @@ -/* eslint-disable max-classes-per-file */ -import { HttpClient } from '@angular/common/http'; -import { Store } from '@ngrx/store'; -import { compare, Operation } from 'fast-json-patch'; +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { FindListOptions } from '../find-list-options.model'; import { Observable, of as observableOf } from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { followLink } from '../../shared/utils/follow-link-config.model'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Item } from '../shared/item.model'; -import { - createFailedRemoteDataObject, - createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$, -} from '../../shared/remote-data.utils'; -import { ChangeAnalyzer } from './change-analyzer'; -import { DataService } from './data.service'; -import { PatchRequest } from './request.models'; -import { RequestService } from './request.service'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { RequestParam } from '../cache/models/request-param.model'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; import { TestScheduler } from 'rxjs/testing'; -import { RemoteData } from './remote-data'; -import { RequestEntryState } from './request-entry-state.model'; -import { CoreState } from '../core-state.model'; -import { FindListOptions } from './find-list-options.model'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; import { fakeAsync, tick } from '@angular/core/testing'; +import { BaseDataService } from './base-data.service'; const endpoint = 'https://rest.api/core'; const BOOLEAN = { f: false, t: true }; -class TestService extends DataService { - +class TestService extends BaseDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected linkPath: string, - protected halService: HALEndpointService, protected objectCache: ObjectCacheService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: ChangeAnalyzer + protected halService: HALEndpointService, ) { - super(); + super(undefined, requestService, rdbService, objectCache, halService); } public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { @@ -56,24 +40,12 @@ class TestService extends DataService { } } -class DummyChangeAnalyzer implements ChangeAnalyzer { - diff(object1: Item, object2: Item): Operation[] { - return compare((object1 as any).metadata, (object2 as any).metadata); - } - -} - -describe('DataService', () => { +describe('BaseDataService', () => { let service: TestService; - let options: FindListOptions; let requestService; let halService; let rdbService; - let notificationsService; - let http; - let comparator; let objectCache; - let store; let selfLink; let linksToFollow; let testScheduler; @@ -83,9 +55,6 @@ describe('DataService', () => { requestService = getMockRequestService(); halService = new HALEndpointServiceStub('url') as any; rdbService = getMockRemoteDataBuildService(); - notificationsService = {} as NotificationsService; - http = {} as HttpClient; - comparator = new DummyChangeAnalyzer() as any; objectCache = { addPatch: () => { @@ -98,7 +67,6 @@ describe('DataService', () => { /* empty */ } } as any; - store = {} as Store; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; linksToFollow = [ followLink('a'), @@ -126,17 +94,11 @@ describe('DataService', () => { ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), }; - return new TestService( requestService, rdbService, - store, - endpoint, - halService, objectCache, - notificationsService, - http, - comparator, + halService, ); } @@ -144,259 +106,6 @@ describe('DataService', () => { service = initTestService(); }); - describe('getFindAllHref', () => { - - it('should return an observable with the endpoint', () => { - options = {}; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(endpoint); - } - ); - }); - - it('should include page in href if currentPage provided in options', () => { - options = { currentPage: 2 }; - const expected = `${endpoint}?page=${options.currentPage - 1}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include size in href if elementsPerPage provided in options', () => { - options = { elementsPerPage: 5 }; - const expected = `${endpoint}?size=${options.elementsPerPage}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include sort href if SortOptions provided in options', () => { - const sortOptions = new SortOptions('field1', SortDirection.ASC); - options = { sort: sortOptions }; - const expected = `${endpoint}?sort=${sortOptions.field},${sortOptions.direction}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include startsWith in href if startsWith provided in options', () => { - options = { startsWith: 'ab' }; - const expected = `${endpoint}?startsWith=${options.startsWith}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include all provided options in href', () => { - const sortOptions = new SortOptions('field1', SortDirection.DESC); - options = { - currentPage: 6, - elementsPerPage: 10, - sort: sortOptions, - startsWith: 'ab', - - }; - const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + - `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include all searchParams in href if any provided in options', () => { - options = { - searchParams: [ - new RequestParam('param1', 'test'), - new RequestParam('param2', 'test2'), - ] - }; - const expected = `${endpoint}?param1=test¶m2=test2`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include linkPath in href if any provided', () => { - const expected = `${endpoint}/test/entries`; - - (service as any).getFindAllHref({}, 'test/entries').subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include single linksToFollow as embed', () => { - const expected = `${endpoint}?embed=bundles`; - - (service as any).getFindAllHref({}, null, followLink('bundles')).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include single linksToFollow as embed and its size', () => { - const expected = `${endpoint}?embed.size=bundles=5&embed=bundles`; - const config: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 5 - }); - (service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include multiple linksToFollow as embed', () => { - const expected = `${endpoint}?embed=bundles&embed=owningCollection&embed=templateItemOf`; - - (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include multiple linksToFollow as embed and its sizes if given', () => { - const expected = `${endpoint}?embed=bundles&embed.size=owningCollection=2&embed=owningCollection&embed=templateItemOf`; - - const config: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 2 - }); - - (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should not include linksToFollow with shouldEmbed = false', () => { - const expected = `${endpoint}?embed=templateItemOf`; - - (service as any).getFindAllHref( - {}, - null, - followLink('bundles', { shouldEmbed: false }), - followLink('owningCollection', { shouldEmbed: false }), - followLink('templateItemOf') - ).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include nested linksToFollow 3lvl', () => { - const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`; - - (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include nested linksToFollow 2lvl and nested embed\'s size', () => { - const expected = `${endpoint}?embed.size=owningCollection/itemtemplate=4&embed=owningCollection/itemtemplate`; - const config: FindListOptions = Object.assign(new FindListOptions(), { - elementsPerPage: 4 - }); - (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - }); - - describe('getIDHref', () => { - const endpointMock = 'https://dspace7-internal.atmire.com/server/api/core/items'; - const resourceIdMock = '003c99b4-d4fe-44b0-a945-e12182a7ca89'; - - it('should return endpoint', () => { - const result = (service as any).getIDHref(endpointMock, resourceIdMock); - expect(result).toEqual(endpointMock + '/' + resourceIdMock); - }); - - it('should include single linksToFollow as embed', () => { - const expected = `${endpointMock}/${resourceIdMock}?embed=bundles`; - const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles')); - expect(result).toEqual(expected); - }); - - it('should include multiple linksToFollow as embed', () => { - const expected = `${endpointMock}/${resourceIdMock}?embed=bundles&embed=owningCollection&embed=templateItemOf`; - const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); - expect(result).toEqual(expected); - }); - - it('should not include linksToFollow with shouldEmbed = false', () => { - const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`; - const result = (service as any).getIDHref( - endpointMock, - resourceIdMock, - followLink('bundles', { shouldEmbed: false }), - followLink('owningCollection', { shouldEmbed: false }), - followLink('templateItemOf') - ); - expect(result).toEqual(expected); - }); - - it('should include nested linksToFollow 3lvl', () => { - const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`; - const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))); - expect(result).toEqual(expected); - }); - }); - - describe('patch', () => { - const dso = { - uuid: 'dso-uuid' - }; - const operations = [ - Object.assign({ - op: 'move', - from: '/1', - path: '/5' - }) as Operation - ]; - - beforeEach(() => { - service.patch(dso, operations); - }); - - it('should send a PatchRequest', () => { - expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest)); - }); - }); - - describe('update', () => { - let operations; - let dso; - let dso2; - const name1 = 'random string'; - const name2 = 'another random string'; - beforeEach(() => { - operations = [{ op: 'replace', path: '/0/value', value: name2 } as Operation]; - - dso = Object.assign(new DSpaceObject(), { - _links: { self: { href: selfLink } }, - metadata: [{ key: 'dc.title', value: name1 }] - }); - - dso2 = Object.assign(new DSpaceObject(), { - _links: { self: { href: selfLink } }, - metadata: [{ key: 'dc.title', value: name2 }] - }); - - spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso)); - spyOn(objectCache, 'addPatch'); - }); - - it('should call addPatch on the object cache with the right parameters when there are differences', () => { - service.update(dso2).subscribe(); - expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); - }); - - it('should not call addPatch on the object cache with the right parameters when there are no differences', () => { - service.update(dso).subscribe(); - expect(objectCache.addPatch).not.toHaveBeenCalled(); - }); - }); - describe(`reRequestStaleRemoteData`, () => { let callback: jasmine.Spy; @@ -661,7 +370,7 @@ describe('DataService', () => { }); - describe(`findAllByHref`, () => { + describe(`findListByHref`, () => { let findListOptions; beforeEach(() => { findListOptions = { currentPage: 5 }; @@ -674,7 +383,7 @@ describe('DataService', () => { spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); - service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow); }); }); @@ -685,11 +394,11 @@ describe('DataService', () => { spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); - service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true); expectObservable(rdbService.buildList.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' }); - service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow); + service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), false); expectObservable(rdbService.buildList.calls.argsFor(1)[0]).toBe('(a|)', { a: 'bingo!' }); }); @@ -701,29 +410,29 @@ describe('DataService', () => { spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); - service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow); expectObservable(rdbService.buildList.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' }); }); }); - it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findAllByHref call as a callback`, () => { + it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findListByHref call as a callback`, () => { testScheduler.run(({ cold, expectObservable }) => { spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale })); - service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); + service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow); expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue(); - spyOn(service, 'findAllByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); + spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); // prove that the spy we just added hasn't been called yet - expect(service.findAllByHref).not.toHaveBeenCalled(); + expect(service.findListByHref).not.toHaveBeenCalled(); // call the callback passed to reRequestStaleRemoteData (service as any).reRequestStaleRemoteData.calls.argsFor(0)[1](); - // verify that findAllByHref _has_ been called now, with the same params as the original call - expect(service.findAllByHref).toHaveBeenCalledWith(jasmine.anything(), findListOptions, true, true, ...linksToFollow); + // verify that findListByHref _has_ been called now, with the same params as the original call + expect(service.findListByHref).toHaveBeenCalledWith(jasmine.anything(), findListOptions, true, true, ...linksToFollow); // ... except for selflink, which will have been turned in to an observable. - expectObservable((service.findAllByHref as jasmine.Spy).calls.argsFor(0)[0]).toBe('(a|)', { a: selfLink }); + expectObservable((service.findListByHref as jasmine.Spy).calls.argsFor(0)[0]).toBe('(a|)', { a: selfLink }); }); }); @@ -737,7 +446,7 @@ describe('DataService', () => { a: 'bingo!', }; - expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); + expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); }); }); @@ -765,7 +474,7 @@ describe('DataService', () => { e: remoteDataMocks.SuccessStale, }; - expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); + expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); }); }); @@ -786,7 +495,7 @@ describe('DataService', () => { e: remoteDataMocks.SuccessStale, }; - expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); + expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); }); }); @@ -816,7 +525,7 @@ describe('DataService', () => { e: remoteDataMocks.SuccessStale, }; - expectObservable(service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); + expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); }); }); @@ -837,7 +546,7 @@ describe('DataService', () => { e: remoteDataMocks.SuccessStale, }; - expectObservable(service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); + expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); }); }); @@ -915,97 +624,4 @@ describe('DataService', () => { }); }); }); - - describe('delete', () => { - let MOCK_SUCCEEDED_RD; - let MOCK_FAILED_RD; - - let invalidateByHrefSpy: jasmine.Spy; - let buildFromRequestUUIDSpy: jasmine.Spy; - let getIDHrefObsSpy: jasmine.Spy; - let deleteByHrefSpy: jasmine.Spy; - - beforeEach(() => { - invalidateByHrefSpy = spyOn(service, 'invalidateByHref').and.returnValue(observableOf(true)); - buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.callThrough(); - getIDHrefObsSpy = spyOn(service, 'getIDHrefObs').and.callThrough(); - deleteByHrefSpy = spyOn(service, 'deleteByHref').and.callThrough(); - - MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({}); - MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong'); - }); - - it('should retrieve href by ID and call deleteByHref', () => { - getIDHrefObsSpy.and.returnValue(observableOf('some-href')); - buildFromRequestUUIDSpy.and.returnValue(createSuccessfulRemoteDataObject$({})); - - service.delete('some-id', ['a', 'b', 'c']).subscribe(rd => { - expect(getIDHrefObsSpy).toHaveBeenCalledWith('some-id'); - expect(deleteByHrefSpy).toHaveBeenCalledWith('some-href', ['a', 'b', 'c']); - }); - }); - - describe('deleteByHref', () => { - it('should call invalidateByHref if the DELETE request succeeds', (done) => { - buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); - - service.deleteByHref('some-href').subscribe(rd => { - expect(rd).toBe(MOCK_SUCCEEDED_RD); - expect(invalidateByHrefSpy).toHaveBeenCalled(); - done(); - }); - }); - - it('should call invalidateByHref even if not subscribing to returned Observable', fakeAsync(() => { - buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); - - service.deleteByHref('some-href'); - tick(); - - expect(invalidateByHrefSpy).toHaveBeenCalled(); - })); - - it('should not call invalidateByHref if the DELETE request fails', (done) => { - buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_FAILED_RD)); - - service.deleteByHref('some-href').subscribe(rd => { - expect(rd).toBe(MOCK_FAILED_RD); - expect(invalidateByHrefSpy).not.toHaveBeenCalled(); - done(); - }); - }); - - it('should wait for invalidateByHref before emitting', () => { - testScheduler.run(({ cold, expectObservable }) => { - buildFromRequestUUIDSpy.and.returnValue( - cold('(r|)', { r: MOCK_SUCCEEDED_RD}) // RD emits right away - ); - invalidateByHrefSpy.and.returnValue( - cold('----(t|)', BOOLEAN) // but we pretend that setting requests to stale takes longer - ); - - const done$ = service.deleteByHref('some-href'); - expectObservable(done$).toBe( - '----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait until that's done - ); - }); - }); - - it('should wait for the DELETE request to resolve before emitting', () => { - testScheduler.run(({ cold, expectObservable }) => { - buildFromRequestUUIDSpy.and.returnValue( - cold('----(r|)', { r: MOCK_SUCCEEDED_RD}) // the request takes a while - ); - invalidateByHrefSpy.and.returnValue( - cold('(t|)', BOOLEAN) // but we pretend that setting to stale happens sooner - ); // e.g.: maybe already stale before this call? - - const done$ = service.deleteByHref('some-href'); - expectObservable(done$).toBe( - '----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait for the request - ); - }); - }); - }); - }); }); diff --git a/src/app/core/data/base/base-data.service.ts b/src/app/core/data/base/base-data.service.ts new file mode 100644 index 0000000000..d8caa6c1c8 --- /dev/null +++ b/src/app/core/data/base/base-data.service.ts @@ -0,0 +1,374 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { AsyncSubject, from as observableFrom, Observable, of as observableOf } from 'rxjs'; +import { map, mergeMap, skipWhile, switchMap, take, tap, toArray } from 'rxjs/operators'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { URLCombiner } from '../../url-combiner/url-combiner'; +import { RemoteData } from '../remote-data'; +import { GetRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { ObjectCacheEntry } from '../../cache/object-cache.reducer'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALDataService } from './hal-data-service.interface'; + +/** + * Common functionality for data services. + * Specific functionality that not all services would need + * is implemented in "DataService feature" classes (e.g. {@link CreateData} + * + * All DataService (or DataService feature) classes must + * - extend this class (or {@link IdentifiableDataService}) + * - implement any DataService features it requires in order to forward calls to it + * + * ``` + * export class SomeDataService extends BaseDataService implements CreateData, SearchData { + * private createData: CreateData; + * private searchData: SearchDataData; + * + * create(...) { + * return this.createData.create(...); + * } + * + * searchBy(...) { + * return this.searchData.searchBy(...); + * } + * } + * ``` + */ +export class BaseDataService implements HALDataService { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive?: number, + ) { + } + + /** + * Allows subclasses to reset the response cache time. + */ + + /** + * Get the endpoint for browsing + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @returns {Observable} + */ + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { + return this.getEndpoint(); + } + + /** + * Get the base endpoint for all requests + */ + protected getEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Turn an options object into a query string and combine it with the given HREF + * + * @param href The HREF to which the query string should be appended + * @param options The [[FindListOptions]] object + * @param extraArgs Array with additional params to combine with query string + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: FollowLinkConfig[]): string { + let args = [...extraArgs]; + + if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { + /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ + args = this.addHrefArg(href, args, `page=${options.currentPage - 1}`); + } + if (hasValue(options.elementsPerPage)) { + args = this.addHrefArg(href, args, `size=${options.elementsPerPage}`); + } + if (hasValue(options.sort)) { + args = this.addHrefArg(href, args, `sort=${options.sort.field},${options.sort.direction}`); + } + if (hasValue(options.startsWith)) { + args = this.addHrefArg(href, args, `startsWith=${options.startsWith}`); + } + if (hasValue(options.searchParams)) { + options.searchParams.forEach((param: RequestParam) => { + args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); + }); + } + args = this.addEmbedParams(href, args, ...linksToFollow); + if (isNotEmpty(args)) { + return new URLCombiner(href, `?${args.join('&')}`).toString(); + } else { + return href; + } + } + + /** + * Turn an array of RequestParam into a query string and combine it with the given HREF + * + * @param href The HREF to which the query string should be appended + * @param params Array with additional params to combine with query string + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * + * @return {Observable} + * Return an observable that emits created HREF + */ + buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig[]): string { + + let args = []; + if (hasValue(params)) { + params.forEach((param: RequestParam) => { + args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); + }); + } + + args = this.addEmbedParams(href, args, ...linksToFollow); + + if (isNotEmpty(args)) { + return new URLCombiner(href, `?${args.join('&')}`).toString(); + } else { + return href; + } + } + /** + * Adds the embed options to the link for the request + * @param href The href the params are to be added to + * @param args params for the query string + * @param linksToFollow links we want to embed in query string if shouldEmbed is true + */ + protected addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig[]) { + linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { + if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { + const embedString = 'embed=' + String(linkToFollow.name); + // Add the embeds size if given in the FollowLinkConfig.FindListOptions + if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { + args = this.addHrefArg(href, args, + 'embed.size=' + String(linkToFollow.name) + '=' + linkToFollow.findListOptions.elementsPerPage); + } + // Adds the nested embeds and their size if given + if (isNotEmpty(linkToFollow.linksToFollow)) { + args = this.addNestedEmbeds(embedString, href, args, ...linkToFollow.linksToFollow); + } else { + args = this.addHrefArg(href, args, embedString); + } + } + }); + return args; + } + + /** + * Add a new argument to the list of arguments, only if it doesn't already exist in the given href, + * or the current list of arguments + * + * @param href The href the arguments are to be added to + * @param currentArgs The current list of arguments + * @param newArg The new argument to add + * @return The next list of arguments, with newArg included if it wasn't already. + * Note this function will not modify any of the input params. + */ + protected addHrefArg(href: string, currentArgs: string[], newArg: string): string[] { + if (href.includes(newArg) || currentArgs.includes(newArg)) { + return [...currentArgs]; + } else { + return [...currentArgs, newArg]; + } + } + + /** + * Add the nested followLinks to the embed param, separated by a /, and their sizes, recursively + * @param embedString embedString so far (recursive) + * @param href The href the params are to be added to + * @param args params for the query string + * @param linksToFollow links we want to embed in query string if shouldEmbed is true + */ + protected addNestedEmbeds(embedString: string, href: string, args: string[], ...linksToFollow: FollowLinkConfig[]): string[] { + let nestEmbed = embedString; + linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { + if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { + nestEmbed = nestEmbed + '/' + String(linkToFollow.name); + // Add the nested embeds size if given in the FollowLinkConfig.FindListOptions + if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { + const nestedEmbedSize = 'embed.size=' + nestEmbed.split('=')[1] + '=' + linkToFollow.findListOptions.elementsPerPage; + args = this.addHrefArg(href, args, nestedEmbedSize); + } + if (hasValue(linkToFollow.linksToFollow) && isNotEmpty(linkToFollow.linksToFollow)) { + args = this.addNestedEmbeds(nestEmbed, href, args, ...linkToFollow.linksToFollow); + } else { + args = this.addHrefArg(href, args, nestEmbed); + } + } + }); + return args; + } + + /** + * An operator that will call the given function if the incoming RemoteData is stale and + * shouldReRequest is true + * + * @param shouldReRequest Whether or not to call the re-request function if the RemoteData is stale + * @param requestFn The function to call if the RemoteData is stale and shouldReRequest is + * true + */ + protected reRequestStaleRemoteData(shouldReRequest: boolean, requestFn: () => Observable>) { + return (source: Observable>): Observable> => { + if (shouldReRequest === true) { + return source.pipe( + tap((remoteData: RemoteData) => { + if (hasValue(remoteData) && remoteData.isStale) { + requestFn(); + } + }) + ); + } else { + return source; + } + }; + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param href$ The url of object we want to retrieve. Can be a string or + * an Observable + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findByHref(href$: string | Observable, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + const requestHref$ = href$.pipe( + isNotEmptyOperator(), + take(1), + map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)), + ); + + this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); + + return this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( + // This skip ensures that if a stale object is present in the cache when you do a + // call it isn't immediately returned, but we wait until the remote data for the new request + // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a + // cached completed object + skipWhile((rd: RemoteData) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), + this.reRequestStaleRemoteData(reRequestOnStale, () => + this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + ); + } + + /** + * Returns an Observable of a {@link RemoteData} of a {@link PaginatedList} of objects, based on an href, + * with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * @param href$ The url of list we want to retrieve. Can be a string or an Observable + * @param options + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version. + * @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findListByHref(href$: string | Observable, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + const requestHref$ = href$.pipe( + isNotEmptyOperator(), + take(1), + map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)), + ); + + this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); + + return this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( + // This skip ensures that if a stale object is present in the cache when you do a + // call it isn't immediately returned, but we wait until the remote data for the new request + // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a + // cached completed object + skipWhile((rd: RemoteData>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), + this.reRequestStaleRemoteData(reRequestOnStale, () => + this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)), + ); + } + + /** + * Create a GET request for the given href, and send it. + * + * @param href$ The url of object we want to retrieve. Can be a string or + * an Observable + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + */ + protected createAndSendGetRequest(href$: string | Observable, useCachedVersionIfAvailable = true): void { + if (isNotEmpty(href$)) { + if (typeof href$ === 'string') { + href$ = observableOf(href$); + } + + href$.pipe( + isNotEmptyOperator(), + take(1) + ).subscribe((href: string) => { + const requestId = this.requestService.generateRequestId(); + const request = new GetRequest(requestId, href); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request, useCachedVersionIfAvailable); + }); + } + } + + /** + * Return the links to traverse from the root of the api to the + * endpoint this DataService represents + * + * e.g. if the api root links to 'foo', and the endpoint at 'foo' + * links to 'bar' the linkPath for the BarDataService would be + * 'foo/bar' + */ + getLinkPath(): string { + return this.linkPath; + } + + /** + * Invalidate a cached object by its href + * @param href the href to invalidate + */ + public invalidateByHref(href: string): Observable { + const done$ = new AsyncSubject(); + + this.objectCache.getByHref(href).pipe( + take(1), + switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( + mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), + toArray(), + )), + ).subscribe(() => { + done$.next(true); + done$.complete(); + }); + + return done$; + } +} diff --git a/src/app/core/data/base/create-data.spec.ts b/src/app/core/data/base/create-data.spec.ts new file mode 100644 index 0000000000..0b2e0f3930 --- /dev/null +++ b/src/app/core/data/base/create-data.spec.ts @@ -0,0 +1,225 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { CreateData, CreateDataImpl } from './create-data'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { RestRequestMethod } from '../rest-request-method'; +import { DSpaceObject } from '../../shared/dspace-object.model'; + +/** + * Tests whether calls to `CreateData` methods are correctly patched through in a concrete data service that implements it + */ +export function testCreateDataImplementation(serviceFactory: () => CreateData) { + let service; + + describe('CreateData implementation', () => { + const OBJ = Object.assign(new DSpaceObject(), { + uuid: '08eec68f-45e4-47a3-80c5-f0beb5627079', + }); + const PARAMS = [ + new RequestParam('abc', 123), new RequestParam('def', 456), + ]; + + beforeAll(() => { + service = serviceFactory(); + + (service as any).createData = jasmine.createSpyObj('createData', { + create: 'TEST create', + }); + }); + + it('should handle calls to create', () => { + const out: any = service.create(OBJ, ...PARAMS); + + expect((service as any).createData.create).toHaveBeenCalledWith(OBJ, ...PARAMS); + expect(out).toBe('TEST create'); + }); + }); +} + +const endpoint = 'https://rest.api/core'; + +class TestService extends CreateDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('test', requestService, rdbService, objectCache, halService, notificationsService, undefined); + } + + public getEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('CreateDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let notificationsService; + let remoteDataMocks; + let obj; + + let MOCK_SUCCEEDED_RD; + let MOCK_FAILED_RD; + + let buildFromRequestUUIDSpy: jasmine.Spy; + let createOnEndpointSpy: jasmine.Spy; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + notificationsService = jasmine.createSpyObj('notificationsService', { + error: undefined, + }); + + obj = { + uuid: '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7', + }; + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ); + } + + beforeEach(() => { + service = initTestService(); + + buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.callThrough(); + createOnEndpointSpy = spyOn(service, 'createOnEndpoint').and.callThrough(); + + MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({}); + MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong'); + }); + + describe('create', () => { + it('should POST the object to the root endpoint with the given parameters and return the remote data', (done) => { + const params = [ + new RequestParam('abc', 123), new RequestParam('def', 456) + ]; + buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Success)); + + service.create(obj, ...params).subscribe(out => { + expect(createOnEndpointSpy).toHaveBeenCalledWith(obj, jasmine.anything()); + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.POST, + uuid: requestService.generateRequestId(), + href: 'https://rest.api/core?abc=123&def=456', + body: JSON.stringify(obj), + })); + expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId()); + expect(out).toEqual(remoteDataMocks.Success); + done(); + }); + }); + }); + + describe('createOnEndpoint', () => { + beforeEach(() => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Success)); + }); + + it('should send a POST request with the object as JSON', (done) => { + service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.POST, + body: JSON.stringify(obj), + })); + done(); + }); + }); + + it('should send the POST request to the given endpoint', (done) => { + + service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.POST, + href: 'https://rest.api/core/custom?search', + })); + done(); + }); + }); + + it('should return the remote data for the sent request', (done) => { + service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.POST, + uuid: requestService.generateRequestId(), + })); + expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId()); + expect(notificationsService.error).not.toHaveBeenCalled(); + expect(out).toEqual(remoteDataMocks.Success); + done(); + }); + }); + + it('should show an error notification if the request fails', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Error)); + + service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.POST, + uuid: requestService.generateRequestId(), + })); + expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId()); + expect(notificationsService.error).toHaveBeenCalled(); + expect(out).toEqual(remoteDataMocks.Error); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/base/create-data.ts b/src/app/core/data/base/create-data.ts new file mode 100644 index 0000000000..3ffcd9adf2 --- /dev/null +++ b/src/app/core/data/base/create-data.ts @@ -0,0 +1,107 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { BaseDataService } from './base-data.service'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../remote-data'; +import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util'; +import { distinctUntilChanged, map, take, takeWhile } from 'rxjs/operators'; +import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; +import { getClassForType } from '../../cache/builders/build-decorators'; +import { CreateRequest } from '../request.models'; +import { NotificationOptions } from '../../../shared/notifications/models/notification-options.model'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; + +/** + * Interface for a data service that can create objects. + */ +export interface CreateData { + /** + * Create a new DSpaceObject on the server, and store the response + * in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable>; +} + +/** + * A DataService feature to create objects. + * + * Concrete data services can use this feature by implementing {@link CreateData} + * and delegating its method to an inner instance of this class. + */ +export class CreateDataImpl extends BaseDataService implements CreateData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Create a new object on the server, and store the response in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable> { + const endpoint$ = this.getEndpoint().pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + map((endpoint: string) => this.buildHrefWithParams(endpoint, params)), + ); + return this.createOnEndpoint(object, endpoint$); + } + + /** + * Send a POST request to create a new resource to a specific endpoint. + * Use this method if the endpoint needs to be adjusted. In most cases {@link create} should be sufficient. + * @param object the object to create + * @param endpoint$ the endpoint to send the POST request to + */ + createOnEndpoint(object: T, endpoint$: Observable): Observable> { + const requestId = this.requestService.generateRequestId(); + const serializedObject = new DSpaceSerializer(getClassForType(object.type)).serialize(object); + + endpoint$.pipe( + take(1), + ).subscribe((endpoint: string) => { + const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedObject)); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + const result$ = this.rdbService.buildFromRequestUUID(requestId); + + // TODO a dataservice is not the best place to show a notification, + // this should move up to the components that use this method + result$.pipe( + takeWhile((rd: RemoteData) => rd.isLoading, true) + ).subscribe((rd: RemoteData) => { + if (rd.hasFailed) { + this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1)); + } + }); + + return result$; + } +} diff --git a/src/app/core/data/base/data-service.decorator.spec.ts b/src/app/core/data/base/data-service.decorator.spec.ts new file mode 100644 index 0000000000..ec4c1c8f6f --- /dev/null +++ b/src/app/core/data/base/data-service.decorator.spec.ts @@ -0,0 +1,55 @@ +/* eslint-disable max-classes-per-file */ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { ResourceType } from '../../shared/resource-type'; +import { BaseDataService } from './base-data.service'; +import { HALDataService } from './hal-data-service.interface'; +import { dataService, getDataServiceFor } from './data-service.decorator'; + +class TestService extends BaseDataService { +} + +class AnotherTestService implements HALDataService { + public findListByHref(href$, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow): any { + return undefined; + } + + public findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow): any { + return undefined; + } +} + +let testType; + +describe('@dataService/getDataServiceFor', () => { + beforeEach(() => { + testType = new ResourceType('testType-' + new Date().getTime()); + }); + + it('should register a resourcetype for a dataservice', () => { + dataService(testType)(TestService); + expect(getDataServiceFor(testType)).toBe(TestService); + }); + + describe(`when the resource type isn't specified`, () => { + it(`should throw an error`, () => { + expect(() => { + dataService(undefined)(TestService); + }).toThrow(); + }); + }); + + describe(`when there already is a registered dataservice for a resourcetype`, () => { + it(`should throw an error`, () => { + dataService(testType)(TestService); + expect(() => { + dataService(testType)(AnotherTestService); + }).toThrow(); + }); + }); +}); diff --git a/src/app/core/data/base/data-service.decorator.ts b/src/app/core/data/base/data-service.decorator.ts new file mode 100644 index 0000000000..fbde9bd94f --- /dev/null +++ b/src/app/core/data/base/data-service.decorator.ts @@ -0,0 +1,51 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { InjectionToken } from '@angular/core'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { ResourceType } from '../../shared/resource-type'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { HALDataService } from './hal-data-service.interface'; + +export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor>>('getDataServiceFor', { + providedIn: 'root', + factory: () => getDataServiceFor, +}); +const dataServiceMap = new Map(); + +/** + * A class decorator to indicate that this class is a data service for a given HAL resource type. + * + * In most cases, a data service should extend {@link BaseDataService}. + * At the very least it must implement {@link HALDataService} in order for it to work with {@link LinkService}. + * + * @param resourceType the resource type the class is a dataservice for + */ +export function dataService(resourceType: ResourceType) { + return (target: GenericConstructor>): void => { + if (hasNoValue(resourceType)) { + throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`); + } + const existingDataservice = dataServiceMap.get(resourceType.value); + + if (hasValue(existingDataservice)) { + throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`); + } + + dataServiceMap.set(resourceType.value, target); + }; +} + +/** + * Return the dataservice matching the given resource type + * + * @param resourceType the resource type you want the matching dataservice for + */ +export function getDataServiceFor(resourceType: ResourceType): GenericConstructor> { + return dataServiceMap.get(resourceType.value); +} diff --git a/src/app/core/data/base/delete-data.spec.ts b/src/app/core/data/base/delete-data.spec.ts new file mode 100644 index 0000000000..7bffef18e6 --- /dev/null +++ b/src/app/core/data/base/delete-data.spec.ts @@ -0,0 +1,245 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { constructIdEndpointDefault } from './identifiable-data.service'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { DeleteData, DeleteDataImpl } from './delete-data'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { fakeAsync, tick } from '@angular/core/testing'; + +/** + * Tests whether calls to `DeleteData` methods are correctly patched through in a concrete data service that implements it + */ +export function testDeleteDataImplementation(serviceFactory: () => DeleteData) { + let service; + + describe('DeleteData implementation', () => { + const ID = '2ce78f3a-791b-4d70-b5eb-753d587bbadd'; + const HREF = 'https://rest.api/core/items/' + ID; + const COPY_VIRTUAL_METADATA = [ + 'a', 'b', 'c' + ]; + + beforeAll(() => { + service = serviceFactory(); + (service as any).deleteData = jasmine.createSpyObj('deleteData', { + delete: 'TEST delete', + deleteByHref: 'TEST deleteByHref', + }); + }); + + it('should handle calls to delete', () => { + const out: any = service.delete(ID, COPY_VIRTUAL_METADATA); + + expect((service as any).deleteData.delete).toHaveBeenCalledWith(ID, COPY_VIRTUAL_METADATA); + expect(out).toBe('TEST delete'); + }); + + it('should handle calls to deleteByHref', () => { + const out: any = service.deleteByHref(HREF, COPY_VIRTUAL_METADATA); + + expect((service as any).deleteData.deleteByHref).toHaveBeenCalledWith(HREF, COPY_VIRTUAL_METADATA); + expect(out).toBe('TEST deleteByHref'); + }); + }); +} + +const endpoint = 'https://rest.api/core'; + +const BOOLEAN = { f: false, t: true }; + +class TestService extends DeleteDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, notificationsService, undefined, constructIdEndpointDefault); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('DeleteDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let notificationsService; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + } + } as any; + notificationsService = {} as NotificationsService; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b') + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('delete', () => { + let MOCK_SUCCEEDED_RD; + let MOCK_FAILED_RD; + + let invalidateByHrefSpy: jasmine.Spy; + let buildFromRequestUUIDSpy: jasmine.Spy; + let getIDHrefObsSpy: jasmine.Spy; + let deleteByHrefSpy: jasmine.Spy; + + beforeEach(() => { + invalidateByHrefSpy = spyOn(service, 'invalidateByHref').and.returnValue(observableOf(true)); + buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.callThrough(); + getIDHrefObsSpy = spyOn(service, 'getIDHrefObs').and.callThrough(); + deleteByHrefSpy = spyOn(service, 'deleteByHref').and.callThrough(); + + MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({}); + MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong'); + }); + + it('should retrieve href by ID and call deleteByHref', () => { + getIDHrefObsSpy.and.returnValue(observableOf('some-href')); + buildFromRequestUUIDSpy.and.returnValue(createSuccessfulRemoteDataObject$({})); + + service.delete('some-id', ['a', 'b', 'c']).subscribe(rd => { + expect(getIDHrefObsSpy).toHaveBeenCalledWith('some-id'); + expect(deleteByHrefSpy).toHaveBeenCalledWith('some-href', ['a', 'b', 'c']); + }); + }); + + describe('deleteByHref', () => { + it('should call invalidateByHref if the DELETE request succeeds', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.deleteByHref('some-href').subscribe(rd => { + expect(rd).toBe(MOCK_SUCCEEDED_RD); + expect(invalidateByHrefSpy).toHaveBeenCalled(); + done(); + }); + }); + + it('should call invalidateByHref even if not subscribing to returned Observable', fakeAsync(() => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD)); + + service.deleteByHref('some-href'); + tick(); + + expect(invalidateByHrefSpy).toHaveBeenCalled(); + })); + + it('should not call invalidateByHref if the DELETE request fails', (done) => { + buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_FAILED_RD)); + + service.deleteByHref('some-href').subscribe(rd => { + expect(rd).toBe(MOCK_FAILED_RD); + expect(invalidateByHrefSpy).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should wait for invalidateByHref before emitting', () => { + testScheduler.run(({ cold, expectObservable }) => { + buildFromRequestUUIDSpy.and.returnValue( + cold('(r|)', { r: MOCK_SUCCEEDED_RD}) // RD emits right away + ); + invalidateByHrefSpy.and.returnValue( + cold('----(t|)', BOOLEAN) // but we pretend that setting requests to stale takes longer + ); + + const done$ = service.deleteByHref('some-href'); + expectObservable(done$).toBe( + '----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait until that's done + ); + }); + }); + + it('should wait for the DELETE request to resolve before emitting', () => { + testScheduler.run(({ cold, expectObservable }) => { + buildFromRequestUUIDSpy.and.returnValue( + cold('----(r|)', { r: MOCK_SUCCEEDED_RD}) // the request takes a while + ); + invalidateByHrefSpy.and.returnValue( + cold('(t|)', BOOLEAN) // but we pretend that setting to stale happens sooner + ); // e.g.: maybe already stale before this call? + + const done$ = service.deleteByHref('some-href'); + expectObservable(done$).toBe( + '----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait for the request + ); + }); + }); + }); + }); +}); diff --git a/src/app/core/data/base/delete-data.ts b/src/app/core/data/base/delete-data.ts new file mode 100644 index 0000000000..2e34f0957c --- /dev/null +++ b/src/app/core/data/base/delete-data.ts @@ -0,0 +1,108 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { AsyncSubject, combineLatest, Observable } from 'rxjs'; +import { RemoteData } from '../remote-data'; +import { NoContent } from '../../shared/NoContent.model'; +import { filter, map, switchMap } from 'rxjs/operators'; +import { DeleteRequest } from '../request.models'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { getFirstCompletedRemoteData } from '../../shared/operators'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; + +export interface DeleteData { + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable>; + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable>; +} + +export class DeleteDataImpl extends IdentifiableDataService implements DeleteData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected responseMsToLive: number, + protected constructIdEndpoint: ConstructIdEndpoint, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive, constructIdEndpoint); + if (hasNoValue(constructIdEndpoint)) { + throw new Error(`DeleteDataImpl initialized without a constructIdEndpoint method (linkPath: ${linkPath})`); + } + } + + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.getIDHrefObs(objectId).pipe( + switchMap((href: string) => this.deleteByHref(href, copyVirtualMetadata)), + ); + } + + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + const requestId = this.requestService.generateRequestId(); + + if (copyVirtualMetadata) { + copyVirtualMetadata.forEach((id) => + href += (href.includes('?') ? '&' : '?') + + 'copyVirtualMetadata=' + + id, + ); + } + + const request = new DeleteRequest(requestId, href); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + + const response$ = this.rdbService.buildFromRequestUUID(requestId); + + const invalidated$ = new AsyncSubject(); + response$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return this.invalidateByHref(href); + } else { + return [true]; + } + }) + ).subscribe(() => { + invalidated$.next(true); + invalidated$.complete(); + }); + + return combineLatest([response$, invalidated$]).pipe( + filter(([_, invalidated]) => invalidated), + map(([response, _]) => response), + ); + } +} diff --git a/src/app/core/data/base/find-all-data.spec.ts b/src/app/core/data/base/find-all-data.spec.ts new file mode 100644 index 0000000000..866c3279dc --- /dev/null +++ b/src/app/core/data/base/find-all-data.spec.ts @@ -0,0 +1,296 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { FindAllData, FindAllDataImpl } from './find-all-data'; +import { FindListOptions } from '../find-list-options.model'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { SortDirection, SortOptions } from '../../cache/models/sort-options.model'; +import { RequestParam } from '../../cache/models/request-param.model'; + +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { Observable, of as observableOf } from 'rxjs'; + +/** + * Tests whether calls to `FindAllData` methods are correctly patched through in a concrete data service that implements it + */ +export function testFindAllDataImplementation(serviceFactory: () => FindAllData) { + let service; + + describe('FindAllData implementation', () => { + const OPTIONS = Object.assign(new FindListOptions(), { elementsPerPage: 10, currentPage: 3 }); + const FOLLOWLINKS = [ + followLink('test'), + followLink('something'), + ]; + + beforeAll(() => { + service = serviceFactory(); + (service as any).findAllData = jasmine.createSpyObj('findAllData', { + findAll: 'TEST findAll', + }); + }); + + it('should handle calls to findAll', () => { + const out: any = service.findAll(OPTIONS, false, true, ...FOLLOWLINKS); + + expect((service as any).findAllData.findAll).toHaveBeenCalledWith(OPTIONS, false, true, ...FOLLOWLINKS); + expect(out).toBe('TEST findAll'); + }); + }); +} + +const endpoint = 'https://rest.api/core'; + +class TestService extends FindAllDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, undefined); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('FindAllDataImpl', () => { + let service: TestService; + let options: FindListOptions; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('getFindAllHref', () => { + + it('should return an observable with the endpoint', () => { + options = {}; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(endpoint); + }, + ); + }); + + it('should include page in href if currentPage provided in options', () => { + options = { currentPage: 2 }; + const expected = `${endpoint}?page=${options.currentPage - 1}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include size in href if elementsPerPage provided in options', () => { + options = { elementsPerPage: 5 }; + const expected = `${endpoint}?size=${options.elementsPerPage}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include sort href if SortOptions provided in options', () => { + const sortOptions = new SortOptions('field1', SortDirection.ASC); + options = { sort: sortOptions }; + const expected = `${endpoint}?sort=${sortOptions.field},${sortOptions.direction}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include startsWith in href if startsWith provided in options', () => { + options = { startsWith: 'ab' }; + const expected = `${endpoint}?startsWith=${options.startsWith}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include all provided options in href', () => { + const sortOptions = new SortOptions('field1', SortDirection.DESC); + options = { + currentPage: 6, + elementsPerPage: 10, + sort: sortOptions, + startsWith: 'ab', + + }; + const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + + `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include all searchParams in href if any provided in options', () => { + options = { + searchParams: [ + new RequestParam('param1', 'test'), + new RequestParam('param2', 'test2'), + ], + }; + const expected = `${endpoint}?param1=test¶m2=test2`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include linkPath in href if any provided', () => { + const expected = `${endpoint}/test/entries`; + + (service as any).getFindAllHref({}, 'test/entries').subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include single linksToFollow as embed', () => { + const expected = `${endpoint}?embed=bundles`; + + (service as any).getFindAllHref({}, null, followLink('bundles')).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include single linksToFollow as embed and its size', () => { + const expected = `${endpoint}?embed.size=bundles=5&embed=bundles`; + const config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 5, + }); + (service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include multiple linksToFollow as embed', () => { + const expected = `${endpoint}?embed=bundles&embed=owningCollection&embed=templateItemOf`; + + (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include multiple linksToFollow as embed and its sizes if given', () => { + const expected = `${endpoint}?embed=bundles&embed.size=owningCollection=2&embed=owningCollection&embed=templateItemOf`; + + const config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 2, + }); + + (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should not include linksToFollow with shouldEmbed = false', () => { + const expected = `${endpoint}?embed=templateItemOf`; + + (service as any).getFindAllHref( + {}, + null, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf'), + ).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include nested linksToFollow 3lvl', () => { + const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`; + + (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include nested linksToFollow 2lvl and nested embed\'s size', () => { + const expected = `${endpoint}?embed.size=owningCollection/itemtemplate=4&embed=owningCollection/itemtemplate`; + const config: FindListOptions = Object.assign(new FindListOptions(), { + elementsPerPage: 4, + }); + (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + }); +}); diff --git a/src/app/core/data/base/find-all-data.ts b/src/app/core/data/base/find-all-data.ts new file mode 100644 index 0000000000..57884e537e --- /dev/null +++ b/src/app/core/data/base/find-all-data.ts @@ -0,0 +1,101 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { Observable } from 'rxjs'; +import { FindListOptions } from '../find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { RemoteData } from '../remote-data'; +import { PaginatedList } from '../paginated-list.model'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { BaseDataService } from './base-data.service'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { isNotEmpty } from '../../../shared/empty.util'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +/** + * Interface for a data service that list all of its objects. + */ +export interface FindAllData { + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>>; +} + +/** + * A DataService feature to list all objects. + * + * Concrete data services can use this feature by implementing {@link FindAllData} + * and delegating its method to an inner instance of this class. + */ +export class FindAllDataImpl extends BaseDataService implements FindAllData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findListByHref(this.getFindAllHref(options), options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF with given options object + * + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { + let endpoint$: Observable; + const args = []; + + endpoint$ = this.getBrowseEndpoint(options).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), + distinctUntilChanged(), + ); + + return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); + } +} diff --git a/src/app/core/data/base/hal-data-service.interface.ts b/src/app/core/data/base/hal-data-service.interface.ts new file mode 100644 index 0000000000..6959399760 --- /dev/null +++ b/src/app/core/data/base/hal-data-service.interface.ts @@ -0,0 +1,41 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Observable } from 'rxjs'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { RemoteData } from '../remote-data'; +import { FindListOptions } from '../find-list-options.model'; +import { PaginatedList } from '../paginated-list.model'; +import { HALResource } from '../../shared/hal-resource.model'; + +/** + * An interface defining the minimum functionality needed for a data service to resolve HAL resources. + */ +export interface HALDataService { + /** + * Returns an Observable of {@link RemoteData} of an object, based on an href, + * with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * @param href$ The url of object we want to retrieve. Can be a string or an Observable + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version. + * @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findByHref(href$: string | Observable, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>; + + /** + * Returns an Observable of a {@link RemoteData} of a {@link PaginatedList} of objects, based on an href, + * with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * + * @param href$ The url of list we want to retrieve. Can be a string or an Observable + * @param findListOptions The options for to use for this find list request. + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version. + * @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findListByHref(href$: string | Observable, findListOptions?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>>; +} diff --git a/src/app/core/data/base/identifiable-data.service.spec.ts b/src/app/core/data/base/identifiable-data.service.spec.ts new file mode 100644 index 0000000000..d08f1141fc --- /dev/null +++ b/src/app/core/data/base/identifiable-data.service.spec.ts @@ -0,0 +1,145 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { FindListOptions } from '../find-list-options.model'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { IdentifiableDataService } from './identifiable-data.service'; + +const endpoint = 'https://rest.api/core'; + +class TestService extends IdentifiableDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('IdentifiableDataService', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + } + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b') + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('getIDHref', () => { + const endpointMock = 'https://dspace7-internal.atmire.com/server/api/core/items'; + const resourceIdMock = '003c99b4-d4fe-44b0-a945-e12182a7ca89'; + + it('should return endpoint', () => { + const result = (service as any).getIDHref(endpointMock, resourceIdMock); + expect(result).toEqual(endpointMock + '/' + resourceIdMock); + }); + + it('should include single linksToFollow as embed', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=bundles`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles')); + expect(result).toEqual(expected); + }); + + it('should include multiple linksToFollow as embed', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=bundles&embed=owningCollection&embed=templateItemOf`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); + expect(result).toEqual(expected); + }); + + it('should not include linksToFollow with shouldEmbed = false', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`; + const result = (service as any).getIDHref( + endpointMock, + resourceIdMock, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf') + ); + expect(result).toEqual(expected); + }); + + it('should include nested linksToFollow 3lvl', () => { + const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/src/app/core/data/base/identifiable-data.service.ts b/src/app/core/data/base/identifiable-data.service.ts new file mode 100644 index 0000000000..904f925765 --- /dev/null +++ b/src/app/core/data/base/identifiable-data.service.ts @@ -0,0 +1,83 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { RemoteData } from '../remote-data'; +import { BaseDataService } from './base-data.service'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +/** + * Shorthand type for the method to construct an ID endpoint. + */ +export type ConstructIdEndpoint = (endpoint: string, resourceID: string) => string; + +/** + * The default method to construct an ID endpoint + */ +export const constructIdEndpointDefault = (endpoint, resourceID) => `${endpoint}/${resourceID}`; + +/** + * A type of data service that deals with objects that have an ID. + * + * The effective endpoint to use for the ID can be adjusted by providing a different {@link ConstructIdEndpoint} method. + * This method is passed as an argument so that it can be set on data service features without having to override them. + */ +export class IdentifiableDataService extends BaseDataService { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive?: number, + protected constructIdEndpoint: ConstructIdEndpoint = constructIdEndpointDefault, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of + * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { + const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow); + return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF for a specific object based on its identifier; with possible embed query params based on linksToFollow + * @param endpoint The base endpoint for the type of object + * @param resourceID The identifier for the object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { + return this.buildHrefFromFindOptions(this.constructIdEndpoint(endpoint, resourceID), {}, [], ...linksToFollow); + } + + /** + * Create an observable for the HREF of a specific object based on its identifier + * @param resourceID The identifier for the object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getIDHrefObs(resourceID: string, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.getEndpoint().pipe( + map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow))); + } +} diff --git a/src/app/core/data/base/patch-data.spec.ts b/src/app/core/data/base/patch-data.spec.ts new file mode 100644 index 0000000000..53f2083dab --- /dev/null +++ b/src/app/core/data/base/patch-data.spec.ts @@ -0,0 +1,235 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +/* eslint-disable max-classes-per-file */ +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { TestScheduler } from 'rxjs/testing'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { PatchData, PatchDataImpl } from './patch-data'; +import { ChangeAnalyzer } from '../change-analyzer'; +import { Item } from '../../shared/item.model'; +import { compare, Operation } from 'fast-json-patch'; +import { PatchRequest } from '../request.models'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { constructIdEndpointDefault } from './identifiable-data.service'; +import { RestRequestMethod } from '../rest-request-method'; + +/** + * Tests whether calls to `PatchData` methods are correctly patched through in a concrete data service that implements it + */ +export function testPatchDataImplementation(serviceFactory: () => PatchData) { + let service; + + describe('PatchData implementation', () => { + const OBJ = Object.assign(new DSpaceObject(), { + uuid: '08eec68f-45e4-47a3-80c5-f0beb5627079', + }); + const OPERATIONS = [ + { op: 'replace', path: '/0/value', value: 'test' }, + { op: 'add', path: '/2/value', value: 'test2' }, + ] as Operation[]; + const METHOD = RestRequestMethod.POST; + + beforeAll(() => { + service = serviceFactory(); + (service as any).patchData = jasmine.createSpyObj('patchData', { + patch: 'TEST patch', + update: 'TEST update', + commitUpdates: undefined, + createPatchFromCache: 'TEST createPatchFromCache', + }); + }); + + it('should handle calls to patch', () => { + const out: any = service.patch(OBJ, OPERATIONS); + + expect((service as any).patchData.patch).toHaveBeenCalledWith(OBJ, OPERATIONS); + expect(out).toBe('TEST patch'); + }); + + it('should handle calls to update', () => { + const out: any = service.update(OBJ); + + expect((service as any).patchData.update).toHaveBeenCalledWith(OBJ); + expect(out).toBe('TEST update'); + }); + + it('should handle calls to commitUpdates', () => { + service.commitUpdates(METHOD); + expect((service as any).patchData.commitUpdates).toHaveBeenCalledWith(METHOD); + }); + + it('should handle calls to createPatchFromCache', () => { + const out: any = service.createPatchFromCache(OBJ); + + expect((service as any).patchData.createPatchFromCache).toHaveBeenCalledWith(OBJ); + expect(out).toBe('TEST createPatchFromCache'); + }); + }); +} + +const endpoint = 'https://rest.api/core'; + +class TestService extends PatchDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected comparator: ChangeAnalyzer, + ) { + super(undefined, requestService, rdbService, objectCache, halService, comparator, undefined, constructIdEndpointDefault); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: Item, object2: Item): Operation[] { + return compare((object1 as any).metadata, (object2 as any).metadata); + } +} + +describe('PatchDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let comparator; + let objectCache; + let selfLink; + let linksToFollow; + let testScheduler; + let remoteDataMocks; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + comparator = new DummyChangeAnalyzer() as any; + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + testScheduler = new TestScheduler((actual, expected) => { + // asserting the two objects are equal + // e.g. using chai. + expect(actual).toEqual(expected); + }); + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + comparator, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('patch', () => { + const dso = { + uuid: 'dso-uuid' + }; + const operations = [ + Object.assign({ + op: 'move', + from: '/1', + path: '/5' + }) as Operation + ]; + + beforeEach((done) => { + service.patch(dso, operations).subscribe(() => { + done(); + }); + }); + + it('should send a PatchRequest', () => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest)); + }); + }); + + describe('update', () => { + let operations; + let dso; + let dso2; + const name1 = 'random string'; + const name2 = 'another random string'; + beforeEach(() => { + operations = [{ op: 'replace', path: '/0/value', value: name2 } as Operation]; + + dso = Object.assign(new DSpaceObject(), { + _links: { self: { href: selfLink } }, + metadata: [{ key: 'dc.title', value: name1 }] + }); + + dso2 = Object.assign(new DSpaceObject(), { + _links: { self: { href: selfLink } }, + metadata: [{ key: 'dc.title', value: name2 }] + }); + + spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso)); + spyOn(objectCache, 'addPatch'); + }); + + it('should call addPatch on the object cache with the right parameters when there are differences', () => { + service.update(dso2).subscribe(); + expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); + }); + + it('should not call addPatch on the object cache with the right parameters when there are no differences', () => { + service.update(dso).subscribe(); + expect(objectCache.addPatch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/data/base/patch-data.ts b/src/app/core/data/base/patch-data.ts new file mode 100644 index 0000000000..732f71636e --- /dev/null +++ b/src/app/core/data/base/patch-data.ts @@ -0,0 +1,143 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { Operation } from 'fast-json-patch'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../remote-data'; +import { find, map, mergeMap } from 'rxjs/operators'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { PatchRequest } from '../request.models'; +import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../shared/operators'; +import { ChangeAnalyzer } from '../change-analyzer'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { RestRequestMethod } from '../rest-request-method'; +import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service'; + + +/** + * Interface for a data service that can patch and update objects. + */ +export interface PatchData { + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + patch(object: T, operations: Operation[]): Observable>; + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + update(object: T): Observable>; + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod): void; + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + createPatchFromCache(object: T): Observable; +} + +/** + * A DataService feature to patch and update objects. + * + * Concrete data services can use this feature by implementing {@link PatchData} + * and delegating its method to an inner instance of this class. + * + * Note that this feature requires the object in question to have an ID. + * Make sure to use the same {@link ConstructIdEndpoint} as in the parent data service. + */ +export class PatchDataImpl extends IdentifiableDataService implements PatchData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected comparator: ChangeAnalyzer, + protected responseMsToLive: number, + protected constructIdEndpoint: ConstructIdEndpoint, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive, constructIdEndpoint); + if (hasNoValue(constructIdEndpoint)) { + throw new Error(`PatchDataImpl initialized without a constructIdEndpoint method (linkPath: ${linkPath})`); + } + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + patch(object: T, operations: Operation[]): Observable> { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, object.uuid)), + ); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + ).subscribe((href: string) => { + const request = new PatchRequest(requestId, href, operations); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.send(request); + }); + + return this.rdbService.buildFromRequestUUID(requestId); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + update(object: T): Observable> { + return this.createPatchFromCache(object).pipe( + mergeMap((operations: Operation[]) => { + if (isNotEmpty(operations)) { + this.objectCache.addPatch(object._links.self.href, operations); + } + return this.findByHref(object._links.self.href, true, true); + }), + ); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + commitUpdates(method?: RestRequestMethod): void { + this.requestService.commit(method); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + createPatchFromCache(object: T): Observable { + const oldVersion$ = this.findByHref(object._links.self.href, true, false); + return oldVersion$.pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + map((oldVersion: T) => this.comparator.diff(oldVersion, object)), + ); + } +} diff --git a/src/app/core/data/base/put-data.spec.ts b/src/app/core/data/base/put-data.spec.ts new file mode 100644 index 0000000000..6287fe91b1 --- /dev/null +++ b/src/app/core/data/base/put-data.spec.ts @@ -0,0 +1,176 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { FindListOptions } from '../find-list-options.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { RemoteData } from '../remote-data'; +import { RequestEntryState } from '../request-entry-state.model'; +import { PutData, PutDataImpl } from './put-data'; +import { RestRequestMethod } from '../rest-request-method'; +import { DSpaceObject } from '../../shared/dspace-object.model'; + +/** + * Tests whether calls to `PutData` methods are correctly patched through in a concrete data service that implements it + */ +export function testPutDataImplementation(serviceFactory: () => PutData) { + let service; + + describe('PutData implementation', () => { + const OBJ = Object.assign(new DSpaceObject(), { + uuid: '08eec68f-45e4-47a3-80c5-f0beb5627079', + }); + + beforeAll(() => { + service = serviceFactory(); + (service as any).putData = jasmine.createSpyObj('putData', { + put: 'TEST put', + }); + }); + + it('should handle calls to put', () => { + const out: any = service.put(OBJ); + + expect((service as any).putData.put).toHaveBeenCalledWith(OBJ); + expect(out).toBe('TEST put'); + }); + }); +} + + +const endpoint = 'https://rest.api/core'; + +class TestService extends PutDataImpl { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super(undefined, requestService, rdbService, objectCache, halService, undefined); + } + + public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } +} + +describe('PutDataImpl', () => { + let service: TestService; + let requestService; + let halService; + let rdbService; + let objectCache; + let selfLink; + let remoteDataMocks; + + let obj; + let buildFromRequestUUIDSpy: jasmine.Spy; + + function initTestService(): TestService { + requestService = getMockRequestService(); + halService = new HALEndpointServiceStub('url') as any; + rdbService = getMockRemoteDataBuildService(); + objectCache = { + + addPatch: () => { + /* empty */ + }, + getObjectBySelfLink: () => { + /* empty */ + }, + getByHref: () => { + /* empty */ + }, + } as any; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + + const timeStamp = new Date().getTime(); + const msToLive = 15 * 60 * 1000; + const payload = { foo: 'bar' }; + const statusCodeSuccess = 200; + const statusCodeError = 404; + const errorMessage = 'not found'; + remoteDataMocks = { + RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined), + ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined), + Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess), + SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess), + Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError), + ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), + }; + + return new TestService( + requestService, + rdbService, + objectCache, + halService, + ); + } + + beforeEach(() => { + service = initTestService(); + + obj = Object.assign(new DSpaceObject(), { + uuid: '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7', + metadata: { // recognized properties will be serialized + ['dc.title']: [ + { language: 'en', value: 'some object' }, + ] + }, + data: [ 1, 2, 3, 4 ], // unrecognized properties won't be serialized + _links: { self: { href: selfLink } }, + }); + + + buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.returnValue(observableOf(remoteDataMocks.Success)); + }); + + describe('put', () => { + it('should send a PUT request with the serialized object', (done) => { + service.put(obj).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.PUT, + body: { // _links are not serialized + uuid: obj.uuid, + metadata: obj.metadata + }, + })); + done(); + }); + }); + + it('should send the PUT request to the object\'s self link', (done) => { + service.put(obj).subscribe(() => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.PUT, + href: selfLink, + })); + done(); + }); + }); + + it('should return the remote data for the sent request', (done) => { + service.put(obj).subscribe(out => { + expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({ + method: RestRequestMethod.PUT, + uuid: requestService.generateRequestId(), + })); + expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId()); + expect(out).toEqual(remoteDataMocks.Success); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/base/put-data.ts b/src/app/core/data/base/put-data.ts new file mode 100644 index 0000000000..bd2a8d2929 --- /dev/null +++ b/src/app/core/data/base/put-data.ts @@ -0,0 +1,69 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { BaseDataService } from './base-data.service'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../remote-data'; +import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { PutRequest } from '../request.models'; +import { hasValue } from '../../../shared/empty.util'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +/** + * Interface for a data service that can send PUT requests. + */ +export interface PutData { + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: T): Observable>; +} + +/** + * A DataService feature to send PUT requests. + * + * Concrete data services can use this feature by implementing {@link PutData} + * and delegating its method to an inner instance of this class. + */ +export class PutDataImpl extends BaseDataService implements PutData { + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive: number, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + } + + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: T): Observable> { + const requestId = this.requestService.generateRequestId(); + const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); + const request = new PutRequest(requestId, object._links.self.href, serializedObject); + + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + + this.requestService.send(request); + + return this.rdbService.buildFromRequestUUID(requestId); + } +} diff --git a/src/app/core/data/base/search-data.spec.ts b/src/app/core/data/base/search-data.spec.ts new file mode 100644 index 0000000000..31dddeddfc --- /dev/null +++ b/src/app/core/data/base/search-data.spec.ts @@ -0,0 +1,141 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { constructSearchEndpointDefault, SearchData, SearchDataImpl } from './search-data'; +import { FindListOptions } from '../find-list-options.model'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { of as observableOf } from 'rxjs'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; + +/** + * Tests whether calls to `SearchData` methods are correctly patched through in a concrete data service that implements it + */ +export function testSearchDataImplementation(serviceFactory: () => SearchData) { + let service; + + describe('SearchData implementation', () => { + const OPTIONS = Object.assign(new FindListOptions(), { elementsPerPage: 10, currentPage: 3 }); + const FOLLOWLINKS = [ + followLink('test'), + followLink('something'), + ]; + + beforeAll(() => { + service = serviceFactory(); + (service as any).searchData = jasmine.createSpyObj('searchData', { + searchBy: 'TEST searchBy', + }); + }); + + it('should handle calls to searchBy', () => { + const out: any = service.searchBy('searchMethod', OPTIONS, false, true, ...FOLLOWLINKS); + + expect((service as any).searchData.searchBy).toHaveBeenCalledWith('searchMethod', OPTIONS, false, true, ...FOLLOWLINKS); + expect(out).toBe('TEST searchBy'); + }); + }); +} + +const endpoint = 'https://rest.api/core'; + +describe('SearchDataImpl', () => { + let service: SearchDataImpl; + let requestService; + let halService; + let rdbService; + let linksToFollow; + + let constructSearchEndpointSpy; + let options; + + function initTestService(): SearchDataImpl { + requestService = getMockRequestService(); + halService = jasmine.createSpyObj('halService', { + getEndpoint: observableOf(endpoint), + }); + rdbService = getMockRemoteDataBuildService(); + linksToFollow = [ + followLink('a'), + followLink('b'), + ]; + + constructSearchEndpointSpy = jasmine.createSpy('constructSearchEndpointSpy').and.callFake(constructSearchEndpointDefault); + + options = Object.assign(new FindListOptions(), { + elementsPerPage: 5, + currentPage: 3, + }); + + return new SearchDataImpl( + 'test', + requestService, + rdbService, + undefined, + halService, + undefined, + constructSearchEndpointSpy, + ); + } + + beforeEach(() => { + service = initTestService(); + }); + + describe('getSearchEndpoint', () => { + it('should return the search endpoint for the given method', (done) => { + (service as any).getSearchEndpoint('testMethod').subscribe(searchEndpoint => { + expect(halService.getEndpoint).toHaveBeenCalledWith('test'); + expect(searchEndpoint).toBe('https://rest.api/core/search/testMethod'); + done(); + }); + }); + + it('should use constructSearchEndpoint to construct the search endpoint', (done) => { + (service as any).getSearchEndpoint('testMethod').subscribe(() => { + expect(constructSearchEndpointSpy).toHaveBeenCalledWith('https://rest.api/core', 'testMethod'); + done(); + }); + }); + }); + + describe('getSearchByHref', () => { + beforeEach(() => { + spyOn(service as any, 'getSearchEndpoint').and.callThrough(); + spyOn(service, 'buildHrefFromFindOptions').and.callThrough(); + }); + + it('should return the search endpoint with additional query parameters', (done) => { + service.getSearchByHref('testMethod', options, ...linksToFollow).subscribe(href => { + expect((service as any).getSearchEndpoint).toHaveBeenCalledWith('testMethod'); + expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith( + 'https://rest.api/core/search/testMethod', + options, + [], + ...linksToFollow, + ); + + expect(href).toBe('https://rest.api/core/search/testMethod?page=2&size=5&embed=a&embed=b'); + + done(); + }); + }); + }); + + describe('searchBy', () => { + it('should patch getSearchEndpoint into findListByHref and return the result', () => { + spyOn(service, 'getSearchByHref').and.returnValue('endpoint' as any); + spyOn(service, 'findListByHref').and.returnValue('resulting remote data' as any); + + const out: any = service.searchBy('testMethod', options, false, true, ...linksToFollow); + + expect(service.getSearchByHref).toHaveBeenCalledWith('testMethod', options, ...linksToFollow); + expect(service.findListByHref).toHaveBeenCalledWith('endpoint', undefined, false, true, ...linksToFollow); + expect(out).toBe('resulting remote data'); + }); + }); +}); diff --git a/src/app/core/data/base/search-data.ts b/src/app/core/data/base/search-data.ts new file mode 100644 index 0000000000..536d6d6e25 --- /dev/null +++ b/src/app/core/data/base/search-data.ts @@ -0,0 +1,134 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { BaseDataService } from './base-data.service'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { hasNoValue, isNotEmpty } from '../../../shared/empty.util'; +import { FindListOptions } from '../find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { RemoteData } from '../remote-data'; +import { PaginatedList } from '../paginated-list.model'; +import { RequestService } from '../request.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; + +/** + * Shorthand type for method to construct a search endpoint + */ +export type ConstructSearchEndpoint = (href: string, searchMethod: string) => string; + +/** + * Default method to construct a search endpoint + */ +export const constructSearchEndpointDefault = (href: string, searchMethod: string): string => `${href}/search/${searchMethod}`; + +/** + * Interface for a data service that can search for objects. + */ +export interface SearchData { + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>>; +} + +/** + * A DataService feature to search for objects. + * + * Concrete data services can use this feature by implementing {@link SearchData} + * and delegating its method to an inner instance of this class. + */ +export class SearchDataImpl extends BaseDataService implements SearchData { + /** + * @param linkPath + * @param requestService + * @param rdbService + * @param objectCache + * @param halService + * @param responseMsToLive + * @param constructSearchEndpoint an optional method to construct the search endpoint, passed as an argument so it can be + * modified without extending this class. Defaults to `${href}/search/${searchMethod}` + */ + constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected responseMsToLive: number, + private constructSearchEndpoint: ConstructSearchEndpoint = constructSearchEndpointDefault, + ) { + super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive); + if (hasNoValue(constructSearchEndpoint)) { + throw new Error(`SearchDataImpl initialized without a constructSearchEndpoint method (linkPath: ${linkPath})`); + } + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); + + return this.findListByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable { + let result$: Observable; + const args = []; + + result$ = this.getSearchEndpoint(searchMethod); + + return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); + } + + /** + * Return object search endpoint by given search method + * + * @param searchMethod The search method for the object + */ + private getSearchEndpoint(searchMethod: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map(href => this.constructSearchEndpoint(href, searchMethod)), + ); + } +} diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts index df170397f8..c67eaa2d68 100644 --- a/src/app/core/data/bitstream-data.service.spec.ts +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -12,6 +12,9 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; describe('BitstreamDataService', () => { let service: BitstreamDataService; @@ -47,7 +50,14 @@ describe('BitstreamDataService', () => { }); rdbService = getMockRemoteDataBuildService(); - service = new BitstreamDataService(requestService, rdbService, null, objectCache, halService, null, null, null, null, bitstreamFormatService); + service = new BitstreamDataService(requestService, rdbService, objectCache, halService, null, bitstreamFormatService, null, null); + }); + + describe('composition', () => { + const initService = () => new BitstreamDataService(null, null, null, null, null, null, null, null); + testSearchDataImplementation(initService); + testPatchDataImplementation(initService); + testDeleteDataImplementation(initService); }); describe('when updating the bitstream\'s format', () => { diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 16f2cc16c2..6bdcefe187 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -1,12 +1,9 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Bitstream } from '../shared/bitstream.model'; @@ -15,8 +12,6 @@ import { Bundle } from '../shared/bundle.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { BundleDataService } from './bundle-data.service'; -import { DataService } from './data.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { buildPaginatedList, PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { PutRequest } from './request.models'; @@ -28,36 +23,45 @@ import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.util import { PageInfo } from '../shared/page-info.model'; import { RequestParam } from '../cache/models/request-param.model'; import { sendRequest } from '../shared/request.operators'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { RestRequestMethod } from './rest-request-method'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NoContent } from '../shared/NoContent.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { dataService } from './base/data-service.decorator'; +import { Operation } from 'fast-json-patch'; /** * A service to retrieve {@link Bitstream}s from the REST API */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) @dataService(BITSTREAM) -export class BitstreamDataService extends DataService { - - /** - * The HAL path to the bitstream endpoint - */ - protected linkPath = 'bitstreams'; +export class BitstreamDataService extends IdentifiableDataService implements SearchData, PatchData, DeleteData { + private searchData: SearchDataImpl; + private patchData: PatchDataImpl; + private deleteData: DeleteDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, protected bundleService: BundleDataService, - protected bitstreamFormatService: BitstreamFormatDataService + protected bitstreamFormatService: BitstreamFormatDataService, + protected comparator: DSOChangeAnalyzer, + protected notificationsService: NotificationsService, ) { - super(); + super('bitstreams', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -73,7 +77,7 @@ export class BitstreamDataService extends DataService { * {@link HALLink}s should be automatically resolved */ findAllByBundle(bundle: Bundle, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.findAllByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.findListByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -180,8 +184,97 @@ export class BitstreamDataService extends DataService { hrefObs, useCachedVersionIfAvailable, reRequestOnStale, - ...linksToFollow + ...linksToFollow, ); } + /** + * Create the HREF for a specific object's search method with given options object + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @return {Observable} + * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig[]): Observable { + return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + public patch(object: Bitstream, operations: []): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + public update(object: Bitstream): Observable> { + return this.patchData.update(object); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + public createPatchFromCache(object: Bitstream): Observable { + return this.patchData.createPatchFromCache(object); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/bitstream-format-data.service.spec.ts b/src/app/core/data/bitstream-format-data.service.spec.ts index 30ef79ee6d..662a3f6f1d 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -6,19 +6,16 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { BitstreamFormat } from '../shared/bitstream-format.model'; import { waitForAsync } from '@angular/core/testing'; -import { - BitstreamFormatsRegistryDeselectAction, - BitstreamFormatsRegistryDeselectAllAction, - BitstreamFormatsRegistrySelectAction -} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; import { TestScheduler } from 'rxjs/testing'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { CoreState } from '../core-state.model'; import { RequestEntry } from './request-entry.model'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; describe('BitstreamFormatDataService', () => { let service: BitstreamFormatDataService; @@ -50,8 +47,6 @@ describe('BitstreamFormatDataService', () => { } as HALEndpointService; const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; let rd; let rdbService: RemoteDataBuildService; @@ -65,15 +60,19 @@ describe('BitstreamFormatDataService', () => { return new BitstreamFormatDataService( requestService, rdbService, - store, objectCache, halService, notificationsService, - http, - comparator + store, ); } + describe('composition', () => { + const initService = () => new BitstreamFormatDataService(null, null, null, null, null, null); + testFindAllDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('getBrowseEndpoint', () => { beforeEach(waitForAsync(() => { scheduler = getTestScheduler(); diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts index 1af3db8103..0104389815 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -1,16 +1,10 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, tap } from 'rxjs/operators'; -import { - BitstreamFormatsRegistryDeselectAction, - BitstreamFormatsRegistryDeselectAllAction, - BitstreamFormatsRegistrySelectAction -} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions'; import { BitstreamFormatRegistryState } from '../../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { coreSelector } from '../core.selectors'; @@ -18,40 +12,53 @@ import { BitstreamFormat } from '../shared/bitstream-format.model'; import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type'; import { Bitstream } from '../shared/bitstream.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { RemoteData } from './remote-data'; import { PostRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; import { sendRequest } from '../shared/request.operators'; import { CoreState } from '../core-state.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; +import { FindListOptions } from './find-list-options.model'; +import { PaginatedList } from './paginated-list.model'; +import { NoContent } from '../shared/NoContent.model'; +import { dataService } from './base/data-service.decorator'; const bitstreamFormatsStateSelector = createSelector( coreSelector, - (state: CoreState) => state.bitstreamFormats + (state: CoreState) => state.bitstreamFormats, +); +const selectedBitstreamFormatSelector = createSelector( + bitstreamFormatsStateSelector, + (bitstreamFormatRegistryState: BitstreamFormatRegistryState) => bitstreamFormatRegistryState.selectedBitstreamFormats, ); -const selectedBitstreamFormatSelector = createSelector(bitstreamFormatsStateSelector, - (bitstreamFormatRegistryState: BitstreamFormatRegistryState) => bitstreamFormatRegistryState.selectedBitstreamFormats); /** * A service responsible for fetching/sending data from/to the REST API on the bitstreamformats endpoint */ @Injectable() @dataService(BITSTREAM_FORMAT) -export class BitstreamFormatDataService extends DataService { +export class BitstreamFormatDataService extends IdentifiableDataService implements FindAllData, DeleteData { protected linkPath = 'bitstreamformats'; + private findAllData: FindAllDataImpl; + private deleteData: DeleteDataImpl; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + protected store: Store, + ) { + super('bitstreamformats', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -60,7 +67,7 @@ export class BitstreamFormatDataService extends DataService { */ public getUpdateEndpoint(formatId: string): Observable { return this.getBrowseEndpoint().pipe( - map((endpoint: string) => this.getIDHref(endpoint, formatId)) + map((endpoint: string) => this.getIDHref(endpoint, formatId)), ); } @@ -147,4 +154,47 @@ export class BitstreamFormatDataService extends DataService { findByBitstream(bitstream: Bitstream): Observable> { return this.findByHref(bitstream._links.format.href); } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts index 12eec9e33d..e3ba438f9b 100644 --- a/src/app/core/data/bundle-data.service.spec.ts +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -13,6 +13,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.util import { createPaginatedList } from '../../shared/testing/utils.test'; import { Bundle } from '../shared/bundle.model'; import { CoreState } from '../core-state.model'; +import { testPatchDataImplementation } from './base/patch-data.spec'; class DummyChangeAnalyzer implements ChangeAnalyzer { diff(object1: Item, object2: Item): Operation[] { @@ -64,9 +65,6 @@ describe('BundleDataService', () => { store, objectCache, halService, - notificationsService, - http, - comparator, ); } @@ -74,14 +72,20 @@ describe('BundleDataService', () => { service = initTestService(); }); + describe('composition', () => { + const initService = () => new BundleDataService(null, null, null, null, null); + + testPatchDataImplementation(initService); + }); + describe('findAllByItem', () => { beforeEach(() => { - spyOn(service, 'findAllByHref'); + spyOn(service, 'findListByHref'); service.findAllByItem(item); }); - it('should call findAllByHref with the item\'s bundles link', () => { - expect(service.findAllByHref).toHaveBeenCalledWith(bundleLink, undefined, true, true); + it('should call findListByHref with the item\'s bundles link', () => { + expect(service.findListByHref).toHaveBeenCalledWith(bundleLink, undefined, true, true); }); }); diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index fa5ee51b45..19f0e73706 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -1,20 +1,14 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { map, switchMap, take } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Bundle } from '../shared/bundle.model'; import { BUNDLE } from '../shared/bundle.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { DataService } from './data.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { GetRequest } from './request.models'; @@ -22,30 +16,36 @@ import { RequestService } from './request.service'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { Bitstream } from '../shared/bitstream.model'; import { RequestEntryState } from './request-entry-state.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { RestRequestMethod } from './rest-request-method'; +import { Operation } from 'fast-json-patch'; +import { dataService } from './base/data-service.decorator'; /** * A service to retrieve {@link Bundle}s from the REST API */ @Injectable( - {providedIn: 'root'} + { providedIn: 'root' }, ) @dataService(BUNDLE) -export class BundleDataService extends DataService { - protected linkPath = 'bundles'; - protected bitstreamsEndpoint = 'bitstreams'; +export class BundleDataService extends IdentifiableDataService implements PatchData { + private bitstreamsEndpoint = 'bitstreams'; + + private patchData: PatchDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + protected comparator: DSOChangeAnalyzer, + ) { + super('bundles', requestService, rdbService, objectCache, halService); + + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -61,7 +61,7 @@ export class BundleDataService extends DataService { * {@link HALLink}s should be automatically resolved */ findAllByItem(item: Item, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.findAllByHref(item._links.bundles.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.findListByHref(item._links.bundles.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -133,7 +133,7 @@ export class BundleDataService extends DataService { const hrefObs = this.getBitstreamsEndpoint(bundleId, searchOptions); hrefObs.pipe( - take(1) + take(1), ).subscribe((href) => { const request = new GetRequest(this.requestService.generateRequestId(), href); this.requestService.send(request, true); @@ -141,4 +141,38 @@ export class BundleDataService extends DataService { return this.rdbService.buildList(hrefObs, ...linksToFollow); } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + public patch(object: Bundle, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + public update(object: Bundle): Observable> { + return this.patchData.update(object); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + public createPatchFromCache(object: Bundle): Observable { + return this.patchData.createPatchFromCache(object); + } } diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 031e5ecf47..65f8b3ab2c 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -13,16 +13,17 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { Collection } from '../shared/collection.model'; import { PageInfo } from '../shared/page-info.model'; import { buildPaginatedList } from './paginated-list.model'; -import { - createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject, - createSuccessfulRemoteDataObject$ -} from '../../shared/remote-data.utils'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { Observable } from 'rxjs'; import { RemoteData } from './remote-data'; import { hasNoValue } from '../../shared/empty.util'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; const url = 'fake-url'; const collectionId = 'fake-collection-id'; @@ -75,6 +76,16 @@ describe('CollectionDataService', () => { const paginatedList = buildPaginatedList(pageInfo, array); const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + describe('composition', () => { + const initService = () => new CollectionDataService(null, null, null, null, null, null, null, null, null); + + testCreateDataImplementation(initService); + testFindAllDataImplementation(initService); + testSearchDataImplementation(initService); + testPatchDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('when the requests are successful', () => { beforeEach(() => { createService(); @@ -201,7 +212,7 @@ describe('CollectionDataService', () => { notificationsService = new NotificationsServiceStub(); translate = getMockTranslateService(); - service = new CollectionDataService(requestService, rdbService, null, null, objectCache, halService, notificationsService, null, null, null, translate); + service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate); } }); diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index c243b49d3f..405b35c1f9 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,6 +1,5 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; @@ -9,7 +8,6 @@ import { NotificationOptions } from '../../shared/notifications/models/notificat import { INotification } from '../../shared/notifications/models/notification.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -33,30 +31,28 @@ import { import { RequestService } from './request.service'; import { BitstreamDataService } from './bitstream-data.service'; import { RestRequest } from './rest-request.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { Community } from '../shared/community.model'; +import { dataService } from './base/data-service.decorator'; @Injectable() @dataService(COLLECTION) export class CollectionDataService extends ComColDataService { - protected linkPath = 'collections'; protected errorTitle = 'collection.source.update.notifications.error.title'; protected contentSourceError = 'collection.source.update.notifications.error.content'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected cds: CommunityDataService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected comparator: DSOChangeAnalyzer, protected notificationsService: NotificationsService, - protected http: HttpClient, protected bitstreamDataService: BitstreamDataService, - protected comparator: DSOChangeAnalyzer, - protected translate: TranslateService + protected communityDataService: CommunityDataService, + protected translate: TranslateService, ) { - super(); + super('collections', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } /** @@ -284,15 +280,15 @@ export class CollectionDataService extends ComColDataService { * @param findListOptions Pagination and search options. */ findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable>> { - return this.findAllByHref(item._links.mappedCollections.href, findListOptions); + return this.findListByHref(item._links.mappedCollections.href, findListOptions); } protected getScopeCommunityHref(options: FindListOptions) { - return this.cds.getEndpoint().pipe( - map((endpoint: string) => this.cds.getIDHref(endpoint, options.scopeID)), + return this.communityDataService.getEndpoint().pipe( + map((endpoint: string) => this.communityDataService.getIDHref(endpoint, options.scopeID)), filter((href: string) => isNotEmpty(href)), - take(1) + take(1), ); } } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index dffc97f294..0f9f0fa740 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -13,16 +13,16 @@ import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RequestService } from './request.service'; -import { - createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject$, - createFailedRemoteDataObject, - createSuccessfulRemoteDataObject -} from '../../shared/remote-data.utils'; +import { createFailedRemoteDataObject, createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { BitstreamDataService } from './bitstream-data.service'; import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; import { Bitstream } from '../shared/bitstream.model'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; const LINK_NAME = 'test'; @@ -47,7 +47,7 @@ class TestService extends ComColDataService { protected comparator: DSOChangeAnalyzer, protected linkPath: string ) { - super(); + super('something', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } protected getFindByParentHref(parentUUID: string): Observable { @@ -154,6 +154,15 @@ describe('ComColDataService', () => { service = initTestService(); }); + describe('composition', () => { + const initService = () => new TestService(null, null, null, null, null, null, null, null, null, null, null); + testCreateDataImplementation(initService); + testFindAllDataImplementation(initService); + testSearchDataImplementation(initService); + testPatchDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('getBrowseEndpoint', () => { it(`should call createAndSendGetRequest with the scope Community's self link`, () => { testScheduler.run(({ cold, flush, expectObservable }) => { diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index 01cd18df0c..abc9046cd0 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -4,7 +4,6 @@ import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Community } from '../shared/community.model'; import { HALLink } from '../shared/hal-link.model'; -import { DataService } from './data.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -17,11 +16,45 @@ import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils'; import { URLCombiner } from '../url-combiner/url-combiner'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FindListOptions } from './find-list-options.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { RestRequestMethod } from './rest-request-method'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { RequestParam } from '../cache/models/request-param.model'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { Operation } from 'fast-json-patch'; -export abstract class ComColDataService extends DataService { - protected abstract objectCache: ObjectCacheService; - protected abstract halService: HALEndpointService; - protected abstract bitstreamDataService: BitstreamDataService; +export abstract class ComColDataService extends IdentifiableDataService implements CreateData, FindAllData, SearchData, PatchData, DeleteData { + private createData: CreateData; + private findAllData: FindAllData; + private searchData: SearchData; + private patchData: PatchData; + private deleteData: DeleteData; + + protected constructor( + protected linkPath: string, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected comparator: DSOChangeAnalyzer, + protected notificationsService: NotificationsService, + protected bitstreamDataService: BitstreamDataService, + ) { + super(linkPath, requestService, rdbService, objectCache, halService); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + } /** * Get the scoped endpoint URL by fetching the object with @@ -66,7 +99,7 @@ export abstract class ComColDataService extend const href$ = this.getFindByParentHref(parentUUID).pipe( map((href: string) => this.buildHrefFromFindOptions(href, options)) ); - return this.findAllByHref(href$, options, true, true, ...linksToFollow); + return this.findListByHref(href$, options, true, true, ...linksToFollow); } /** @@ -129,4 +162,110 @@ export abstract class ComColDataService extend const parentCommunity = dso._links.parentCommunity; return isNotEmpty(parentCommunity) ? parentCommunity.href : null; } + + + /** + * Create a new object on the server, and store the response in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + create(object: T, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + public patch(object: T, operations: []): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + public update(object: T): Observable> { + return this.patchData.update(object); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + public createPatchFromCache(object: T): Observable { + return this.patchData.createPatchFromCache(object); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 903d9bc79c..efb6d50e84 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,11 +1,8 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { Community } from '../shared/community.model'; @@ -19,36 +16,36 @@ import { RequestService } from './request.service'; import { BitstreamDataService } from './bitstream-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { isNotEmpty } from '../../shared/empty.util'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { dataService } from './base/data-service.decorator'; @Injectable() @dataService(COMMUNITY) export class CommunityDataService extends ComColDataService { - protected linkPath = 'communities'; protected topLinkPath = 'search/top'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected comparator: DSOChangeAnalyzer, protected notificationsService: NotificationsService, protected bitstreamDataService: BitstreamDataService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer ) { - super(); + super('communities', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService); } + // this method is overridden in order to make it public getEndpoint() { return this.halService.getEndpoint(this.linkPath); } findTop(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable>> { - const hrefObs = this.getFindAllHref(options, this.topLinkPath); - return this.findAllByHref(hrefObs, undefined, true, true, ...linksToFollow); + return this.getEndpoint().pipe( + map(href => `${href}/search/top`), + switchMap(href => this.findListByHref(href, options, true, true, ...linksToFollow)) + ); } protected getFindByParentHref(parentUUID: string): Observable { diff --git a/src/app/core/data/configuration-data.service.spec.ts b/src/app/core/data/configuration-data.service.spec.ts index 7077f098e0..7fe69c16e5 100644 --- a/src/app/core/data/configuration-data.service.spec.ts +++ b/src/app/core/data/configuration-data.service.spec.ts @@ -5,8 +5,6 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { GetRequest } from './request.models'; import { RequestService } from './request.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { ConfigurationDataService } from './configuration-data.service'; import { ConfigurationProperty } from '../shared/configuration-property.model'; @@ -44,18 +42,12 @@ describe('ConfigurationDataService', () => { }) }); objectCache = {} as ObjectCacheService; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; service = new ConfigurationDataService( requestService, rdbService, objectCache, halService, - notificationsService, - http, - comparator ); }); diff --git a/src/app/core/data/configuration-data.service.ts b/src/app/core/data/configuration-data.service.ts index c8241aa9c7..de044e25e3 100644 --- a/src/app/core/data/configuration-data.service.ts +++ b/src/app/core/data/configuration-data.service.ts @@ -1,55 +1,30 @@ /* eslint-disable max-classes-per-file */ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; import { ConfigurationProperty } from '../shared/configuration-property.model'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { CONFIG_PROPERTY } from '../shared/config-property.resource-type'; -import { CoreState } from '../core-state.model'; - -class DataServiceImpl extends DataService { - protected linkPath = 'properties'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); - } -} +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { dataService } from './base/data-service.decorator'; @Injectable() @dataService(CONFIG_PROPERTY) /** * Data Service responsible for retrieving Configuration properties */ -export class ConfigurationDataService { - protected linkPath = 'properties'; - private dataService: DataServiceImpl; +export class ConfigurationDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + ) { + super('properties', requestService, rdbService, objectCache, halService); } /** @@ -57,6 +32,6 @@ export class ConfigurationDataService { * @param name */ findByPropertyName(name: string): Observable> { - return this.dataService.findById(name); + return this.findById(name); } } diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 695b2e5fea..e69de29bb2 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,702 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Store } from '@ngrx/store'; -import { Operation } from 'fast-json-patch'; -import { AsyncSubject, from as observableFrom, Observable, of as observableOf } from 'rxjs'; -import { - distinctUntilChanged, - filter, - find, - map, - mergeMap, - skipWhile, - switchMap, - take, - takeWhile, - tap, - toArray -} from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { getClassForType } from '../cache/builders/build-decorators'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { RequestParam } from '../cache/models/request-param.model'; -import { ObjectCacheEntry } from '../cache/object-cache.reducer'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DSpaceSerializer } from '../dspace-rest/dspace.serializer'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { URLCombiner } from '../url-combiner/url-combiner'; -import { ChangeAnalyzer } from './change-analyzer'; -import { PaginatedList } from './paginated-list.model'; -import { RemoteData } from './remote-data'; -import { CreateRequest, DeleteRequest, GetRequest, PatchRequest, PutRequest } from './request.models'; -import { RequestService } from './request.service'; -import { RestRequestMethod } from './rest-request-method'; -import { UpdateDataService } from './update-data.service'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { NoContent } from '../shared/NoContent.model'; -import { CacheableObject } from '../cache/cacheable-object.model'; -import { CoreState } from '../core-state.model'; -import { FindListOptions } from './find-list-options.model'; - -export abstract class DataService implements UpdateDataService { - protected abstract requestService: RequestService; - protected abstract rdbService: RemoteDataBuildService; - protected abstract store: Store; - protected abstract linkPath: string; - protected abstract halService: HALEndpointService; - protected abstract objectCache: ObjectCacheService; - protected abstract notificationsService: NotificationsService; - protected abstract http: HttpClient; - protected abstract comparator: ChangeAnalyzer; - - /** - * Allows subclasses to reset the response cache time. - */ - protected responseMsToLive: number; - - /** - * Get the endpoint for browsing - * @param options The [[FindListOptions]] object - * @param linkPath The link path for the object - * @returns {Observable} - */ - getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { - return this.getEndpoint(); - } - - /** - * Get the base endpoint for all requests - */ - protected getEndpoint(): Observable { - return this.halService.getEndpoint(this.linkPath); - } - - /** - * Create the HREF with given options object - * - * @param options The [[FindListOptions]] object - * @param linkPath The link path for the object - * @return {Observable} - * Return an observable that emits created HREF - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig[]): Observable { - let endpoint$: Observable; - const args = []; - - endpoint$ = this.getBrowseEndpoint(options).pipe( - filter((href: string) => isNotEmpty(href)), - map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), - distinctUntilChanged() - ); - - return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); - } - - /** - * Create the HREF for a specific object's search method with given options object - * - * @param searchMethod The search method for the object - * @param options The [[FindListOptions]] object - * @return {Observable} - * Return an observable that emits created HREF - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig[]): Observable { - let result$: Observable; - const args = []; - - result$ = this.getSearchEndpoint(searchMethod); - - return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); - } - - /** - * Turn an options object into a query string and combine it with the given HREF - * - * @param href The HREF to which the query string should be appended - * @param options The [[FindListOptions]] object - * @param extraArgs Array with additional params to combine with query string - * @return {Observable} - * Return an observable that emits created HREF - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: FollowLinkConfig[]): string { - let args = [...extraArgs]; - - if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { - /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ - args = this.addHrefArg(href, args, `page=${options.currentPage - 1}`); - } - if (hasValue(options.elementsPerPage)) { - args = this.addHrefArg(href, args, `size=${options.elementsPerPage}`); - } - if (hasValue(options.sort)) { - args = this.addHrefArg(href, args, `sort=${options.sort.field},${options.sort.direction}`); - } - if (hasValue(options.startsWith)) { - args = this.addHrefArg(href, args, `startsWith=${options.startsWith}`); - } - if (hasValue(options.searchParams)) { - options.searchParams.forEach((param: RequestParam) => { - args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); - }); - } - args = this.addEmbedParams(href, args, ...linksToFollow); - if (isNotEmpty(args)) { - return new URLCombiner(href, `?${args.join('&')}`).toString(); - } else { - return href; - } - } - - /** - * Turn an array of RequestParam into a query string and combine it with the given HREF - * - * @param href The HREF to which the query string should be appended - * @param params Array with additional params to combine with query string - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - * - * @return {Observable} - * Return an observable that emits created HREF - */ - buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig[]): string { - - let args = []; - if (hasValue(params)) { - params.forEach((param: RequestParam) => { - args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); - }); - } - - args = this.addEmbedParams(href, args, ...linksToFollow); - - if (isNotEmpty(args)) { - return new URLCombiner(href, `?${args.join('&')}`).toString(); - } else { - return href; - } - } - - /** - * Adds the embed options to the link for the request - * @param href The href the params are to be added to - * @param args params for the query string - * @param linksToFollow links we want to embed in query string if shouldEmbed is true - */ - protected addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig[]) { - linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { - if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { - const embedString = 'embed=' + String(linkToFollow.name); - // Add the embeds size if given in the FollowLinkConfig.FindListOptions - if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { - args = this.addHrefArg(href, args, - 'embed.size=' + String(linkToFollow.name) + '=' + linkToFollow.findListOptions.elementsPerPage); - } - // Adds the nested embeds and their size if given - if (isNotEmpty(linkToFollow.linksToFollow)) { - args = this.addNestedEmbeds(embedString, href, args, ...linkToFollow.linksToFollow); - } else { - args = this.addHrefArg(href, args, embedString); - } - } - }); - return args; - } - - /** - * Add a new argument to the list of arguments, only if it doesn't already exist in the given href, - * or the current list of arguments - * - * @param href The href the arguments are to be added to - * @param currentArgs The current list of arguments - * @param newArg The new argument to add - * @return The next list of arguments, with newArg included if it wasn't already. - * Note this function will not modify any of the input params. - */ - protected addHrefArg(href: string, currentArgs: string[], newArg: string): string[] { - if (href.includes(newArg) || currentArgs.includes(newArg)) { - return [...currentArgs]; - } else { - return [...currentArgs, newArg]; - } - } - - /** - * Add the nested followLinks to the embed param, separated by a /, and their sizes, recursively - * @param embedString embedString so far (recursive) - * @param href The href the params are to be added to - * @param args params for the query string - * @param linksToFollow links we want to embed in query string if shouldEmbed is true - */ - protected addNestedEmbeds(embedString: string, href: string, args: string[], ...linksToFollow: FollowLinkConfig[]): string[] { - let nestEmbed = embedString; - linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { - if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) { - nestEmbed = nestEmbed + '/' + String(linkToFollow.name); - // Add the nested embeds size if given in the FollowLinkConfig.FindListOptions - if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) { - const nestedEmbedSize = 'embed.size=' + nestEmbed.split('=')[1] + '=' + linkToFollow.findListOptions.elementsPerPage; - args = this.addHrefArg(href, args, nestedEmbedSize); - } - if (hasValue(linkToFollow.linksToFollow) && isNotEmpty(linkToFollow.linksToFollow)) { - args = this.addNestedEmbeds(nestEmbed, href, args, ...linkToFollow.linksToFollow); - } else { - args = this.addHrefArg(href, args, nestEmbed); - } - } - }); - return args; - } - - /** - * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded - * info should be added to the objects - * - * @param options Find list options object - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - * @return {Observable>>} - * Return an observable that emits object list - */ - findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.findAllByHref(this.getFindAllHref(options), options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Create the HREF for a specific object based on its identifier; with possible embed query params based on linksToFollow - * @param endpoint The base endpoint for the type of object - * @param resourceID The identifier for the object - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { - return this.buildHrefFromFindOptions(endpoint + '/' + resourceID, {}, [], ...linksToFollow); - } - - /** - * Create an observable for the HREF of a specific object based on its identifier - * @param resourceID The identifier for the object - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - getIDHrefObs(resourceID: string, ...linksToFollow: FollowLinkConfig[]): Observable { - return this.getEndpoint().pipe( - map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow))); - } - - /** - * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of - * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object - * @param id ID of object we want to retrieve - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow); - return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * An operator that will call the given function if the incoming RemoteData is stale and - * shouldReRequest is true - * - * @param shouldReRequest Whether or not to call the re-request function if the RemoteData is stale - * @param requestFn The function to call if the RemoteData is stale and shouldReRequest is - * true - */ - protected reRequestStaleRemoteData(shouldReRequest: boolean, requestFn: () => Observable>) { - return (source: Observable>): Observable> => { - if (shouldReRequest === true) { - return source.pipe( - tap((remoteData: RemoteData) => { - if (hasValue(remoteData) && remoteData.isStale) { - requestFn(); - } - }) - ); - } else { - return source; - } - }; - } - - /** - * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of - * {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object - * @param href$ The url of object we want to retrieve. Can be a string or - * an Observable - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findByHref(href$: string | Observable, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - if (typeof href$ === 'string') { - href$ = observableOf(href$); - } - - const requestHref$ = href$.pipe( - isNotEmptyOperator(), - take(1), - map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)) - ); - - this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - - return this.rdbService.buildSingle(requestHref$, ...linksToFollow).pipe( - // This skip ensures that if a stale object is present in the cache when you do a - // call it isn't immediately returned, but we wait until the remote data for the new request - // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a - // cached completed object - skipWhile((rd: RemoteData) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), - this.reRequestStaleRemoteData(reRequestOnStale, () => - this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)) - ); - } - - /** - * Returns a list of observables of {@link RemoteData} of objects, based on an href, with a list - * of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object - * @param href$ The url of object we want to retrieve. Can be a string or - * an Observable - * @param findListOptions Find list options object - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findAllByHref(href$: string | Observable, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - if (typeof href$ === 'string') { - href$ = observableOf(href$); - } - - const requestHref$ = href$.pipe( - isNotEmptyOperator(), - take(1), - map((href: string) => this.buildHrefFromFindOptions(href, findListOptions, [], ...linksToFollow)) - ); - - this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable); - - return this.rdbService.buildList(requestHref$, ...linksToFollow).pipe( - // This skip ensures that if a stale object is present in the cache when you do a - // call it isn't immediately returned, but we wait until the remote data for the new request - // is created. If useCachedVersionIfAvailable is false it also ensures you don't get a - // cached completed object - skipWhile((rd: RemoteData>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted), - this.reRequestStaleRemoteData(reRequestOnStale, () => - this.findAllByHref(href$, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)) - ); - } - - /** - * Create a GET request for the given href, and send it. - * - * @param href$ The url of object we want to retrieve. Can be a string or - * an Observable - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - */ - protected createAndSendGetRequest(href$: string | Observable, useCachedVersionIfAvailable = true): void { - if (isNotEmpty(href$)) { - if (typeof href$ === 'string') { - href$ = observableOf(href$); - } - - href$.pipe( - isNotEmptyOperator(), - take(1) - ).subscribe((href: string) => { - const requestId = this.requestService.generateRequestId(); - const request = new GetRequest(requestId, href); - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } - this.requestService.send(request, useCachedVersionIfAvailable); - }); - } - } - - /** - * Return object search endpoint by given search method - * - * @param searchMethod The search method for the object - */ - protected getSearchEndpoint(searchMethod: string): Observable { - return this.halService.getEndpoint(this.linkPath).pipe( - filter((href: string) => isNotEmpty(href)), - map((href: string) => `${href}/search/${searchMethod}`)); - } - - /** - * Make a new FindListRequest with given search method - * - * @param searchMethod The search method for the object - * @param options The [[FindListOptions]] object - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - * @return {Observable>} - * Return an observable that emits response from the server - */ - searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); - - return this.findAllByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Send a patch request for a specified object - * @param {T} object The object to send a patch request for - * @param {Operation[]} operations The patch operations to be performed - */ - patch(object: T, operations: Operation[]): Observable> { - const requestId = this.requestService.generateRequestId(); - - const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getIDHref(endpoint, object.uuid))); - - hrefObs.pipe( - find((href: string) => hasValue(href)), - ).subscribe((href: string) => { - const request = new PatchRequest(requestId, href, operations); - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } - this.requestService.send(request); - }); - - return this.rdbService.buildFromRequestUUID(requestId); - } - - createPatchFromCache(object: T): Observable { - const oldVersion$ = this.findByHref(object._links.self.href, true, false); - return oldVersion$.pipe( - getFirstSucceededRemoteData(), - getRemoteDataPayload(), - map((oldVersion: T) => this.comparator.diff(oldVersion, object))); - } - - /** - * Send a PUT request for the specified object - * - * @param object The object to send a put request for. - */ - put(object: T): Observable> { - const requestId = this.requestService.generateRequestId(); - const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); - const request = new PutRequest(requestId, object._links.self.href, serializedObject); - - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } - - this.requestService.send(request); - - return this.rdbService.buildFromRequestUUID(requestId); - } - - /** - * Add a new patch to the object cache - * The patch is derived from the differences between the given object and its version in the object cache - * @param {DSpaceObject} object The given object - */ - update(object: T): Observable> { - return this.createPatchFromCache(object) - .pipe( - mergeMap((operations: Operation[]) => { - if (isNotEmpty(operations)) { - this.objectCache.addPatch(object._links.self.href, operations); - } - return this.findByHref(object._links.self.href, true, true); - } - ) - ); - } - - /** - * Create a new DSpaceObject on the server, and store the response - * in the object cache - * - * @param {CacheableObject} object - * The object to create - * @param {RequestParam[]} params - * Array with additional params to combine with query string - */ - create(object: T, ...params: RequestParam[]): Observable> { - const requestId = this.requestService.generateRequestId(); - const endpoint$ = this.getEndpoint().pipe( - isNotEmptyOperator(), - distinctUntilChanged(), - map((endpoint: string) => this.buildHrefWithParams(endpoint, params)) - ); - - const serializedObject = new DSpaceSerializer(getClassForType(object.type)).serialize(object); - - endpoint$.pipe( - take(1) - ).subscribe((endpoint: string) => { - const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedObject)); - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } - this.requestService.send(request); - }); - - const result$ = this.rdbService.buildFromRequestUUID(requestId); - - // TODO a dataservice is not the best place to show a notification, - // this should move up to the components that use this method - result$.pipe( - takeWhile((rd: RemoteData) => rd.isLoading, true) - ).subscribe((rd: RemoteData) => { - if (rd.hasFailed) { - this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1)); - } - }); - - return result$; - } - - /** - * Invalidate an existing DSpaceObject by marking all requests it is included in as stale - * @param objectId The id of the object to be invalidated - * @return An Observable that will emit `true` once all requests are stale - */ - invalidate(objectId: string): Observable { - return this.getIDHrefObs(objectId).pipe( - switchMap((href: string) => this.invalidateByHref(href)) - ); - } - - /** - * Invalidate an existing DSpaceObject by marking all requests it is included in as stale - * @param href The self link of the object to be invalidated - * @return An Observable that will emit `true` once all requests are stale - */ - invalidateByHref(href: string): Observable { - const done$ = new AsyncSubject(); - - this.objectCache.getByHref(href).pipe( - take(1), - switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe( - mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)), - toArray(), - )), - ).subscribe(() => { - done$.next(true); - done$.complete(); - }); - - return done$; - } - - /** - * Delete an existing DSpace Object on the server - * @param objectId The id of the object to be removed - * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual - * metadata should be saved as real metadata - * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, - * errorMessage, timeCompleted, etc - */ - delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { - return this.getIDHrefObs(objectId).pipe( - switchMap((href: string) => this.deleteByHref(href, copyVirtualMetadata)) - ); - } - - /** - * Delete an existing DSpace Object on the server - * @param href The self link of the object to be removed - * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual - * metadata should be saved as real metadata - * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, - * errorMessage, timeCompleted, etc - * Only emits once all request related to the DSO has been invalidated. - */ - deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { - const requestId = this.requestService.generateRequestId(); - - if (copyVirtualMetadata) { - copyVirtualMetadata.forEach((id) => - href += (href.includes('?') ? '&' : '?') - + 'copyVirtualMetadata=' - + id - ); - } - - const request = new DeleteRequest(requestId, href); - if (hasValue(this.responseMsToLive)) { - request.responseMsToLive = this.responseMsToLive; - } - this.requestService.send(request); - - const response$ = this.rdbService.buildFromRequestUUID(requestId); - - const invalidated$ = new AsyncSubject(); - response$.pipe( - getFirstCompletedRemoteData(), - switchMap((rd: RemoteData) => { - if (rd.hasSucceeded) { - return this.invalidateByHref(href); - } else { - return [true]; - } - }) - ).subscribe(() => { - invalidated$.next(true); - invalidated$.complete(); - }); - - return response$.pipe( - switchMap((rd: RemoteData) => { - if (rd.hasSucceeded) { - return invalidated$.pipe( - filter((invalidated: boolean) => invalidated), - map(() => rd) - ); - } else { - return [rd]; - } - }) - ); - } - - /** - * Commit current object changes to the server - * @param method The RestRequestMethod for which de server sync buffer should be committed - */ - commitUpdates(method?: RestRequestMethod) { - this.requestService.commit(method); - } - - /** - * Return the links to traverse from the root of the api to the - * endpoint this DataService represents - * - * e.g. if the api root links to 'foo', and the endpoint at 'foo' - * links to 'bar' the linkPath for the BarDataService would be - * 'foo/bar' - */ - getLinkPath(): string { - return this.linkPath; - } -} diff --git a/src/app/core/data/dso-redirect-data.service.ts b/src/app/core/data/dso-redirect-data.service.ts deleted file mode 100644 index 6270689f03..0000000000 --- a/src/app/core/data/dso-redirect-data.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; -import { hasValue } from '../../shared/empty.util'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RemoteData } from './remote-data'; -import { IdentifierType } from './request.models'; -import { RequestService } from './request.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { Item } from '../shared/item.model'; -import { getItemPageRoute } from '../../item-page/item-page-routing-paths'; -import { CoreState } from '../core-state.model'; - -@Injectable() -export class DsoRedirectDataService extends DataService { - - // Set the default link path to the identifier lookup endpoint. - protected linkPath = 'pid'; - private uuidEndpoint = 'dso'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, - private router: Router) { - super(); - } - - setLinkPath(identifierType: IdentifierType) { - // The default 'pid' endpoint for identifiers does not support uuid lookups. - // For uuid lookups we need to change the linkPath. - if (identifierType === IdentifierType.UUID) { - this.linkPath = this.uuidEndpoint; - } - } - - getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { - // Supporting both identifier (pid) and uuid (dso) endpoints - return this.buildHrefFromFindOptions( endpoint.replace(/\{\?id\}/, `?id=${resourceID}`) - .replace(/\{\?uuid\}/, `?uuid=${resourceID}`), - {}, [], ...linksToFollow); - } - - findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable> { - this.setLinkPath(identifierType); - return this.findById(id).pipe( - getFirstCompletedRemoteData(), - tap((response) => { - if (response.hasSucceeded) { - const dso = response.payload; - const uuid = dso.uuid; - if (hasValue(uuid)) { - let newRoute = this.getEndpointFromDSOType(response.payload.type); - if (dso.type.startsWith('item')) { - newRoute = getItemPageRoute(dso as Item); - } else if (hasValue(newRoute)) { - newRoute += '/' + uuid; - } - if (hasValue(newRoute)) { - this.router.navigate([newRoute]); - } - } - } - }) - ); - } - // Is there an existing method somewhere else that converts dso type to route? - getEndpointFromDSOType(dsoType: string): string { - // Are there other types to consider? - if (dsoType.startsWith('item')) { - return 'items'; - } else if (dsoType.startsWith('community')) { - return 'communities'; - } else if (dsoType.startsWith('collection')) { - return 'collections'; - } else { - return ''; - } - } -} diff --git a/src/app/core/data/dso-redirect-data.service.spec.ts b/src/app/core/data/dso-redirect.service.spec.ts similarity index 66% rename from src/app/core/data/dso-redirect-data.service.spec.ts rename to src/app/core/data/dso-redirect.service.spec.ts index 3f3a799e45..6bfd8dbd2e 100644 --- a/src/app/core/data/dso-redirect-data.service.spec.ts +++ b/src/app/core/data/dso-redirect.service.spec.ts @@ -1,22 +1,18 @@ -import { HttpClient } from '@angular/common/http'; -import { Store } from '@ngrx/store'; import { cold, getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DsoRedirectDataService } from './dso-redirect-data.service'; +import { DsoRedirectService } from './dso-redirect.service'; import { GetRequest, IdentifierType } from './request.models'; import { RequestService } from './request.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { Item } from '../shared/item.model'; -import { CoreState } from '../core-state.model'; -describe('DsoRedirectDataService', () => { +describe('DsoRedirectService', () => { let scheduler: TestScheduler; - let service: DsoRedirectDataService; + let service: DsoRedirectService; let halService: HALEndpointService; let requestService: RequestService; let rdbService: RemoteDataBuildService; @@ -29,10 +25,6 @@ describe('DsoRedirectDataService', () => { const requestHandleURL = `https://rest.api/rest/api/pid/find?id=${encodedHandle}`; const requestUUIDURL = `https://rest.api/rest/api/pid/find?id=${dsoUUID}`; const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2'; - const store = {} as Store; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; const objectCache = {} as ObjectCacheService; beforeEach(() => { @@ -59,20 +51,16 @@ describe('DsoRedirectDataService', () => { a: remoteData }) }); - service = new DsoRedirectDataService( + service = new DsoRedirectService( requestService, rdbService, - store, objectCache, halService, - notificationsService, - http, - comparator, - router + router, ); }); - describe('findById', () => { + describe('findByIdAndIDType', () => { it('should call HALEndpointService with the path to the pid endpoint', () => { scheduler.schedule(() => service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE)); scheduler.flush(); @@ -141,7 +129,7 @@ describe('DsoRedirectDataService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(router.navigate).toHaveBeenCalledWith([remoteData.payload.type + 's/' + remoteData.payload.uuid]); + expect(router.navigate).toHaveBeenCalledWith(['/collections/' + remoteData.payload.uuid]); }); it('should navigate to communities route', () => { @@ -150,55 +138,58 @@ describe('DsoRedirectDataService', () => { redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); - expect(router.navigate).toHaveBeenCalledWith(['communities/' + remoteData.payload.uuid]); + expect(router.navigate).toHaveBeenCalledWith(['/communities/' + remoteData.payload.uuid]); }); }); - describe('getIDHref', () => { - it('should return endpoint', () => { - const result = (service as any).getIDHref(pidLink, dsoUUID); - expect(result).toEqual(requestUUIDURL); - }); + describe('DataService', () => { // todo: should only test the id/uuid interpolation thingy + describe('getIDHref', () => { // todo: should be able to move this up to IdentifiableDataService? + it('should return endpoint', () => { + const result = (service as any).dataService.getIDHref(pidLink, dsoUUID); + expect(result).toEqual(requestUUIDURL); + }); - it('should include single linksToFollow as embed', () => { - const expected = `${requestUUIDURL}&embed=bundles`; - const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles')); - expect(result).toEqual(expected); - }); + it('should include single linksToFollow as embed', () => { + const expected = `${requestUUIDURL}&embed=bundles`; + const result = (service as any).dataService.getIDHref(pidLink, dsoUUID, followLink('bundles')); + expect(result).toEqual(expected); + }); - it('should include multiple linksToFollow as embed', () => { - const expected = `${requestUUIDURL}&embed=bundles&embed=owningCollection&embed=templateItemOf`; - const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); - expect(result).toEqual(expected); - }); + it('should include multiple linksToFollow as embed', () => { + const expected = `${requestUUIDURL}&embed=bundles&embed=owningCollection&embed=templateItemOf`; + const result = (service as any).dataService.getIDHref(pidLink, dsoUUID, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); + expect(result).toEqual(expected); + }); - it('should not include linksToFollow with shouldEmbed = false', () => { - const expected = `${requestUUIDURL}&embed=templateItemOf`; - const result = (service as any).getIDHref( - pidLink, - dsoUUID, - followLink('bundles', { shouldEmbed: false }), - followLink('owningCollection', { shouldEmbed: false }), - followLink('templateItemOf') - ); - expect(result).toEqual(expected); - }); + it('should not include linksToFollow with shouldEmbed = false', () => { + const expected = `${requestUUIDURL}&embed=templateItemOf`; + const result = (service as any).dataService.getIDHref( + pidLink, + dsoUUID, + followLink('bundles', { shouldEmbed: false }), + followLink('owningCollection', { shouldEmbed: false }), + followLink('templateItemOf'), + ); + expect(result).toEqual(expected); + }); - it('should include nested linksToFollow 3lvl', () => { - const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; - const result = (service as any).getIDHref( - pidLink, - dsoUUID, - followLink('owningCollection', - {}, - followLink('itemtemplate', + it('should include nested linksToFollow 3lvl', () => { + const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; + const result = (service as any).dataService.getIDHref( + pidLink, + dsoUUID, + followLink( + 'owningCollection', {}, - followLink('relationships') - ) - ) - ); - expect(result).toEqual(expected); + followLink( + 'itemtemplate', + {}, + followLink('relationships'), + ), + ), + ); + expect(result).toEqual(expected); + }); }); }); - }); diff --git a/src/app/core/data/dso-redirect.service.ts b/src/app/core/data/dso-redirect.service.ts new file mode 100644 index 0000000000..81ce678e43 --- /dev/null +++ b/src/app/core/data/dso-redirect.service.ts @@ -0,0 +1,104 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +/* eslint-disable max-classes-per-file */ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteData } from './remote-data'; +import { IdentifierType } from './request.models'; +import { RequestService } from './request.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { getDSORoute } from '../../app-routing-paths'; + +const ID_ENDPOINT = 'pid'; +const UUID_ENDPOINT = 'dso'; + +/** + * A data service to retrieve DSpaceObjects by persistent identifier or UUID. + * Doesn't define a constant {@link linkPath} but switches between two endpoints on demand: + * {@link setLinkPath} must be called before each request. + */ +class DsoByIdOrUUIDDataService extends IdentifiableDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + ) { + super( + undefined, requestService, rdbService, objectCache, halService, undefined, + // interpolate id/uuid as query parameter + (endpoint: string, resourceID: string): string => { + return endpoint.replace(/{\?id}/, `?id=${resourceID}`) + .replace(/{\?uuid}/, `?uuid=${resourceID}`); + }, + ); + } + + /** + * The default 'pid' endpoint for identifiers does not support uuid lookups. + * For uuid lookups we need to change the linkPath. + * @param identifierType + */ + setLinkPath(identifierType: IdentifierType) { + if (identifierType === IdentifierType.UUID) { + this.linkPath = UUID_ENDPOINT; + } else { + this.linkPath = ID_ENDPOINT; + } + } +} + +/** + * A service to handle redirects from identifier paths to DSO path + * e.g.: redirect from /handle/... to /items/... + */ +@Injectable() +export class DsoRedirectService { + private dataService: DsoByIdOrUUIDDataService; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + private router: Router, + ) { + this.dataService = new DsoByIdOrUUIDDataService(requestService, rdbService, objectCache, halService); + } + + /** + * Retrieve a DSpaceObject by + * @param id the identifier of the object to retrieve + * @param identifierType the type of the given identifier (defaults to UUID) + */ + findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable> { + this.dataService.setLinkPath(identifierType); + return this.dataService.findById(id).pipe( + getFirstCompletedRemoteData(), + tap((response) => { + if (response.hasSucceeded) { + const dso = response.payload; + if (hasValue(dso.uuid)) { + let newRoute = getDSORoute(dso); + if (hasValue(newRoute)) { + this.router.navigate([newRoute]); + } + } + } + }) + ); + } +} diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts index 4b3fafa73a..0f167ea47e 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -7,8 +7,6 @@ import { GetRequest } from './request.models'; import { RequestService } from './request.service'; import { DSpaceObjectDataService } from './dspace-object-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; describe('DSpaceObjectDataService', () => { let scheduler: TestScheduler; @@ -42,18 +40,12 @@ describe('DSpaceObjectDataService', () => { }) }); objectCache = {} as ObjectCacheService; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; service = new DSpaceObjectDataService( requestService, rdbService, objectCache, halService, - notificationsService, - http, - comparator ); }); diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index ae0d525281..2ad024133c 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -1,106 +1,28 @@ -/* eslint-disable max-classes-per-file */ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { DSPACE_OBJECT } from '../shared/dspace-object.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { PaginatedList } from './paginated-list.model'; -import { CoreState } from '../core-state.model'; -import { FindListOptions } from './find-list-options.model'; - -class DataServiceImpl extends DataService { - protected linkPath = 'dso'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { - super(); - } - - getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig[]): string { - return this.buildHrefFromFindOptions( endpoint.replace(/\{\?uuid\}/, `?uuid=${resourceID}`), - {}, [], ...linksToFollow); - } -} +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { dataService } from './base/data-service.decorator'; @Injectable() @dataService(DSPACE_OBJECT) -export class DSpaceObjectDataService { - protected linkPath = 'dso'; - private dataService: DataServiceImpl; - +export class DSpaceObjectDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + ) { + super( + 'dso', requestService, rdbService, objectCache, halService, undefined, + // interpolate uuid as query parameter + (endpoint: string, resourceID: string): string => { + return endpoint.replace(/{\?uuid}/, `?uuid=${resourceID}`); + }, + ); } - - /** - * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the object - * @param id ID of object we want to retrieve - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - - } - /** - * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the object - * @param href The url of object we want to retrieve - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Returns a list of observables of {@link RemoteData} of objects, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the object - * @param href The url of object we want to retrieve - * @param findListOptions Find list options object - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - } diff --git a/src/app/core/data/entity-type-data.service.spec.ts b/src/app/core/data/entity-type-data.service.spec.ts new file mode 100644 index 0000000000..cf164adb84 --- /dev/null +++ b/src/app/core/data/entity-type-data.service.spec.ts @@ -0,0 +1,19 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { EntityTypeDataService } from './entity-type-data.service'; + +describe('EntityTypeDataService', () => { + describe('composition', () => { + const initService = () => new EntityTypeDataService(null, null, null, null, null); + testFindAllDataImplementation(initService); + testSearchDataImplementation(initService); + }); +}); diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type-data.service.ts similarity index 59% rename from src/app/core/data/entity-type.service.ts rename to src/app/core/data/entity-type-data.service.ts index d08e6d28e7..4020ff638d 100644 --- a/src/app/core/data/entity-type.service.ts +++ b/src/app/core/data/entity-type-data.service.ts @@ -1,13 +1,8 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { DataService } from './data.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; @@ -16,28 +11,31 @@ import { RelationshipType } from '../shared/item-relationships/relationship-type import { PaginatedList } from './paginated-list.model'; import { ItemType } from '../shared/item-relationships/item-type.model'; import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; -import { RelationshipTypeService } from './relationship-type.service'; -import { CoreState } from '../core-state.model'; +import { RelationshipTypeDataService } from './relationship-type-data.service'; import { FindListOptions } from './find-list-options.model'; +import { BaseDataService } from './base/base-data.service'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; /** * Service handling all ItemType requests */ @Injectable() -export class EntityTypeService extends DataService { +export class EntityTypeDataService extends BaseDataService implements FindAllData, SearchData { + private findAllData: FindAllData; + private searchData: SearchDataImpl; - protected linkPath = 'entitytypes'; + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected relationshipTypeService: RelationshipTypeDataService, + ) { + super('entitytypes', requestService, rdbService, objectCache, halService); - constructor(protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected halService: HALEndpointService, - protected objectCache: ObjectCacheService, - protected notificationsService: NotificationsService, - protected relationshipTypeService: RelationshipTypeService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } getBrowseEndpoint(options, linkPath?: string): Observable { @@ -147,7 +145,7 @@ export class EntityTypeService extends DataService { */ getEntityTypeRelationships(entityTypeId: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { const href$ = this.getRelationshipTypesEndpoint(entityTypeId); - return this.relationshipTypeService.findAllByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.relationshipTypeService.findListByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -158,7 +156,43 @@ export class EntityTypeService extends DataService { return this.halService.getEndpoint(this.linkPath).pipe( take(1), switchMap((endPoint: string) => - this.findByHref(endPoint + '/label/' + label)) + this.findByHref(endPoint + '/label/' + label)), ); } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 989a401733..3b033f693a 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -13,11 +13,9 @@ import { RegistrationResponseParsingService } from './registration-response-pars import { RemoteData } from './remote-data'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -@Injectable( - { - providedIn: 'root', - } -) +@Injectable({ + providedIn: 'root', +}) /** * Service that will register a new email address and request a token */ diff --git a/src/app/core/data/external-source.service.spec.ts b/src/app/core/data/external-source-data.service.spec.ts similarity index 80% rename from src/app/core/data/external-source.service.spec.ts rename to src/app/core/data/external-source-data.service.spec.ts index 59226197d1..cdbdbaa006 100644 --- a/src/app/core/data/external-source.service.spec.ts +++ b/src/app/core/data/external-source-data.service.spec.ts @@ -1,12 +1,13 @@ -import { ExternalSourceService } from './external-source.service'; +import { ExternalSourceDataService } from './external-source-data.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { of as observableOf } from 'rxjs'; import { GetRequest } from './request.models'; +import { testSearchDataImplementation } from './base/search-data.spec'; describe('ExternalSourceService', () => { - let service: ExternalSourceService; + let service: ExternalSourceDataService; let requestService; let rdbService; @@ -48,15 +49,20 @@ describe('ExternalSourceService', () => { buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries)) }); halService = jasmine.createSpyObj('halService', { - getEndpoint: observableOf('external-sources-REST-endpoint') + getEndpoint: observableOf('external-sources-REST-endpoint'), }); - service = new ExternalSourceService(requestService, rdbService, undefined, undefined, halService, undefined, undefined, undefined); + service = new ExternalSourceDataService(requestService, rdbService, undefined, halService); } beforeEach(() => { init(); }); + describe('composition', () => { + const initService = () => new ExternalSourceDataService(null, null, null, null); + testSearchDataImplementation(initService); + }); + describe('getExternalSourceEntries', () => { let result; diff --git a/src/app/core/data/external-source.service.ts b/src/app/core/data/external-source-data.service.ts similarity index 63% rename from src/app/core/data/external-source.service.ts rename to src/app/core/data/external-source-data.service.ts index 6ea7e07d28..c0552aeaec 100644 --- a/src/app/core/data/external-source.service.ts +++ b/src/app/core/data/external-source-data.service.ts @@ -1,13 +1,9 @@ import { Injectable } from '@angular/core'; -import { DataService } from './data.service'; import { ExternalSource } from '../shared/external-source.model'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; @@ -15,28 +11,27 @@ import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { SearchData, SearchDataImpl } from './base/search-data'; /** * A service handling all external source requests */ @Injectable() -export class ExternalSourceService extends DataService { - protected linkPath = 'externalsources'; +export class ExternalSourceDataService extends IdentifiableDataService implements SearchData { + private searchData: SearchData; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + ) { + super('externalsources', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -75,10 +70,28 @@ export class ExternalSourceService extends DataService { isNotEmptyOperator(), distinctUntilChanged(), map((endpoint: string) => hasValue(searchOptions) ? searchOptions.toRestUrl(endpoint) : endpoint), - take(1) + take(1), ); // TODO create a dedicated ExternalSourceEntryDataService and move this entire method to it. Then the "as any"s won't be necessary - return this.findAllByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as any) as any; + return this.findListByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as any) as any; + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } } diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts index df46d3f0a1..ff9b542e44 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts @@ -1,6 +1,5 @@ import { AuthorizationDataService } from './authorization-data.service'; import { SiteDataService } from '../site-data.service'; -import { AuthService } from '../../auth/auth.service'; import { Site } from '../../shared/site.model'; import { EPerson } from '../../eperson/models/eperson.model'; import { of as observableOf } from 'rxjs'; @@ -12,11 +11,11 @@ import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from import { createPaginatedList } from '../../../shared/testing/utils.test'; import { Feature } from '../../shared/feature.model'; import { FindListOptions } from '../find-list-options.model'; +import { testSearchDataImplementation } from '../base/search-data.spec'; describe('AuthorizationDataService', () => { let service: AuthorizationDataService; let siteService: SiteDataService; - let authService: AuthService; let site: Site; let ePerson: EPerson; @@ -37,13 +36,9 @@ describe('AuthorizationDataService', () => { uuid: 'test-eperson' }); siteService = jasmine.createSpyObj('siteService', { - find: observableOf(site) + find: observableOf(site), }); - authService = { - isAuthenticated: () => observableOf(true), - getAuthenticatedUserFromStore: () => observableOf(ePerson) - } as AuthService; - service = new AuthorizationDataService(requestService, undefined, undefined, undefined, undefined, undefined, undefined, undefined, authService, siteService); + service = new AuthorizationDataService(requestService, undefined, undefined, undefined, siteService); } beforeEach(() => { @@ -51,6 +46,11 @@ describe('AuthorizationDataService', () => { spyOn(service, 'searchBy').and.returnValue(observableOf(undefined)); }); + describe('composition', () => { + const initService = () => new AuthorizationDataService(null, null, null, null, null); + testSearchDataImplementation(initService); + }); + it('should call setStaleByHrefSubstring method', () => { service.invalidateAuthorizationsRequestCache(); expect((service as any).requestService.setStaleByHrefSubstring).toHaveBeenCalledWith((service as any).linkPath); diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index f27919844d..a9aded4bfe 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -1,18 +1,11 @@ import { Observable, of as observableOf } from 'rxjs'; import { Injectable } from '@angular/core'; import { AUTHORIZATION } from '../../shared/authorization.resource-type'; -import { dataService } from '../../cache/builders/build-decorators'; -import { DataService } from '../data.service'; import { Authorization } from '../../shared/authorization.model'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; -import { AuthService } from '../../auth/auth.service'; import { SiteDataService } from '../site-data.service'; import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { RemoteData } from '../remote-data'; @@ -24,31 +17,32 @@ import { AuthorizationSearchParams } from './authorization-search-params'; import { addSiteObjectUrlIfEmpty, oneAuthorizationMatchesFeature } from './authorization-utils'; import { FeatureID } from './feature-id'; import { getFirstCompletedRemoteData } from '../../shared/operators'; -import { CoreState } from '../../core-state.model'; import { FindListOptions } from '../find-list-options.model'; +import { BaseDataService } from '../base/base-data.service'; +import { SearchData, SearchDataImpl } from '../base/search-data'; +import { dataService } from '../base/data-service.decorator'; /** * A service to retrieve {@link Authorization}s from the REST API */ @Injectable() @dataService(AUTHORIZATION) -export class AuthorizationDataService extends DataService { +export class AuthorizationDataService extends BaseDataService implements SearchData { protected linkPath = 'authorizations'; protected searchByObjectPath = 'object'; + private searchData: SearchDataImpl; + constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, - protected authService: AuthService, - protected siteService: SiteDataService + protected siteService: SiteDataService, ) { - super(); + super('authorizations', requestService, rdbService, objectCache, halService); + + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -130,7 +124,25 @@ export class AuthorizationDataService extends DataService { params.push(new RequestParam('eperson', ePersonUuid)); } return Object.assign(new FindListOptions(), options, { - searchParams: [...params] + searchParams: [...params], }); } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } } diff --git a/src/app/core/data/feature-authorization/feature-data.service.ts b/src/app/core/data/feature-authorization/feature-data.service.ts index cbe8356660..eda8791153 100644 --- a/src/app/core/data/feature-authorization/feature-data.service.ts +++ b/src/app/core/data/feature-authorization/feature-data.service.ts @@ -1,36 +1,27 @@ import { Injectable } from '@angular/core'; import { FEATURE } from '../../shared/feature.resource-type'; -import { dataService } from '../../cache/builders/build-decorators'; -import { DataService } from '../data.service'; import { Feature } from '../../shared/feature.model'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DSOChangeAnalyzer } from '../dso-change-analyzer.service'; -import { CoreState } from '../../core-state.model'; +import { BaseDataService } from '../base/base-data.service'; +import { dataService } from '../base/data-service.decorator'; /** * A service to retrieve {@link Feature}s from the REST API */ @Injectable() @dataService(FEATURE) -export class FeatureDataService extends DataService { +export class FeatureDataService extends BaseDataService { protected linkPath = 'features'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer ) { - super(); + super('features', requestService, rdbService, objectCache, halService); } } diff --git a/src/app/core/data/href-only-data.service.spec.ts b/src/app/core/data/href-only-data.service.spec.ts index 64c451837d..bf7d2890ea 100644 --- a/src/app/core/data/href-only-data.service.spec.ts +++ b/src/app/core/data/href-only-data.service.spec.ts @@ -1,8 +1,8 @@ import { HrefOnlyDataService } from './href-only-data.service'; import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { DataService } from './data.service'; import { FindListOptions } from './find-list-options.model'; +import { BaseDataService } from './base/base-data.service'; describe(`HrefOnlyDataService`, () => { let service: HrefOnlyDataService; @@ -15,12 +15,12 @@ describe(`HrefOnlyDataService`, () => { href = 'https://rest.api/server/api/core/items/de7fa215-4a25-43a7-a4d7-17534a09fdfc'; followLinks = [ followLink('link1'), followLink('link2') ]; findListOptions = new FindListOptions(); - service = new HrefOnlyDataService(null, null, null, null, null, null, null, null); + service = new HrefOnlyDataService(null, null, null, null); }); it(`should instantiate a private DataService`, () => { expect((service as any).dataService).toBeDefined(); - expect((service as any).dataService).toBeInstanceOf(DataService); + expect((service as any).dataService).toBeInstanceOf(BaseDataService); }); describe(`findByHref`, () => { @@ -28,7 +28,7 @@ describe(`HrefOnlyDataService`, () => { spy = spyOn((service as any).dataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); }); - it(`should delegate to findByHref on the internal DataService`, () => { + it(`should forward to findByHref on the internal DataService`, () => { service.findByHref(href, false, false, ...followLinks); expect(spy).toHaveBeenCalledWith(href, false, false, ...followLinks); }); @@ -48,33 +48,33 @@ describe(`HrefOnlyDataService`, () => { }); }); - describe(`findAllByHref`, () => { + describe(`findListByHref`, () => { beforeEach(() => { - spy = spyOn((service as any).dataService, 'findAllByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); + spy = spyOn((service as any).dataService, 'findListByHref').and.returnValue(createSuccessfulRemoteDataObject$(null)); }); - it(`should delegate to findAllByHref on the internal DataService`, () => { - service.findAllByHref(href, findListOptions, false, false, ...followLinks); + it(`should delegate to findListByHref on the internal DataService`, () => { + service.findListByHref(href, findListOptions, false, false, ...followLinks); expect(spy).toHaveBeenCalledWith(href, findListOptions, false, false, ...followLinks); }); describe(`when findListOptions is omitted`, () => { - it(`should call findAllByHref on the internal DataService with findListOptions = {}`, () => { - service.findAllByHref(href); + it(`should call findListByHref on the internal DataService with findListOptions = {}`, () => { + service.findListByHref(href); expect(spy).toHaveBeenCalledWith(jasmine.anything(), {}, jasmine.anything(), jasmine.anything()); }); }); describe(`when useCachedVersionIfAvailable is omitted`, () => { - it(`should call findAllByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { - service.findAllByHref(href); + it(`should call findListByHref on the internal DataService with useCachedVersionIfAvailable = true`, () => { + service.findListByHref(href); expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), true, jasmine.anything()); }); }); describe(`when reRequestOnStale is omitted`, () => { - it(`should call findAllByHref on the internal DataService with reRequestOnStale = true`, () => { - service.findAllByHref(href); + it(`should call findListByHref on the internal DataService with reRequestOnStale = true`, () => { + service.findListByHref(href); expect(spy).toHaveBeenCalledWith(jasmine.anything(), jasmine.anything(), jasmine.anything(), true); }); }); diff --git a/src/app/core/data/href-only-data.service.ts b/src/app/core/data/href-only-data.service.ts index 60c225cb34..0a765de101 100644 --- a/src/app/core/data/href-only-data.service.ts +++ b/src/app/core/data/href-only-data.service.ts @@ -1,42 +1,20 @@ -/* eslint-disable max-classes-per-file */ -import { DataService } from './data.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { Injectable } from '@angular/core'; import { VOCABULARY_ENTRY } from '../submission/vocabularies/models/vocabularies.resource-type'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteData } from './remote-data'; import { Observable } from 'rxjs'; import { PaginatedList } from './paginated-list.model'; import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type'; import { LICENSE } from '../shared/license.resource-type'; import { CacheableObject } from '../cache/cacheable-object.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; - -class DataServiceImpl extends DataService { - // linkPath isn't used if we're only searching by href. - protected linkPath = undefined; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); - } -} +import { BaseDataService } from './base/base-data.service'; +import { HALDataService } from './base/hal-data-service.interface'; +import { dataService } from './base/data-service.decorator'; /** * A DataService with only findByHref methods. Its purpose is to be used for resources that don't @@ -44,26 +22,37 @@ class DataServiceImpl extends DataService { * for their links to be resolved by the LinkService. * * an @dataService annotation can be added for any number of these resource types + * + * + * Additionally, this service may be used to retrieve objects by `href` regardless of their type + * For example + * ``` + * const items$: Observable>> = hrefOnlyDataService.findListByHref(href); + * const sites$: Observable>> = hrefOnlyDataService.findListByHref(href); + * ``` + * This means we cannot extend from {@link BaseDataService} directly because the method signatures would not match. */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) @dataService(VOCABULARY_ENTRY) @dataService(ITEM_TYPE) @dataService(LICENSE) -export class HrefOnlyDataService { - private dataService: DataServiceImpl; +export class HrefOnlyDataService implements HALDataService { + /** + * Works with a {@link BaseDataService} internally, but only exposes two of its methods + * with altered signatures to (optionally) constrain the arbitrary return type. + * @private + */ + private dataService: BaseDataService; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, store, objectCache, halService, notificationsService, http, comparator); + ) { + this.dataService = new BaseDataService(undefined, requestService, rdbService, objectCache, halService); } /** @@ -93,7 +82,7 @@ export class HrefOnlyDataService { * @param linksToFollow List of {@link FollowLinkConfig} that indicate which * {@link HALLink}s should be automatically resolved */ - findAllByHref(href: string | Observable, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { - return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + findListByHref(href: string | Observable, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.dataService.findListByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } } diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index a4ed9f882f..2c20ed0fb6 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -10,18 +10,21 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { ItemDataService } from './item-data.service'; -import { DeleteRequest, GetRequest, PostRequest } from './request.models'; +import { DeleteRequest, PostRequest } from './request.models'; import { RequestService } from './request.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { CoreState } from '../core-state.model'; import { RequestEntry } from './request-entry.model'; import { FindListOptions } from './find-list-options.model'; import { HALEndpointServiceStub } from 'src/app/shared/testing/hal-endpoint-service.stub'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; describe('ItemDataService', () => { let scheduler: TestScheduler; let service: ItemDataService; - let bs: BrowseService; + let browseService: BrowseService; const requestService = Object.assign(getMockRequestService(), { generateRequestId(): string { return scopeID; @@ -78,24 +81,29 @@ describe('ItemDataService', () => { return new ItemDataService( requestService, rdbService, - store, - bs, objectCache, halEndpointService, notificationsService, - http, comparator, - bundleService + browseService, + bundleService, ); } + describe('composition', () => { + const initService = () => new ItemDataService(null, null, null, null, null, null, null, null); + testCreateDataImplementation(initService); + testPatchDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('getBrowseEndpoint', () => { beforeEach(() => { scheduler = getTestScheduler(); }); it('should return the endpoint to fetch Items within the given scope and starting with the given string', () => { - bs = initMockBrowseService(true); + browseService = initMockBrowseService(true); service = initTestService(); const result = service.getBrowseEndpoint(options); @@ -106,7 +114,7 @@ describe('ItemDataService', () => { describe('if the dc.date.issue browse isn\'t configured for items', () => { beforeEach(() => { - bs = initMockBrowseService(false); + browseService = initMockBrowseService(false); service = initTestService(); }); it('should throw an error', () => { diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index cb5d7a3d57..09268a0282 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,12 +1,18 @@ -import { HttpClient, HttpHeaders } from '@angular/common/http'; +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +/* eslint-disable max-classes-per-file */ +import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { BrowseService } from '../browse/browse.service'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; @@ -16,12 +22,10 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; import { ITEM } from '../shared/item.resource-type'; import { URLCombiner } from '../url-combiner/url-combiner'; - -import { DataService } from './data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; -import { DeleteRequest, GetRequest, PostRequest, PutRequest} from './request.models'; +import { DeleteRequest, GetRequest, PostRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { Bundle } from '../shared/bundle.model'; @@ -34,27 +38,42 @@ import { ResponseParsingService } from './parsing.service'; import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service'; import { sendRequest } from '../shared/request.operators'; import { RestRequest } from './rest-request.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { ConstructIdEndpoint, IdentifiableDataService } from './base/identifiable-data.service'; +import { PatchData, PatchDataImpl } from './base/patch-data'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { RestRequestMethod } from './rest-request-method'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { RequestParam } from '../cache/models/request-param.model'; +import { dataService } from './base/data-service.decorator'; -@Injectable() -@dataService(ITEM) -export class ItemDataService extends DataService { - protected linkPath = 'items'; +/** + * An abstract service for CRUD operations on Items + * Doesn't specify an endpoint because multiple endpoints support Item-like functionality (e.g. items, itemtemplates) + * Extend this class to implement data services for Items + */ +export abstract class BaseItemDataService extends IdentifiableDataService implements CreateData, PatchData, DeleteData { + private createData: CreateData; + private patchData: PatchData; + private deleteData: DeleteData; - constructor( + protected constructor( + protected linkPath, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, protected comparator: DSOChangeAnalyzer, - protected bundleService: BundleDataService + protected browseService: BrowseService, + protected bundleService: BundleDataService, + protected constructIdEndpoint: ConstructIdEndpoint = (endpoint, resourceID) => `${endpoint}/${resourceID}`, ) { - super(); + super(linkPath, requestService, rdbService, objectCache, halService, undefined, constructIdEndpoint); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.patchData = new PatchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -69,10 +88,11 @@ export class ItemDataService extends DataService { if (options.sort && options.sort.field) { field = options.sort.field; } - return this.bs.getBrowseURLFor(field, linkPath).pipe( + return this.browseService.getBrowseURLFor(field, linkPath).pipe( filter((href: string) => isNotEmpty(href)), map((href: string) => new URLCombiner(href, `?scope=${options.scopeID}`).toString()), - distinctUntilChanged(),); + distinctUntilChanged(), + ); } /** @@ -84,7 +104,7 @@ export class ItemDataService extends DataService { public getMappedCollectionsEndpoint(itemId: string, collectionId?: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, itemId)), - map((endpoint: string) => `${endpoint}/mappedCollections${collectionId ? `/${collectionId}` : ''}`) + map((endpoint: string) => `${endpoint}/mappedCollections${collectionId ? `/${collectionId}` : ''}`), ); } @@ -219,7 +239,7 @@ export class ItemDataService extends DataService { public getMoveItemEndpoint(itemId: string): Observable { return this.halService.getEndpoint(this.linkPath).pipe( map((endpoint: string) => this.getIDHref(endpoint, itemId)), - map((endpoint: string) => `${endpoint}/owningCollection`) + map((endpoint: string) => `${endpoint}/owningCollection`), ); } @@ -299,4 +319,93 @@ export class ItemDataService extends DataService { this.requestService.setStaleByHrefSubstring('item/' + itemUUID); } + /** + * Commit current object changes to the server + * @param method The RestRequestMethod for which de server sync buffer should be committed + */ + public commitUpdates(method?: RestRequestMethod): void { + this.patchData.commitUpdates(method); + } + + /** + * Send a patch request for a specified object + * @param {T} object The object to send a patch request for + * @param {Operation[]} operations The patch operations to be performed + */ + public patch(object: Item, operations: Operation[]): Observable> { + return this.patchData.patch(object, operations); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + public update(object: Item): Observable> { + return this.patchData.update(object); + } + + /** + * Return a list of operations representing the difference between an object and its latest value in the cache. + * @param object the object to resolve to a list of patch operations + */ + public createPatchFromCache(object: Item): Observable { + return this.patchData.createPatchFromCache(object); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + /** + * Create a new object on the server, and store the response in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + public create(object: Item, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + +} + +/** + * A service for CRUD operations on Items + */ +@Injectable() +@dataService(ITEM) +export class ItemDataService extends BaseItemDataService { + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected comparator: DSOChangeAnalyzer, + protected browseService: BrowseService, + protected bundleService: BundleDataService, + ) { + super('items', requestService, rdbService, objectCache, halService, notificationsService, comparator, browseService, bundleService); + } } diff --git a/src/app/core/data/item-request-data.service.spec.ts b/src/app/core/data/item-request-data.service.spec.ts index 0d99ca5cd4..a5d1872510 100644 --- a/src/app/core/data/item-request-data.service.spec.ts +++ b/src/app/core/data/item-request-data.service.spec.ts @@ -35,7 +35,7 @@ describe('ItemRequestDataService', () => { getEndpoint: observableOf(restApiEndpoint), }); - service = new ItemRequestDataService(requestService, rdbService, null, null, halService, null, null, null); + service = new ItemRequestDataService(requestService, rdbService, null, halService); }); describe('requestACopy', () => { diff --git a/src/app/core/data/item-request-data.service.ts b/src/app/core/data/item-request-data.service.ts index 2bab0b304f..ff6025f7ac 100644 --- a/src/app/core/data/item-request-data.service.ts +++ b/src/app/core/data/item-request-data.service.ts @@ -9,40 +9,27 @@ import { PostRequest, PutRequest } from './request.models'; import { RequestService } from './request.service'; import { ItemRequest } from '../shared/item-request.model'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { DataService } from './data.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { HttpHeaders } from '@angular/common/http'; import { RequestCopyEmail } from '../../request-copy/email-request-copy/request-copy-email.model'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { CoreState } from '../core-state.model'; import { sendRequest } from '../shared/request.operators'; +import { IdentifiableDataService } from './base/identifiable-data.service'; /** * A service responsible for fetching/sending data from/to the REST API on the itemrequests endpoint */ -@Injectable( - { - providedIn: 'root', - } -) -export class ItemRequestDataService extends DataService { - - protected linkPath = 'itemrequests'; - +@Injectable({ + providedIn: 'root', +}) +export class ItemRequestDataService extends IdentifiableDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer, ) { - super(); + super('itemrequests', requestService, rdbService, objectCache, halService); } getItemRequestEndpoint(): Observable { @@ -124,9 +111,9 @@ export class ItemRequestDataService extends DataService { suggestOpenAccess, }), options); }), - sendRequest(this.requestService)).subscribe(); + sendRequest(this.requestService), + ).subscribe(); return this.rdbService.buildFromRequestUUID(requestId); } - } diff --git a/src/app/core/data/item-template-data.service.spec.ts b/src/app/core/data/item-template-data.service.spec.ts index 4b8aa362ba..16cf0dbd99 100644 --- a/src/app/core/data/item-template-data.service.spec.ts +++ b/src/app/core/data/item-template-data.service.spec.ts @@ -8,17 +8,20 @@ import { BrowseService } from '../browse/browse.service'; import { cold } from 'jasmine-marbles'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { CollectionDataService } from './collection-data.service'; import { RestRequestMethod } from './rest-request-method'; import { Item } from '../shared/item.model'; import { RestRequest } from './rest-request.model'; import { CoreState } from '../core-state.model'; import { RequestEntry } from './request-entry.model'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testPatchDataImplementation } from './base/patch-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; +import createSpyObj = jasmine.createSpyObj; describe('ItemTemplateDataService', () => { let service: ItemTemplateDataService; - let itemService: any; + let byCollection: any; const item = new Item(); const collectionEndpoint = 'https://rest.api/core/collections/4af28e99-6a9c-4036-a199-e1b587046d39'; @@ -47,14 +50,14 @@ describe('ItemTemplateDataService', () => { } as RequestService; const rdbService = {} as RemoteDataBuildService; const store = {} as Store; - const bs = {} as BrowseService; + const browseService = {} as BrowseService; const objectCache = { getObjectBySelfLink(self) { return observableOf({}); }, addPatch(self, operations) { // Do nothing - } + }, } as any; const halEndpointService = { getEndpoint(linkPath: string): Observable { @@ -62,7 +65,6 @@ describe('ItemTemplateDataService', () => { } } as HALEndpointService; const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; const comparator = { diff(first, second) { return [{}]; @@ -78,60 +80,68 @@ describe('ItemTemplateDataService', () => { service = new ItemTemplateDataService( requestService, rdbService, - store, - bs, objectCache, halEndpointService, notificationsService, - http, comparator, + browseService, undefined, - collectionService + collectionService, ); - itemService = (service as any).dataService; + byCollection = (service as any).byCollection; } beforeEach(() => { initTestService(); }); - describe('commitUpdates', () => { - it('should call commitUpdates on the item service implementation', () => { - spyOn(itemService, 'commitUpdates'); - service.commitUpdates(); - expect(itemService.commitUpdates).toHaveBeenCalled(); - }); - }); - - describe('update', () => { - it('should call update on the item service implementation', () => { - spyOn(itemService, 'update'); - service.update(item); - expect(itemService.update).toHaveBeenCalled(); - }); + describe('composition', () => { + const initService = () => new ItemTemplateDataService(null, null, null, null, null, null, null, null, null); + testCreateDataImplementation(initService); + testPatchDataImplementation(initService); + testDeleteDataImplementation(initService); }); describe('findByCollectionID', () => { - it('should call findByCollectionID on the item service implementation', () => { - spyOn(itemService, 'findByCollectionID'); + it('should call findByCollectionID on the collection-based data service', () => { + spyOn(byCollection, 'findById'); service.findByCollectionID(scopeID); - expect(itemService.findByCollectionID).toHaveBeenCalled(); + expect(byCollection.findById).toHaveBeenCalled(); }); }); - describe('create', () => { - it('should call createTemplate on the item service implementation', () => { - spyOn(itemService, 'createTemplate'); - service.create(item, scopeID); - expect(itemService.createTemplate).toHaveBeenCalled(); + describe('createByCollectionID', () => { + it('should call createTemplate on the collection-based data service', () => { + spyOn(byCollection, 'createTemplate'); + service.createByCollectionID(item, scopeID); + expect(byCollection.createTemplate).toHaveBeenCalledWith(item, scopeID); }); }); - describe('deleteByCollectionID', () => { - it('should call deleteByCollectionID on the item service implementation', () => { - spyOn(itemService, 'deleteByCollectionID'); - service.deleteByCollectionID(item, scopeID); - expect(itemService.deleteByCollectionID).toHaveBeenCalled(); + describe('byCollection', () => { + beforeEach(() => { + byCollection.createData = createSpyObj('createData', { + createOnEndpoint: 'TEST createOnEndpoint', + }); + }); + + describe('getIDHrefObs', () => { + it('should point to the Item template of a given Collection', () => { + expect(byCollection.getIDHrefObs(scopeID)).toBeObservable(cold('a', { a: jasmine.stringMatching(`/collections/${scopeID}/itemtemplate`) })); + }); + }); + + describe('createTemplate', () => { + it('should forward to CreateDataImpl.createOnEndpoint', () => { + spyOn(byCollection, 'getIDHrefObs').and.returnValue('TEST getIDHrefObs'); + + const out = byCollection.createTemplate(item, scopeID); + + expect(byCollection.getIDHrefObs).toHaveBeenCalledWith(scopeID); + expect(byCollection.createData.createOnEndpoint).toHaveBeenCalledWith(item, 'TEST getIDHrefObs'); + expect(out).toBe('TEST createOnEndpoint'); + }); }); }); }); + diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts index fd9f7de031..634c966dba 100644 --- a/src/app/core/data/item-template-data.service.ts +++ b/src/app/core/data/item-template-data.service.ts @@ -1,147 +1,62 @@ /* eslint-disable max-classes-per-file */ import { Injectable } from '@angular/core'; -import { ItemDataService } from './item-data.service'; -import { UpdateDataService } from './update-data.service'; +import { BaseItemDataService } from './item-data.service'; import { Item } from '../shared/item.model'; -import { RestRequestMethod } from './rest-request-method'; import { RemoteData } from './remote-data'; import { Observable } from 'rxjs'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; import { BrowseService } from '../browse/browse.service'; import { CollectionDataService } from './collection-data.service'; -import { map, switchMap } from 'rxjs/operators'; +import { switchMap } from 'rxjs/operators'; import { BundleDataService } from './bundle-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { NoContent } from '../shared/NoContent.model'; -import { hasValue } from '../../shared/empty.util'; -import { Operation } from 'fast-json-patch'; -import { getFirstCompletedRemoteData } from '../shared/operators'; -import { CoreState } from '../core-state.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { CreateDataImpl } from './base/create-data'; /** - * A custom implementation of the ItemDataService, but for collection item templates - * Makes sure to change the endpoint before sending out CRUD requests for the item template + * Data service for interacting with Item templates via their Collection */ -class DataServiceImpl extends ItemDataService { - protected collectionLinkPath = 'itemtemplate'; - protected linkPath = 'itemtemplates'; - - /** - * Endpoint dynamically changing depending on what request we're sending - */ - private endpoint$: Observable; - - /** - * Is the current endpoint based on a collection? - */ - private collectionEndpoint = false; +class CollectionItemTemplateDataService extends IdentifiableDataService { + private createData: CreateDataImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DSOChangeAnalyzer, - protected bundleService: BundleDataService, - protected collectionService: CollectionDataService) { - super(requestService, rdbService, store, bs, objectCache, halService, notificationsService, http, comparator, bundleService); + protected collectionService: CollectionDataService, + ) { + super('itemtemplates', requestService, rdbService, objectCache, halService, undefined); + + // We only intend to use createOnEndpoint, so this inner data service feature doesn't need an endpoint at all + this.createData = new CreateDataImpl(undefined, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); } /** - * Get the endpoint based on a collection - * @param collectionID The ID of the collection to base the endpoint on + * Create an observable for the HREF of a specific object based on its identifier + * + * Overridden to ensure that {@link findById} works with Collection IDs and points to the template. + * @param collectionID the ID of a Collection */ - public getCollectionEndpoint(collectionID: string): Observable { + public getIDHrefObs(collectionID: string): Observable { return this.collectionService.getIDHrefObs(collectionID).pipe( - switchMap((href: string) => this.halService.getEndpoint(this.collectionLinkPath, href)) + switchMap((href: string) => this.halService.getEndpoint('itemtemplate', href)), ); } /** - * Set the endpoint to be based on a collection - * @param collectionID The ID of the collection to base the endpoint on - */ - private setCollectionEndpoint(collectionID: string) { - this.collectionEndpoint = true; - this.endpoint$ = this.getCollectionEndpoint(collectionID); - } - - /** - * Set the endpoint to the regular linkPath - */ - private setRegularEndpoint() { - this.collectionEndpoint = false; - this.endpoint$ = this.halService.getEndpoint(this.linkPath); - } - - /** - * Get the base endpoint for all requests - * Uses the current collectionID to assemble a request endpoint for the collection's item template - */ - protected getEndpoint(): Observable { - return this.endpoint$; - } - - /** - * If the current endpoint is based on a collection, simply return the collection's template endpoint, otherwise - * create a regular template endpoint - * @param resourceID - */ - getIDHrefObs(resourceID: string): Observable { - if (this.collectionEndpoint) { - return this.getEndpoint(); - } else { - return super.getIDHrefObs(resourceID); - } - } - - /** - * Set the collection ID and send a find by ID request - * @param collectionID - * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's - * no valid cached version. Defaults to true - * @param reRequestOnStale Whether or not the request should automatically be re- - * requested after the response becomes stale - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which - * {@link HALLink}s should be automatically resolved - */ - findByCollectionID(collectionID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - this.setCollectionEndpoint(collectionID); - return super.findById(collectionID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); - } - - /** - * Set the collection ID and send a create request + * Create a new item template for a Collection by ID * @param item * @param collectionID */ - createTemplate(item: Item, collectionID: string): Observable> { - this.setCollectionEndpoint(collectionID); - return super.create(item); - } - - /** - * Set the collection ID and send a delete request - * @param item - * @param collectionID - */ - deleteByCollectionID(item: Item, collectionID: string): Observable { - this.setRegularEndpoint(); - return super.delete(item.uuid).pipe( - getFirstCompletedRemoteData(), - map((response: RemoteData) => hasValue(response) && response.hasSucceeded) - ); + public createTemplate(item: Item, collectionID: string): Observable> { + return this.createData.createOnEndpoint(item, this.getIDHrefObs(collectionID)); } } @@ -149,43 +64,23 @@ class DataServiceImpl extends ItemDataService { * A service responsible for fetching/sending data from/to the REST API on a collection's itemtemplates endpoint */ @Injectable() -export class ItemTemplateDataService implements UpdateDataService { - /** - * The data service responsible for all CRUD actions on the item - */ - private dataService: DataServiceImpl; +export class ItemTemplateDataService extends BaseItemDataService { + private byCollection: CollectionItemTemplateDataService; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, - protected http: HttpClient, protected comparator: DSOChangeAnalyzer, + protected browseService: BrowseService, protected bundleService: BundleDataService, - protected collectionService: CollectionDataService) { - this.dataService = new DataServiceImpl(requestService, rdbService, store, bs, objectCache, halService, notificationsService, http, comparator, bundleService, collectionService); - } + protected collectionService: CollectionDataService, + ) { + super('itemtemplates', requestService, rdbService, objectCache, halService, notificationsService, comparator, browseService, bundleService); - /** - * Commit current object changes to the server - */ - commitUpdates(method?: RestRequestMethod) { - this.dataService.commitUpdates(method); - } - - /** - * Add a new patch to the object cache - */ - update(object: Item): Observable> { - return this.dataService.update(object); - } - - patch(dso: Item, operations: Operation[]): Observable> { - return this.dataService.patch(dso, operations); + this.byCollection = new CollectionItemTemplateDataService(requestService, rdbService, objectCache, halService, notificationsService, collectionService); } /** @@ -199,7 +94,7 @@ export class ItemTemplateDataService implements UpdateDataService { * {@link HALLink}s should be automatically resolved */ findByCollectionID(collectionID: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable> { - return this.dataService.findByCollectionID(collectionID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + return this.byCollection.findById(collectionID, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } /** @@ -207,17 +102,8 @@ export class ItemTemplateDataService implements UpdateDataService { * @param item * @param collectionID */ - create(item: Item, collectionID: string): Observable> { - return this.dataService.createTemplate(item, collectionID); - } - - /** - * Delete a template item by collection ID - * @param item - * @param collectionID - */ - deleteByCollectionID(item: Item, collectionID: string): Observable { - return this.dataService.deleteByCollectionID(item, collectionID); + createByCollectionID(item: Item, collectionID: string): Observable> { + return this.byCollection.createTemplate(item, collectionID); } /** @@ -225,6 +111,6 @@ export class ItemTemplateDataService implements UpdateDataService { * @param collectionID The ID of the collection to base the endpoint on */ getCollectionEndpoint(collectionID: string): Observable { - return this.dataService.getCollectionEndpoint(collectionID); + return this.byCollection.getIDHrefObs(collectionID); } } diff --git a/src/app/core/data/lookup-relation.service.spec.ts b/src/app/core/data/lookup-relation.service.spec.ts index c9e523f796..58598b9870 100644 --- a/src/app/core/data/lookup-relation.service.spec.ts +++ b/src/app/core/data/lookup-relation.service.spec.ts @@ -1,5 +1,5 @@ import { LookupRelationService } from './lookup-relation.service'; -import { ExternalSourceService } from './external-source.service'; +import { ExternalSourceDataService } from './external-source-data.service'; import { SearchService } from '../shared/search/search.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createPaginatedList } from '../../shared/testing/utils.test'; @@ -16,7 +16,7 @@ import { of as observableOf } from 'rxjs'; describe('LookupRelationService', () => { let service: LookupRelationService; - let externalSourceService: ExternalSourceService; + let externalSourceService: ExternalSourceDataService; let searchService: SearchService; let requestService: RequestService; diff --git a/src/app/core/data/lookup-relation.service.ts b/src/app/core/data/lookup-relation.service.ts index 7808a24e92..7a6bc2358b 100644 --- a/src/app/core/data/lookup-relation.service.ts +++ b/src/app/core/data/lookup-relation.service.ts @@ -1,4 +1,4 @@ -import { ExternalSourceService } from './external-source.service'; +import { ExternalSourceDataService } from './external-source-data.service'; import { SearchService } from '../shared/search/search.service'; import { concat, distinctUntilChanged, map, multicast, startWith, take, takeWhile } from 'rxjs/operators'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; @@ -34,7 +34,7 @@ export class LookupRelationService { pageSize: 1 }); - constructor(protected externalSourceService: ExternalSourceService, + constructor(protected externalSourceService: ExternalSourceDataService, protected searchService: SearchService, protected requestService: RequestService) { } diff --git a/src/app/core/data/metadata-field-data.service.spec.ts b/src/app/core/data/metadata-field-data.service.spec.ts index 54a174e365..1ce078f5d5 100644 --- a/src/app/core/data/metadata-field-data.service.spec.ts +++ b/src/app/core/data/metadata-field-data.service.spec.ts @@ -10,6 +10,11 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { RequestParam } from '../cache/models/request-param.model'; import { FindListOptions } from './find-list-options.model'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { testCreateDataImplementation } from './base/create-data.spec'; +import { testSearchDataImplementation } from './base/search-data.spec'; +import { testPutDataImplementation } from './base/put-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; describe('MetadataFieldDataService', () => { let metadataFieldService: MetadataFieldDataService; @@ -33,22 +38,33 @@ describe('MetadataFieldDataService', () => { generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', send: {}, getByUUID: observableOf({ response: new RestResponse(true, 200, 'OK') }), - setStaleByHrefSubstring: {} + setStaleByHrefSubstring: {}, }); halService = Object.assign(new HALEndpointServiceStub(endpoint)); notificationsService = jasmine.createSpyObj('notificationsService', { - error: {} + error: {}, }); rdbService = jasmine.createSpyObj('rdbService', { - buildSingle: createSuccessfulRemoteDataObject$(undefined) + buildSingle: createSuccessfulRemoteDataObject$(undefined), + buildList: createSuccessfulRemoteDataObject$(createPaginatedList([])), }); - metadataFieldService = new MetadataFieldDataService(requestService, rdbService, undefined, halService, undefined, undefined, undefined, notificationsService); + metadataFieldService = new MetadataFieldDataService( + requestService, rdbService, undefined, halService, notificationsService, + ); } beforeEach(() => { init(); }); + describe('composition', () => { + const initService = () => new MetadataFieldDataService(null, null, null, null, null); + testCreateDataImplementation(initService); + testSearchDataImplementation(initService); + testPutDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('findBySchema', () => { beforeEach(() => { spyOn(metadataFieldService, 'searchBy'); diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index 5a78213c84..d05e3533d3 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -1,17 +1,10 @@ import { Injectable } from '@angular/core'; import { hasValue } from '../../shared/empty.util'; -import { dataService } from '../cache/builders/build-decorators'; -import { DataService } from './data.service'; import { PaginatedList } from './paginated-list.model'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { HttpClient } from '@angular/common/http'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; import { MetadataField } from '../metadata/metadata-field.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; @@ -19,29 +12,44 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { RequestParam } from '../cache/models/request-param.model'; -import { CoreState } from '../core-state.model'; import { FindListOptions } from './find-list-options.model'; +import { SearchData, SearchDataImpl } from './base/search-data'; +import { PutData, PutDataImpl } from './base/put-data'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { NoContent } from '../shared/NoContent.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { dataService } from './base/data-service.decorator'; /** * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint */ @Injectable() @dataService(METADATA_FIELD) -export class MetadataFieldDataService extends DataService { - protected linkPath = 'metadatafields'; +export class MetadataFieldDataService extends IdentifiableDataService implements CreateData, PutData, DeleteData, SearchData { + private createData: CreateData; + private searchData: SearchData; + private putData: PutData; + private deleteData: DeleteData; + protected searchBySchemaLinkPath = 'bySchema'; protected searchByFieldNameLinkPath = 'byFieldName'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected halService: HALEndpointService, protected objectCache: ObjectCacheService, - protected comparator: DefaultChangeAnalyzer, - protected http: HttpClient, - protected notificationsService: NotificationsService) { - super(); + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('metadatafields', requestService, rdbService, objectCache, halService); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -57,7 +65,7 @@ export class MetadataFieldDataService extends DataService { */ findBySchema(schema: MetadataSchema, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]) { const optionsWithSchema = Object.assign(new FindListOptions(), options, { - searchParams: [new RequestParam('schema', schema.prefix)] + searchParams: [new RequestParam('schema', schema.prefix)], }); return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } @@ -85,8 +93,8 @@ export class MetadataFieldDataService extends DataService { new RequestParam('element', hasValue(element) ? element : ''), new RequestParam('qualifier', hasValue(qualifier) ? qualifier : ''), new RequestParam('query', hasValue(query) ? query : ''), - new RequestParam('exactName', hasValue(exactName) ? exactName : '') - ] + new RequestParam('exactName', hasValue(exactName) ? exactName : ''), + ], }); return this.searchBy(this.searchByFieldNameLinkPath, optionParams, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } @@ -112,4 +120,67 @@ export class MetadataFieldDataService extends DataService { } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } + + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: MetadataField): Observable> { + return this.putData.put(object); + } + + /** + * Create a new object on the server, and store the response in the object cache + * + * @param object The object to create + * @param params Array with additional params to combine with query string + */ + create(object: MetadataField, ...params: RequestParam[]): Observable> { + return this.createData.create(object, ...params); + } + + /** + * Make a new FindListRequest with given search method + * + * @param searchMethod The search method for the object + * @param options The [[FindListOptions]] object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits response from the server + */ + public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + } diff --git a/src/app/core/data/metadata-schema-data.service.spec.ts b/src/app/core/data/metadata-schema-data.service.spec.ts index 2e61955502..1bcf4e1104 100644 --- a/src/app/core/data/metadata-schema-data.service.spec.ts +++ b/src/app/core/data/metadata-schema-data.service.spec.ts @@ -9,6 +9,8 @@ import { MetadataSchema } from '../metadata/metadata-schema.model'; import { CreateRequest, PutRequest } from './request.models'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; +import { testFindAllDataImplementation } from './base/find-all-data.spec'; +import { testDeleteDataImplementation } from './base/delete-data.spec'; describe('MetadataSchemaDataService', () => { let metadataSchemaService: MetadataSchemaDataService; @@ -24,20 +26,33 @@ describe('MetadataSchemaDataService', () => { generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', send: {}, getByUUID: observableOf({ response: new RestResponse(true, 200, 'OK') }), - removeByHrefSubstring: {} + removeByHrefSubstring: {}, }); halService = Object.assign(new HALEndpointServiceStub(endpoint)); notificationsService = jasmine.createSpyObj('notificationsService', { - error: {} + error: {}, }); rdbService = getMockRemoteDataBuildService(); - metadataSchemaService = new MetadataSchemaDataService(requestService, rdbService, undefined, halService, undefined, undefined, undefined, notificationsService); + metadataSchemaService = new MetadataSchemaDataService( + requestService, + rdbService, + null, + halService, + notificationsService, + ); } beforeEach(() => { init(); }); + describe('composition', () => { + const initService = () => new MetadataSchemaDataService(null, null, null, null, null); + + testFindAllDataImplementation(initService); + testDeleteDataImplementation(initService); + }); + describe('createOrUpdateMetadataSchema', () => { let schema: MetadataSchema; diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index f277f6cab6..6bd633b8c6 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -1,40 +1,50 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { METADATA_SCHEMA } from '../metadata/metadata-schema.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DataService } from './data.service'; -import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { RequestService } from './request.service'; import { Observable } from 'rxjs'; import { hasValue } from '../../shared/empty.util'; import { tap } from 'rxjs/operators'; import { RemoteData } from './remote-data'; -import { CoreState } from '../core-state.model'; +import { PutData, PutDataImpl } from './base/put-data'; +import { CreateData, CreateDataImpl } from './base/create-data'; +import { NoContent } from '../shared/NoContent.model'; +import { FindAllData, FindAllDataImpl } from './base/find-all-data'; +import { FindListOptions } from './find-list-options.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { PaginatedList } from './paginated-list.model'; +import { IdentifiableDataService } from './base/identifiable-data.service'; +import { DeleteData, DeleteDataImpl } from './base/delete-data'; +import { dataService } from './base/data-service.decorator'; /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint */ @Injectable() @dataService(METADATA_SCHEMA) -export class MetadataSchemaDataService extends DataService { - protected linkPath = 'metadataschemas'; +export class MetadataSchemaDataService extends IdentifiableDataService implements FindAllData, DeleteData { + private createData: CreateData; + private findAllData: FindAllData; + private putData: PutData; + private deleteData: DeleteData; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, - protected halService: HALEndpointService, protected objectCache: ObjectCacheService, - protected comparator: DefaultChangeAnalyzer, - protected http: HttpClient, - protected notificationsService: NotificationsService) { - super(); + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + ) { + super('metadataschemas', requestService, rdbService, objectCache, halService); + + this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); + this.putData = new PutDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); } /** @@ -49,9 +59,9 @@ export class MetadataSchemaDataService extends DataService { const isUpdate = hasValue(schema.id); if (isUpdate) { - return this.put(schema); + return this.putData.put(schema); } else { - return this.create(schema); + return this.createData.create(schema); } } @@ -61,8 +71,50 @@ export class MetadataSchemaDataService extends DataService { */ clearRequests(): Observable { return this.getBrowseEndpoint().pipe( - tap((href: string) => this.requestService.removeByHrefSubstring(href)) + tap((href: string) => this.requestService.removeByHrefSubstring(href)), ); } + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } diff --git a/src/app/core/data/processes/process-data.service.spec.ts b/src/app/core/data/processes/process-data.service.spec.ts new file mode 100644 index 0000000000..88e5bd5791 --- /dev/null +++ b/src/app/core/data/processes/process-data.service.spec.ts @@ -0,0 +1,19 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { testFindAllDataImplementation } from '../base/find-all-data.spec'; +import { ProcessDataService } from './process-data.service'; +import { testDeleteDataImplementation } from '../base/delete-data.spec'; + +describe('ProcessDataService', () => { + describe('composition', () => { + const initService = () => new ProcessDataService(null, null, null, null, null, null); + testFindAllDataImplementation(initService); + testDeleteDataImplementation(initService); + }); +}); diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts index cb00ac529e..3bf34eb650 100644 --- a/src/app/core/data/processes/process-data.service.ts +++ b/src/app/core/data/processes/process-data.service.ts @@ -1,15 +1,9 @@ import { Injectable } from '@angular/core'; -import { DataService } from '../data.service'; import { RequestService } from '../request.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../default-change-analyzer.service'; import { Process } from '../../../process-page/processes/process.model'; -import { dataService } from '../../cache/builders/build-decorators'; import { PROCESS } from '../../../process-page/processes/process.resource-type'; import { Observable } from 'rxjs'; import { switchMap } from 'rxjs/operators'; @@ -17,24 +11,33 @@ import { PaginatedList } from '../paginated-list.model'; import { Bitstream } from '../../shared/bitstream.model'; import { RemoteData } from '../remote-data'; import { BitstreamDataService } from '../bitstream-data.service'; -import { CoreState } from '../../core-state.model'; +import { IdentifiableDataService } from '../base/identifiable-data.service'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { FindAllData, FindAllDataImpl } from '../base/find-all-data'; +import { FindListOptions } from '../find-list-options.model'; +import { dataService } from '../base/data-service.decorator'; +import { DeleteData, DeleteDataImpl } from '../base/delete-data'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NoContent } from '../../shared/NoContent.model'; @Injectable() @dataService(PROCESS) -export class ProcessDataService extends DataService { - protected linkPath = 'processes'; +export class ProcessDataService extends IdentifiableDataService implements FindAllData, DeleteData { + private findAllData: FindAllData; + private deleteData: DeleteData; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, - protected notificationsService: NotificationsService, protected bitstreamDataService: BitstreamDataService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - super(); + protected notificationsService: NotificationsService, + ) { + super('processes', requestService, rdbService, objectCache, halService); + + this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive); + this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint); } /** @@ -53,6 +56,49 @@ export class ProcessDataService extends DataService { */ getFiles(processId: string): Observable>> { const href$ = this.getFilesEndpoint(processId); - return this.bitstreamDataService.findAllByHref(href$); + return this.bitstreamDataService.findListByHref(href$); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig[]): Observable>> { + return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + + /** + * Delete an existing object on the server + * @param objectId The id of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + */ + public delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete an existing object on the server + * @param href The self link of the object to be removed + * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual + * metadata should be saved as real metadata + * @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode, + * errorMessage, timeCompleted, etc + * Only emits once all request related to the DSO has been invalidated. + */ + public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); } } diff --git a/src/app/core/data/processes/script-data.service.spec.ts b/src/app/core/data/processes/script-data.service.spec.ts new file mode 100644 index 0000000000..0aee3cfb46 --- /dev/null +++ b/src/app/core/data/processes/script-data.service.spec.ts @@ -0,0 +1,17 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { testFindAllDataImplementation } from '../base/find-all-data.spec'; +import { ScriptDataService } from './script-data.service'; + +describe('ScriptDataService', () => { + describe('composition', () => { + const initService = () => new ScriptDataService(null, null, null, null); + testFindAllDataImplementation(initService); + }); +}); diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index 75a66c822a..ed228612ef 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -1,46 +1,44 @@ import { Injectable } from '@angular/core'; -import { DataService } from '../data.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; -import { Store } from '@ngrx/store'; import { ObjectCacheService } from '../../cache/object-cache.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service'; -import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DefaultChangeAnalyzer } from '../default-change-analyzer.service'; import { Script } from '../../../process-page/scripts/script.model'; import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; import { map, take } from 'rxjs/operators'; import { URLCombiner } from '../../url-combiner/url-combiner'; import { RemoteData } from '../remote-data'; -import { MultipartPostRequest} from '../request.models'; +import { MultipartPostRequest } from '../request.models'; import { RequestService } from '../request.service'; import { Observable } from 'rxjs'; -import { dataService } from '../../cache/builders/build-decorators'; import { SCRIPT } from '../../../process-page/scripts/script.resource-type'; import { Process } from '../../../process-page/processes/process.model'; import { hasValue } from '../../../shared/empty.util'; import { getFirstCompletedRemoteData } from '../../shared/operators'; import { RestRequest } from '../rest-request.model'; -import { CoreState } from '../../core-state.model'; +import { IdentifiableDataService } from '../base/identifiable-data.service'; +import { FindAllData, FindAllDataImpl } from '../base/find-all-data'; +import { FindListOptions } from '../find-list-options.model'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { PaginatedList } from '../paginated-list.model'; +import { dataService } from '../base/data-service.decorator'; export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import'; export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export'; @Injectable() @dataService(SCRIPT) -export class ScriptDataService extends DataService