Skip to content

Commit

Permalink
feat(#9193): add functionality of getting people as pages or async it…
Browse files Browse the repository at this point in the history
…erables in cht-datasource (#9311)

Co-authored-by: Joshua Kuestersteffen <[email protected]>
  • Loading branch information
sugat009 and jkuester authored Aug 23, 2024
1 parent 6a3d444 commit 34dd030
Show file tree
Hide file tree
Showing 28 changed files with 1,278 additions and 66 deletions.
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"build/**",
"jsdocs/**",
"shared-libs/cht-datasource/dist/**",
"shared-libs/cht-datasource/docs/**",
"tests/scalability/report*/**",
"tests/scalability/jmeter/**",
"webapp/src/ts/providers/xpath-element-path.provider.ts"
Expand Down
27 changes: 21 additions & 6 deletions api/src/controllers/person.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,35 @@ const getPerson = ({ with_lineage }) => ctx.bind(
? Person.v1.getWithLineage
: Person.v1.get
);
const getPageByType = () => ctx.bind(Person.v1.getPage);

const checkUserPermissions = async (req) => {
const userCtx = await auth.getUserCtx(req);
if (!auth.isOnlineOnly(userCtx) || !auth.hasAllPermissions(userCtx, 'can_view_contacts')) {
return Promise.reject({ code: 403, message: 'Insufficient privileges' });
}
};

module.exports = {
v1: {
get: serverUtils.doOrError(async (req, res) => {
const userCtx = await auth.getUserCtx(req);
if (!auth.isOnlineOnly(userCtx) || !auth.hasAllPermissions(userCtx, 'can_view_contacts')) {
return Promise.reject({ code: 403, message: 'Insufficient privileges' });
}
await checkUserPermissions(req);
const { uuid } = req.params;
const person = await getPerson(req.query)(Qualifier.byUuid(uuid));
if (!person) {
return serverUtils.error({ status: 404, message: 'Person not found' }, req, res);
}
return res.json(person);
})
}
}),
getAll: serverUtils.doOrError(async (req, res) => {
await checkUserPermissions(req);

const personType = Qualifier.byContactType(req.query.personType);
const limit = req.query.limit ? Number(req.query.limit) : req.query.limit;

const docs = await getPageByType()( personType, req.query.cursor, limit );

return res.json(docs);
}),
},
};
1 change: 1 addition & 0 deletions api/src/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ app.postJson('/api/v1/people', function(req, res) {
.catch(err => serverUtils.error(err, req, res));
});

app.get('/api/v1/person', person.v1.getAll);
app.get('/api/v1/person/:uuid', person.v1.get);

app.postJson('/api/v1/bulk-delete', bulkDocs.bulkDelete);
Expand Down
6 changes: 6 additions & 0 deletions api/src/server-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const isClientHuman = require('./is-client-human');
const logger = require('@medic/logger');
const MEDIC_BASIC_AUTH = 'Basic realm="Medic Web Services"';
const cookie = require('./services/cookie');
const {InvalidArgumentError} = require('@medic/cht-datasource');

const wantsJSON = req => req.accepts(['text', 'json']) === 'json';

Expand Down Expand Up @@ -57,12 +58,17 @@ module.exports = {
if (typeof err === 'string') {
return module.exports.serverError(err, req, res);
}

// https://github.com/nodejs/node/issues/9027
let code = err.code || err.statusCode || err.status || 500;
if (err instanceof InvalidArgumentError) {
code = 400;
}
if (!Number.isInteger(code)) {
logger.warn(`Non-numeric error code: ${code}`);
code = 500;
}

if (code === 401) {
return module.exports.notLoggedIn(req, res, showPrompt);
}
Expand Down
109 changes: 108 additions & 1 deletion api/tests/mocha/controllers/person.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const sinon = require('sinon');
const { expect } = require('chai');
const { Person, Qualifier } = require('@medic/cht-datasource');
const { Person, Qualifier, InvalidArgumentError } = require('@medic/cht-datasource');
const auth = require('../../../src/auth');
const controller = require('../../../src/controllers/person');
const dataContext = require('../../../src/services/data-context');
Expand Down Expand Up @@ -154,5 +154,112 @@ describe('Person Controller', () => {
expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true;
});
});

describe('getAll', () => {
let personGetPageByType;
let qualifierByContactType;
const personType = 'person';
const invalidPersonType = 'invalidPerson';
const personTypeQualifier = { contactType: personType };
const person = { name: 'John Doe' };
const limit = 100;
const cursor = null;
const people = Array.from({ length: 3 }, () => ({ ...person }));

beforeEach(() => {
req = {
query: {
personType,
cursor,
limit,
}
};
personGetPageByType = sinon.stub();
qualifierByContactType = sinon.stub(Qualifier, 'byContactType');
dataContextBind.withArgs(Person.v1.getPage).returns(personGetPageByType);
qualifierByContactType.returns(personTypeQualifier);
});

afterEach(() => {
expect(getUserCtx.calledOnceWithExactly(req)).to.be.true;
expect(isOnlineOnly.calledOnceWithExactly(userCtx)).to.be.true;
});

it('returns a page of people with correct query params', async () => {
isOnlineOnly.returns(true);
hasAllPermissions.returns(true);
personGetPageByType.resolves(people);

await controller.v1.getAll(req, res);

expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true;
expect(qualifierByContactType.calledOnceWithExactly(req.query.personType)).to.be.true;
expect(dataContextBind.calledOnceWithExactly(Person.v1.getPage)).to.be.true;
expect(personGetPageByType.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true;
expect(res.json.calledOnceWithExactly(people)).to.be.true;
expect(serverUtilsError.notCalled).to.be.true;
});

it('returns error if user does not have can_view_contacts permission', async () => {
const error = { code: 403, message: 'Insufficient privileges' };
isOnlineOnly.returns(true);
hasAllPermissions.returns(false);

await controller.v1.getAll(req, res);

expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true;
expect(dataContextBind.notCalled).to.be.true;
expect(qualifierByContactType.notCalled).to.be.true;
expect(personGetPageByType.notCalled).to.be.true;
expect(res.json.notCalled).to.be.true;
expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true;
});

it('returns error if not an online user', async () => {
const error = { code: 403, message: 'Insufficient privileges' };
isOnlineOnly.returns(false);

await controller.v1.getAll(req, res);

expect(hasAllPermissions.notCalled).to.be.true;
expect(dataContextBind.notCalled).to.be.true;
expect(qualifierByContactType.notCalled).to.be.true;
expect(personGetPageByType.notCalled).to.be.true;
expect(res.json.notCalled).to.be.true;
expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true;
});

it('returns 400 error when argument is invalid', async () => {
const err = new InvalidArgumentError(`Invalid contact type: [${invalidPersonType}]`);
isOnlineOnly.returns(true);
hasAllPermissions.returns(true);
personGetPageByType.throws(err);

await controller.v1.getAll(req, res);

expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true;
expect(qualifierByContactType.calledOnceWithExactly(req.query.personType)).to.be.true;
expect(dataContextBind.calledOnceWithExactly(Person.v1.getPage)).to.be.true;
expect(personGetPageByType.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true;
expect(res.json.notCalled).to.be.true;
expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true;
});

it('rethrows error in case of other errors', async () => {
const err = new Error('error');
isOnlineOnly.returns(true);
hasAllPermissions.returns(true);
personGetPageByType.throws(err);

await controller.v1.getAll(req, res);

expect(hasAllPermissions.calledOnceWithExactly(userCtx, 'can_view_contacts')).to.be.true;
expect(qualifierByContactType.calledOnceWithExactly(req.query.personType)).to.be.true;
expect(dataContextBind.calledOnceWithExactly(Person.v1.getPage)).to.be.true;
expect(personGetPageByType.calledOnceWithExactly(personTypeQualifier, cursor, limit)).to.be.true;
expect(res.json.notCalled).to.be.true;
expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true;
});
});
});
});
14 changes: 13 additions & 1 deletion api/tests/mocha/server-utils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const chai = require('chai');
const environment = require('@medic/environment');
const serverUtils = require('../../src/server-utils');
const cookie = require('../../src/services/cookie');
const {InvalidArgumentError} = require('@medic/cht-datasource');

let req;
let res;
Expand Down Expand Up @@ -60,6 +61,18 @@ describe('Server utils', () => {
chai.expect(res.end.callCount).to.equal(0);
});

it('function handles InvalidArgument error as bad request(400) error', () => {
const err = new InvalidArgumentError('Bad Request');

serverUtils.error(err, req, res);

chai.expect(res.writeHead.callCount).to.eq(1);
chai.expect(res.writeHead.args[0][0]).to.eq(400);
chai.expect(res.writeHead.args[0][1]['Content-Type']).to.equal('text/plain');
chai.expect(res.end.callCount).to.equal(1);
chai.expect(res.end.args[0][0]).to.equal('Bad Request');
});

it('function handles 503 errors - #3821', () => {
// an example error thrown by the `request` library
const error = {
Expand Down Expand Up @@ -124,7 +137,6 @@ describe('Server utils', () => {
chai.expect(res.end.callCount).to.equal(1);
chai.expect(res.end.args[0][0]).to.equal('foo');
});

});

describe('notLoggedIn', () => {
Expand Down
33 changes: 26 additions & 7 deletions shared-libs/cht-datasource/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
const JS_DOC_REQUIRED_CONTEXTS = [
'FunctionDeclaration',
'FunctionExpression',
'VariableDeclaration',
'TSInterfaceDeclaration',
'TSTypeAliasDeclaration',
'TSEnumDeclaration',
'TSMethodSignature'
];

module.exports = {
overrides: [
{
Expand All @@ -19,11 +29,6 @@ module.exports = {
settings: {
jsdoc: {
contexts: [
'VariableDeclaration',
'TSInterfaceDeclaration',
'TSTypeAliasDeclaration',
'TSEnumDeclaration',
'TSMethodSignature'
]
}
},
Expand All @@ -42,8 +47,22 @@ module.exports = {
FunctionExpression: true,
MethodDefinition: true,
},
publicOnly: true,
}]
contexts: JS_DOC_REQUIRED_CONTEXTS,
publicOnly: true
}],
['jsdoc/require-param']: ['error', {
contexts: JS_DOC_REQUIRED_CONTEXTS,
exemptedBy: ['inheritdoc', 'private', 'internal']
}],
['jsdoc/require-returns']: ['error', {
contexts: JS_DOC_REQUIRED_CONTEXTS,
exemptedBy: ['inheritdoc', 'private', 'internal']
}],
['jsdoc/require-yields']: ['error', {
contexts: JS_DOC_REQUIRED_CONTEXTS,
exemptedBy: ['inheritdoc', 'private', 'internal']
}],
['jsdoc/check-tag-names']: ['error', { definedTags: ['typeParam']}],
}
}
]
Expand Down
30 changes: 30 additions & 0 deletions shared-libs/cht-datasource/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* const myPerson = await datasource.v1.person.getByUuid(myUuid);
*/
import { hasAnyPermission, hasPermissions } from './auth';
import { Nullable } from './libs/core';
import { assertDataContext, DataContext } from './libs/data-context';
import * as Person from './person';
import * as Place from './place';
Expand All @@ -36,6 +37,7 @@ export { Nullable, NonEmptyArray } from './libs/core';
export { DataContext } from './libs/data-context';
export { getLocalDataContext } from './local';
export { getRemoteDataContext } from './remote';
export { InvalidArgumentError } from './libs/error';
export * as Person from './person';
export * as Place from './place';
export * as Qualifier from './qualifier';
Expand Down Expand Up @@ -85,6 +87,34 @@ export const getDatasource = (ctx: DataContext) => {
* @throws Error if no UUID is provided
*/
getByUuidWithLineage: (uuid: string) => ctx.bind(Person.v1.getWithLineage)(Qualifier.byUuid(uuid)),

/**
* Returns an array of people for the provided page specifications.
* @param personType the type of people to return
* @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be
* returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page.
* @param limit the maximum number of people to return. Default is 100.
* @returns a page of people for the provided specifications
* @throws Error if no type is provided or if the type is not for a person
* @throws Error if the provided limit is `<= 0`
* @throws Error if the provided cursor is not a valid page token or `null`
* @see {@link getByType} which provides the same data, but without having to manually account for paging
*/
getPageByType: (
personType: string,
cursor: Nullable<string> = null,
limit = 100
) => ctx.bind(Person.v1.getPage)(
Qualifier.byContactType(personType), cursor, limit
),

/**
* Returns a generator for fetching all people with the given type.
* @param personType the type of people to return
* @returns a generator for fetching all people with the given type
* @throws Error if no type is provided or if the type is not for a person
*/
getByType: (personType: string) => ctx.bind(Person.v1.getAll)(Qualifier.byContactType(personType)),
}
}
};
Expand Down
32 changes: 32 additions & 0 deletions shared-libs/cht-datasource/src/libs/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,35 @@ export const findById = <T extends Identifiable>(values: T[], id: string): Nulla
export abstract class AbstractDataContext implements DataContext {
readonly bind = <T>(fn: (ctx: DataContext) => T): T => fn(this);
}

/**
* Represents a page of results. The `data` array contains the results for this page. The `cursor` field contains a
* token that can be used to fetch the next page of results. If no `cursor` value is returned, there are no additional
* results available. (Note that no assumptions should be made about the _contents_ of the cursor string.)
* @typeParam T the type of the data in the page
*/
export interface Page<T> {
readonly data: T[];
readonly cursor: Nullable<string>;
}

/** @internal */
export const getPagedGenerator = async function* <S, T>(
fetchFunction: (args: S, s: Nullable<string>, l: number) => Promise<Page<T>>,
fetchFunctionArgs: S
): AsyncGenerator<T, null> {
const limit = 100;
let cursor: Nullable<string> = null;

do {
const docs = await fetchFunction(fetchFunctionArgs, cursor, limit);

for (const doc of docs.data) {
yield doc;
}

cursor = docs.cursor;
} while (cursor);

return null;
};
15 changes: 15 additions & 0 deletions shared-libs/cht-datasource/src/libs/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Represents an error that occurs when an invalid argument is provided.
* This error is typically thrown when a function or method receives an argument
* that doesn't meet the expected criteria or constraints.
*/
export class InvalidArgumentError extends Error {
/**
* Constructor
* @param message a descriptive error message why the error was raised
*/
constructor(message: string) {
super(message);
this.name = 'InvalidArgumentError';
}
}
Loading

0 comments on commit 34dd030

Please sign in to comment.