diff --git a/src/dao/BaseDao.ts b/src/dao/BaseDao.ts index 9c2d30b..b35e524 100644 --- a/src/dao/BaseDao.ts +++ b/src/dao/BaseDao.ts @@ -3,6 +3,7 @@ import { SensorThingsService } from '../service/SensorThingsService'; import { AxiosError, AxiosResponse } from 'axios'; import { NotFoundError } from '../error/NotFoundError'; import {Query} from "../query/Query"; +import {EntityIterator} from "./iterator/EntityIterator"; /** * Entity independent implementation of a data access object. @@ -10,9 +11,11 @@ import {Query} from "../query/Query"; */ export abstract class BaseDao> { protected _service: SensorThingsService; + public iterator: EntityIterator; constructor(service: SensorThingsService) { this._service = service; + this.iterator = new EntityIterator(this, service); } /** diff --git a/src/dao/iterator/EntityIterator.ts b/src/dao/iterator/EntityIterator.ts new file mode 100644 index 0000000..6e08d03 --- /dev/null +++ b/src/dao/iterator/EntityIterator.ts @@ -0,0 +1,91 @@ +import {Entity} from "../../model/Entity"; +import {BaseDao} from "../BaseDao"; +import {SensorThingsService} from "../../service/SensorThingsService"; +import {AxiosError, AxiosResponse} from "axios"; +import {InitialisationError} from "../../error/InitialisationError"; + +/** + * This allows to browse large collections of entities that span over several pages, + * by following @iot.nextLink URLs. + */ +export class EntityIterator> { + private readonly _dao: BaseDao; + private readonly _service: SensorThingsService; + private readonly _entities: Array; + private _apiParsed: boolean; + private _index: number; + private _nextLink: string; + + public constructor (dao: BaseDao, service: SensorThingsService) { + this._dao = dao; + this._service = service; + this._entities = new Array(); + this._index = 0; + this._apiParsed = false; + this._nextLink = ''; + } + + /** + * Checks if there are entities on the current entities collections to return. + * Must be invoked before calling next(). + */ + public async hasNext(): Promise { + if (this._entities.length === 0) + await this._loadUpEntities(); + if (this._entities.length === 0) + return false; + + if (this._index === this._entities.length) + if (this._nextLink !== '') + await this._loadUpEntities(true); + + return this._index < this._entities.length; + } + + /** + * Returns the next element of the current entities collection. + * This will throw if hasNext() was not previously invoked. + */ + public async next(): Promise { + if (!this._apiParsed) + throw new InitialisationError('hasNext() must be called before next() calls.'); + this._index += 1; + return this._entities[this._index-1]; + } + + /** + * Loads up entities from the current SensorThings service endpoint. + * This will use either default entity endpoint, or next link page if told to do so. + * @param useNextLink should use stored next entities page link ? + * @private + */ + private async _loadUpEntities(useNextLink = false): Promise { + if (!useNextLink) this._entities.length = 0; + const endpoint = useNextLink + ? this._nextLink + : [ + this._service.endpoint, + this._dao.entityPathname + ].join('/'); + + return await this._service.httpClient + .get( endpoint ) + .then((response: AxiosResponse<{'@iot.nextLink'?: string, value: Record[]}>) => { + if (response.data["@iot.nextLink"] !== undefined) { + this._nextLink = response.data["@iot.nextLink"]; + } else if (useNextLink) { + this._nextLink = ''; + } + + response.data.value.map((datum: Record) => { + this._entities.push( + this._dao.buildEntityFromSensorThingsAPIRawData(datum) + ); + }); + this._apiParsed = true; + }) + .catch((error: AxiosError) => { + throw error; + }); + } +} diff --git a/src/error/InitialisationError.ts b/src/error/InitialisationError.ts new file mode 100644 index 0000000..f3babdf --- /dev/null +++ b/src/error/InitialisationError.ts @@ -0,0 +1 @@ +export class InitialisationError extends Error {} diff --git a/test/dao.test.ts b/test/dao.test.ts index 0dde86c..6d573f9 100644 --- a/test/dao.test.ts +++ b/test/dao.test.ts @@ -8,6 +8,7 @@ import {ThingAPIResponses} from "./responses/ThingAPIResponses"; import {DumbEntityBuilder} from "./utils/DumbEntityBuilder"; import {LocationDao} from "../src/dao/LocationDao"; import {LocationAPIResponses} from "./responses/LocationAPIResponses"; +import {InitialisationError} from "../src/error/InitialisationError"; const service = new SensorThingsService('https://example.org'); let mockInjector: HttpClientMock; @@ -315,4 +316,104 @@ describe('DAO', () => { const dao = new DumbEntityDao(service); expect(dao.entityPublicAttributes).toEqual(['name', 'description']); }); + + describe('Iterator', () => { + it('should return false when querying empty collection', async () => { + const dao = new DumbEntityDao(service); + const iterator = dao.iterator; + mockInjector.injectMockCalls(service, [{ + targetUrl: 'https://example.org/DumbEntities', + method: 'get', + callback: () => { + return { + data: ThingAPIResponses.getEmptyResponse() + } + } + }]); + + const result = await iterator.hasNext(); + expect(result).toBeFalsy(); + }); + + it('should return true when querying not-empty collection', async () => { + const dao = new DumbEntityDao(service); + const iterator = dao.iterator; + mockInjector.injectMockCalls(service, [{ + targetUrl: 'https://example.org/DumbEntities', + method: 'get', + callback: () => { + return { + data: ThingAPIResponses.things + } + } + }]); + + const result = await iterator.hasNext(); + expect(result).toBeTruthy(); + }); + + it('should return 5 things one after one', async () => { + const dao = new DumbEntityDao(service); + const iterator = dao.iterator; + mockInjector.injectMockCalls(service, [{ + targetUrl: 'https://example.org/DumbEntities', + method: 'get', + callback: () => { + return { + data: ThingAPIResponses.top5things + } + } + }]); + + let counter = 0; + while (counter < 5) { + const hasItems = await iterator.hasNext(); + if (!hasItems) { + fail('Iterator.failed returned false while collection still have items'); + return; + } + + await iterator.next(); + counter += 1; + } + expect(await iterator.hasNext()).toBeFalsy(); + }); + + it('should not allow next() call before hasNext() call', async () => { + const dao = new DumbEntityDao(service); + const iterator = dao.iterator; + const getNextEntity = async () => await iterator.next(); + await expect(getNextEntity()).rejects.toThrow( + new InitialisationError('hasNext() must be called before next() calls.') + ); + }); + + it('should parse entities over several pages', async () => { + mockInjector.injectMockCalls(service, [{ + targetUrl: 'https://example.org/DumbEntities', + method: 'get', + callback: () => { + return { + data: ThingAPIResponses.getThingsFirstPage() + } + } + }, { + targetUrl: 'https://example.org/Things?$top=100&$skip=100', + method: 'get', + callback: () => { + return { + data: ThingAPIResponses.getThingsSecondPage() + } + } + }]); + const dao = new DumbEntityDao(service); + const iterator = dao.iterator; + + let things = []; + while (await iterator.hasNext()) { + things.push(iterator.next()); + } + expect(things.length).toEqual(200); + }); + }); }); diff --git a/test/responses/ThingAPIResponses.ts b/test/responses/ThingAPIResponses.ts index fa257dc..9aea272 100644 --- a/test/responses/ThingAPIResponses.ts +++ b/test/responses/ThingAPIResponses.ts @@ -1,9 +1,15 @@ export class ThingAPIResponses { + static getEmptyResponse(): Record { + return { + "@iot.count":0, + "value":[] + }; + } + static get top5things(): Object { let things = this.things.value as Object[]; return { "@iot.count":27590, - "@iot.nextLink":"https://scratchpad.sensorup.com/OGCSensorThings/v1.0/Things?$top=5&$skip=5", "value": things.filter((_value, index) => index < 5) }; } @@ -62,6 +68,21 @@ export class ThingAPIResponses { }; } + static getThingsFirstPage(): Object { + const things = ThingAPIResponses.things; + things['@iot.count'] = 200; + things['@iot.nextLink'] = `https://example.org/Things?$top=100&$skip=100`; + return things; + } + + static getThingsSecondPage(): Object { + const things = ThingAPIResponses.things; + things['@iot.count'] = 200; + delete things['@iot.nextLink']; + return things; + } + + static get things(): Record { return { "@iot.count":27590,