Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Entities iterator #8

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8074f28
[feat] adding EntityIterator class skeleton
Alystrasz Oct 22, 2021
1592eed
[test] Iterator.hasNext should return false when querying empty colle…
Alystrasz Oct 22, 2021
3a0f40f
[refactor] removing useless imports
Alystrasz Oct 22, 2021
74b37d6
[test] Iterator.hasNext should return true when querying not-empty co…
Alystrasz Oct 22, 2021
2adee23
[feat] iterator can tell if there's no entities in current collection
Alystrasz Oct 22, 2021
fc866fb
[feat] iterator should return 5 things one after one
Alystrasz Oct 22, 2021
99409d2
[test] correctly checking hasNext return
Alystrasz Oct 22, 2021
a65c20f
[feat] adding basic iterator implementation
Alystrasz Oct 22, 2021
eee1ddc
[test] iterator should not allow next() call before hasNext() call
Alystrasz Oct 22, 2021
f8d9e04
[feat] checking if iterator has parsed API with a boolean
Alystrasz Oct 22, 2021
6137a96
[test] iterator should parse entities over several pages
Alystrasz Oct 22, 2021
9f7d6a8
Merge branch 'master' into feat/full-iterator
Alystrasz Oct 22, 2021
bd5b593
[test] using new Mock.Injector.injectMockCalls signature
Alystrasz Oct 22, 2021
d2bdd3d
[test] fix (using method instead of method result)
Alystrasz Oct 22, 2021
a66211b
[test] removing nextLink from getThingsSecondPage mock response
Alystrasz Oct 22, 2021
55a01d5
[test] removing nextLink from top5things mock response
Alystrasz Oct 22, 2021
7fa8cb7
[feat] iterator follows nextLinks when needed
Alystrasz Oct 22, 2021
2acdb32
[refactor] lint
Alystrasz Oct 22, 2021
a4096b8
[docs] adding documentation to EntityIterator
Alystrasz Oct 22, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/dao/BaseDao.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ 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.
* It allows to create, get, update and remove entities.
*/
export abstract class BaseDao<T extends Entity<T>> {
protected _service: SensorThingsService;
public iterator: EntityIterator<T>;

constructor(service: SensorThingsService) {
this._service = service;
this.iterator = new EntityIterator<T>(this, service);
}

/**
Expand Down
91 changes: 91 additions & 0 deletions src/dao/iterator/EntityIterator.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Entity<T>> {
Alystrasz marked this conversation as resolved.
Show resolved Hide resolved
private readonly _dao: BaseDao<T>;
private readonly _service: SensorThingsService;
private readonly _entities: Array<T>;
private _apiParsed: boolean;
private _index: number;
private _nextLink: string;

public constructor (dao: BaseDao<T>, service: SensorThingsService) {
this._dao = dao;
this._service = service;
this._entities = new Array<T>();
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<boolean> {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add an integration test on distant backend, to check if it can go through 10s of pages.

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<T> {
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<void> {
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<string, string>[]}>) => {
if (response.data["@iot.nextLink"] !== undefined) {
this._nextLink = response.data["@iot.nextLink"];
} else if (useNextLink) {
this._nextLink = '';
}

response.data.value.map((datum: Record<string, string>) => {
this._entities.push(
this._dao.buildEntityFromSensorThingsAPIRawData(datum)
);
});
this._apiParsed = true;
})
.catch((error: AxiosError) => {
throw error;
});
}
}
1 change: 1 addition & 0 deletions src/error/InitialisationError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class InitialisationError extends Error {}
101 changes: 101 additions & 0 deletions test/dao.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
});
});
23 changes: 22 additions & 1 deletion test/responses/ThingAPIResponses.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
export class ThingAPIResponses {
static getEmptyResponse(): Record<string, unknown> {
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)
};
}
Expand Down Expand Up @@ -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<string, unknown> {
return {
"@iot.count":27590,
Expand Down