Skip to content

Commit

Permalink
feat(#9241): create REST API endpoint for getting people (#9295)
Browse files Browse the repository at this point in the history
  • Loading branch information
sugat009 authored Aug 12, 2024
1 parent bf8a77d commit 20ee6e5
Show file tree
Hide file tree
Showing 15 changed files with 289 additions and 24 deletions.
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 = Number(req.query.limit) || 100;

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 @@ -63,6 +64,11 @@ module.exports = {
logger.warn(`Non-numeric error code: ${code}`);
code = 500;
}

if (err instanceof InvalidArgumentError) {
code = 400;
}

if (code === 401) {
return module.exports.notLoggedIn(req, res, showPrompt);
}
Expand Down
110 changes: 109 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,113 @@ describe('Person Controller', () => {
expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true;
});
});

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

beforeEach(() => {
req = {
query: {
personType,
limit,
skip
}
};
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, limit, skip)).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 errorMessage = `Invalid person type: [${invalidPersonType}]`;
const errorPayload = { status: 400, message: errorMessage };
isOnlineOnly.returns(true);
hasAllPermissions.returns(true);
personGetPageByType.throws(new InvalidArgumentError(errorMessage));

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, limit, skip)).to.be.true;
expect(res.json.notCalled).to.be.true;
expect(serverUtilsError.calledOnceWithExactly(errorPayload, 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, limit, skip)).to.be.true;
expect(res.json.notCalled).to.be.true;
expect(serverUtilsError.calledOnceWithExactly(err, req, res)).to.be.true;
});
});
});
});
1 change: 1 addition & 0 deletions shared-libs/cht-datasource/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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
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';
}
}
3 changes: 2 additions & 1 deletion shared-libs/cht-datasource/src/local/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getDocById, getDocsByIds, queryDocsByKey } from './libs/doc';
import { LocalDataContext, SettingsService } from './libs/data-context';
import logger from '@medic/logger';
import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage';
import {InvalidArgumentError} from '../libs/error';

/** @internal */
export namespace v1 {
Expand Down Expand Up @@ -70,7 +71,7 @@ export namespace v1 {
const personTypesIds = personTypes.map((item) => item.id);

if (!personTypesIds.includes(personType.contactType)) {
throw new Error(`Invalid person type: ${personType.contactType}`);
throw new InvalidArgumentError(`Invalid contact type [${personType.contactType}]`);
}

// Adding a number skip variable here so as not to confuse ourselves
Expand Down
9 changes: 5 additions & 4 deletions shared-libs/cht-datasource/src/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Local from './local';
import * as Place from './place';
import { LocalDataContext } from './local/libs/data-context';
import { RemoteDataContext } from './remote/libs/data-context';
import { InvalidArgumentError } from './libs/error';
import { Page } from './libs/core';

/** */
Expand All @@ -29,27 +30,27 @@ export namespace v1 {

const assertPersonQualifier: (qualifier: unknown) => asserts qualifier is UuidQualifier = (qualifier: unknown) => {
if (!isUuidQualifier(qualifier)) {
throw new Error(`Invalid identifier [${JSON.stringify(qualifier)}].`);
throw new InvalidArgumentError(`Invalid identifier [${JSON.stringify(qualifier)}].`);
}
};

const assertTypeQualifier: (qualifier: unknown) => asserts qualifier is ContactTypeQualifier = (
qualifier: unknown
) => {
if (!isContactTypeQualifier(qualifier)) {
throw new Error(`Invalid type [${JSON.stringify(qualifier)}].`);
throw new InvalidArgumentError(`Invalid contact type [${JSON.stringify(qualifier)}].`);
}
};

const assertLimit = (limit: unknown) => {
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
throw new Error(`The limit must be a positive number: [${String(limit)}]`);
throw new InvalidArgumentError(`The limit must be a positive number: [${String(limit)}]`);
}
};

const assertCursor = (cursor: unknown) => {
if (typeof cursor !== 'string' || Number(cursor) < 0) {
throw new Error(`The cursor must be a stringified non-negative number: [${String(cursor)}]`);
throw new InvalidArgumentError(`The cursor must be a stringified non-negative number: [${String(cursor)}]`);
}
};

Expand Down
5 changes: 3 additions & 2 deletions shared-libs/cht-datasource/src/qualifier.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isString, hasField, isRecord } from './libs/core';
import { InvalidArgumentError } from './libs/error';

/**
* A qualifier that identifies an entity by its UUID.
Expand All @@ -13,7 +14,7 @@ export type UuidQualifier = Readonly<{ uuid: string }>;
*/
export const byUuid = (uuid: string): UuidQualifier => {
if (!isString(uuid) || uuid.length === 0) {
throw new Error(`Invalid UUID [${JSON.stringify(uuid)}].`);
throw new InvalidArgumentError(`Invalid UUID [${JSON.stringify(uuid)}].`);
}
return { uuid };
};
Expand Down Expand Up @@ -41,7 +42,7 @@ export type ContactTypeQualifier = Readonly<{ contactType: string }>;
*/
export const byContactType = (contactType: string): ContactTypeQualifier => {
if (!isString(contactType) || contactType.length === 0) {
throw new Error(`Invalid ContactType [${JSON.stringify(contactType)}].`);
throw new InvalidArgumentError(`Invalid contact type [${JSON.stringify(contactType)}].`);
}

return { contactType };
Expand Down
2 changes: 1 addition & 1 deletion shared-libs/cht-datasource/src/remote/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ export namespace v1 {
cursor: string,
limit: number,
): Promise<Page<Person.v1.Person>> => getPeople(remoteContext)(
{'limit': limit.toString(), 'contactType': personType.contactType, cursor}
{'limit': limit.toString(), 'personType': personType.contactType, cursor}
);
}
2 changes: 1 addition & 1 deletion shared-libs/cht-datasource/test/local/person.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ describe('local person', () => {

it('throws an error if person identifier is invalid/does not exist', async () => {
await expect(Person.v1.getPage(localContext)(invalidPersonTypeQualifier, cursor, limit)).to.be.rejectedWith(
`Invalid person type: ${invalidPersonTypeQualifier.contactType}`
`Invalid contact type [${invalidPersonTypeQualifier.contactType}]`
);

expect(settingsGetAll.calledOnce).to.be.true;
Expand Down
4 changes: 2 additions & 2 deletions shared-libs/cht-datasource/test/person.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ describe('person', () => {
isContactTypeQualifier.returns(false);

await expect(Person.v1.getPage(dataContext)(invalidQualifier, cursor, limit))
.to.be.rejectedWith(`Invalid type [${JSON.stringify(invalidQualifier)}].`);
.to.be.rejectedWith(`Invalid contact type [${JSON.stringify(invalidQualifier)}].`);

expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true;
expect(adapt.calledOnceWithExactly(dataContext, Local.Person.v1.getPage, Remote.Person.v1.getPage)).to.be.true;
Expand Down Expand Up @@ -276,7 +276,7 @@ describe('person', () => {
isContactTypeQualifier.returns(false);

expect(() => Person.v1.getAll(dataContext)(personTypeQualifier))
.to.throw(`Invalid type [${JSON.stringify(personTypeQualifier)}]`);
.to.throw(`Invalid contact type [${JSON.stringify(personTypeQualifier)}]`);
expect(assertDataContext.calledOnceWithExactly(dataContext)).to.be.true;
expect(personGetPage.notCalled).to.be.true;
expect(isContactTypeQualifier.calledOnceWithExactly(personTypeQualifier)).to.be.true;
Expand Down
2 changes: 1 addition & 1 deletion shared-libs/cht-datasource/test/qualifier.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('qualifier', () => {
].forEach(contactType => {
it(`throws an error for ${JSON.stringify(contactType)}`, () => {
expect(() => byContactType(contactType as string)).to.throw(
`Invalid ContactType [${JSON.stringify(contactType)}].`
`Invalid contact type [${JSON.stringify(contactType)}].`
);
});
});
Expand Down
5 changes: 3 additions & 2 deletions shared-libs/cht-datasource/test/remote/person.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,12 @@ describe('remote person', () => {
describe('getPage', () => {
const limit = 3;
const cursor = '1';
const personTypeQualifier = {contactType: 'person'};
const personType = 'person';
const personTypeQualifier = {contactType: personType};
const queryParam = {
limit: limit.toString(),
personType,
cursor,
...personTypeQualifier
};

it('returns people', async () => {
Expand Down
Loading

0 comments on commit 20ee6e5

Please sign in to comment.