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

feat(#9241): create REST API endpoint for getting people #9295

Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
08e2b27
feat(#9237): Add functionality of getting people with pagination in c…
sugat009 Jul 15, 2024
ab92f0b
Update shared-libs/cht-datasource/src/local/libs/doc.ts
sugat009 Jul 17, 2024
de63197
feat(#9237): Address PR comments
sugat009 Jul 19, 2024
105d5db
Update shared-libs/cht-datasource/src/qualifier.ts
sugat009 Jul 23, 2024
2b92b4c
Update shared-libs/cht-datasource/test/local/person.spec.ts
sugat009 Jul 23, 2024
ba2e27a
Update shared-libs/cht-datasource/src/person.ts
sugat009 Jul 23, 2024
15068fa
Update shared-libs/cht-datasource/src/remote/person.ts
sugat009 Jul 23, 2024
a83a259
Update shared-libs/cht-datasource/src/person.ts
sugat009 Jul 23, 2024
29a3ad5
Update shared-libs/cht-datasource/src/index.ts
sugat009 Jul 23, 2024
8fbc245
feat(#9237): Address PR comments
sugat009 Jul 23, 2024
a99de72
feat(#9237): Add unit tests for getResources in remote mode
sugat009 Jul 23, 2024
d87c258
feat(#9237): Address PR comments
sugat009 Jul 24, 2024
eceeb75
(#feat): Minor fix
sugat009 Jul 24, 2024
1aab0e3
Update shared-libs/cht-datasource/src/person.ts
sugat009 Jul 26, 2024
54d04cb
Update shared-libs/cht-datasource/test/local/libs/lineage.spec.ts
sugat009 Jul 26, 2024
dcc3758
Update shared-libs/cht-datasource/test/local/libs/lineage.spec.ts
sugat009 Jul 26, 2024
e800b1a
Update shared-libs/cht-datasource/test/local/person.spec.ts
sugat009 Jul 26, 2024
34b466c
feat(#9237): Address PR comments
sugat009 Jul 26, 2024
3aa0fd8
feat(#9241): add REST endpoint /api/v1/people to get people
sugat009 Jul 31, 2024
f47b819
feat(#9241): Add unit tests for controller
sugat009 Aug 1, 2024
09e3178
Remove extra brackets
sugat009 Aug 1, 2024
44f1362
Add missing param in checkUserPermissions function
sugat009 Aug 1, 2024
a2296c6
Update shared-libs/cht-datasource/src/libs/error.ts
sugat009 Aug 1, 2024
dc9ffa6
Update shared-libs/cht-datasource/src/libs/error.ts
sugat009 Aug 1, 2024
b77b171
Add integration tests to the /api/v1/person route and fix some issues
sugat009 Aug 2, 2024
635f292
Separate the looped integration test into tests of their own
sugat009 Aug 7, 2024
1e87aa4
Implement cursor based pagination
sugat009 Aug 6, 2024
bf3a22c
Fix unit tests according to implementation of cursor pagination
sugat009 Aug 6, 2024
26a286b
Fix integration tests
sugat009 Aug 7, 2024
f2fd53d
Refactor the AsyncIterator
sugat009 Jul 25, 2024
cc7acec
Add documentation for new functions
sugat009 Jul 25, 2024
a875c2e
Add unit tests
sugat009 Jul 25, 2024
1df3d68
Add comment on unit tests for clarity
sugat009 Jul 25, 2024
52b07c2
Address PR comments
sugat009 Jul 29, 2024
91595f4
Avoid using spread operator in getDocumentStream function
sugat009 Jul 31, 2024
c785563
Fix documentation of previously changed functions
sugat009 Jul 31, 2024
4e60500
Write getAll generator in src/person.ts
sugat009 Jul 24, 2024
a292060
Refactor the AsyncIterator
sugat009 Jul 25, 2024
6b63922
Add documentation for new functions
sugat009 Jul 25, 2024
c1e55e1
Add unit tests
sugat009 Jul 25, 2024
f3a5edb
Address PR comments
sugat009 Jul 29, 2024
cbfb30f
Fix conflict with target branch
sugat009 Jul 31, 2024
1704024
Update shared-libs/cht-datasource/src/person.ts
sugat009 Jul 31, 2024
a73932e
Update shared-libs/cht-datasource/src/index.ts
sugat009 Aug 6, 2024
711748d
Update shared-libs/cht-datasource/src/index.ts
sugat009 Aug 6, 2024
ff9bd08
Update shared-libs/cht-datasource/src/person.ts
sugat009 Aug 6, 2024
cb7cf93
Remove limit and skip as arguments for getDocumentStream function
sugat009 Aug 6, 2024
9625fc6
Remove duplicate test case added during conflict resolution
sugat009 Aug 7, 2024
820bb48
Cleanup after refactoring
sugat009 Aug 7, 2024
5656f0a
Fix unit tests according to implementation of cursor pagination
sugat009 Aug 6, 2024
90cc788
Add unit tests
sugat009 Jul 25, 2024
6d5f2dd
Address PR comments
sugat009 Jul 29, 2024
8c77a08
Avoid using spread operator in getDocumentStream function
sugat009 Jul 31, 2024
315fff9
Fix conflict with target branch
sugat009 Jul 31, 2024
507e63f
Remove limit and skip as arguments for getDocumentStream function
sugat009 Aug 6, 2024
7f6e7b9
Cleanup after refactoring
sugat009 Aug 7, 2024
74e3600
Refactor getDocumentStream w.r.t. the cursor based pagination in getP…
sugat009 Aug 7, 2024
036ce01
Fix merge conflicts with target branch
sugat009 Aug 7, 2024
b5efcda
Update shared-libs/cht-datasource/src/libs/core.ts
sugat009 Aug 8, 2024
7914acd
Change skip to cursor in all places and refactor fetchAndFilter funct…
sugat009 Aug 8, 2024
c79c935
Fix eslint issues
sugat009 Aug 8, 2024
ae39df7
feat(#9241): add REST endpoint /api/v1/people to get people
sugat009 Jul 31, 2024
d8d072c
Add integration tests to the /api/v1/person route and fix some issues
sugat009 Aug 2, 2024
3e651e8
Address PR comments
sugat009 Aug 8, 2024
dd4b59a
Fix conflict with main and change skip to cursor in endpoint
sugat009 Aug 8, 2024
bdf5ee2
Merge remote-tracking branch 'origin/9193-api-endpoints-for-getting-c…
sugat009 Aug 12, 2024
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
38 changes: 31 additions & 7 deletions api/src/controllers/person.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { Person, Qualifier } = require('@medic/cht-datasource');
const { Person, Qualifier, InvalidArgumentError } = require('@medic/cht-datasource');
const ctx = require('../services/data-context');
const serverUtils = require('../server-utils');
const auth = require('../auth');
Expand All @@ -8,20 +8,44 @@ 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);
})
}
}),
getPageByType: serverUtils.doOrError(async (req, res) => {
await checkUserPermissions(req);
sugat009 marked this conversation as resolved.
Show resolved Hide resolved
const personType = Qualifier.byContactType(req.query.personType);
const limit = req.query.limit !== undefined ? Number(req.query.limit) : 100;
const skip = req.query.skip !== undefined ? Number(req.query.skip) : 0;

try {
// TODO: change this when #9281 gets merged
const docs = await getPageByType()( personType, limit, skip );

return res.json(docs);
} catch (err) {
if (err instanceof InvalidArgumentError) {
return serverUtils.error({status: 400, message: err.message}, req, res);
sugat009 marked this conversation as resolved.
Show resolved Hide resolved
}

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

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

app.postJson('/api/v1/bulk-delete', bulkDocs.bulkDelete);
Expand Down
110 changes: 109 additions & 1 deletion api/tests/mocha/controllers/person.spec.js
sugat009 marked this conversation as resolved.
Show resolved Hide resolved
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 }));
m5r marked this conversation as resolved.
Show resolved Hide resolved

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.getPageByType(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.getPageByType(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.getPageByType(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.getPageByType(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.getPageByType(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;
});
});
});
});
15 changes: 15 additions & 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 Expand Up @@ -85,6 +86,20 @@ 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 limit the maximum number of people to return. Default is 100.
* @param skip the number of people to skip. Default is 0.
* @returns an array of people for the provided page 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 skip is `< 0`
*/
getPageByType: (personType: string, limit = 100, skip = 0) => ctx.bind(Person.v1.getPage)(
Qualifier.byContactType(personType), limit, skip
),
}
}
};
Expand Down
17 changes: 17 additions & 0 deletions shared-libs/cht-datasource/src/libs/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* 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 = message;
sugat009 marked this conversation as resolved.
Show resolved Hide resolved

Object.setPrototypeOf(this, InvalidArgumentError.prototype);
sugat009 marked this conversation as resolved.
Show resolved Hide resolved
}
}
26 changes: 18 additions & 8 deletions shared-libs/cht-datasource/src/local/libs/doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,25 @@ export const getDocsByIds = (db: PouchDB.Database<Doc>) => async (uuids: string[
.filter((doc): doc is Doc => isDoc(doc));
};

const queryDocs = (db: PouchDB.Database<Doc>, view: string, options: PouchDB.Query.Options<Doc, unknown>) => db
.query(view, {...options})
.then(({ rows }) => rows.map(({ doc }) => isDoc(doc) ? doc : null));

/** @internal */
export const queryDocsByKey = (
export const queryDocsByRange = (
db: PouchDB.Database<Doc>,
view: string
) => async (key: string): Promise<Nullable<Doc>[]> => db
.query(view, {
startkey: [key],
endkey: [key, {}],
include_docs: true
})
.then(({ rows }) => rows.map(({ doc }) => isDoc(doc) ? doc : null));
) => async (
startkey: unknown,
endkey: unknown
): Promise<Nullable<Doc>[]> => queryDocs(db, view, { include_docs: true, startkey, endkey});

/** @internal */
export const queryDocsByKey = (
db: PouchDB.Database<Doc>,
view: string
) => async (
key: unknown,
limit: number,
skip: number
): Promise<Nullable<Doc>[]> => queryDocs(db, view, { include_docs: true, key, limit, skip });
9 changes: 5 additions & 4 deletions shared-libs/cht-datasource/src/local/libs/lineage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,18 @@ import {
Nullable
} from '../../libs/core';
import { Doc } from '../../libs/doc';
import { queryDocsByKey } from './doc';
import { queryDocsByRange } from './doc';
import logger from '@medic/logger';

/**
* Returns the identified document along with the parent documents recorded for its lineage. The returned array is
* sorted such that the identified document is the first element and the parent documents are in order of lineage.
* @internal
*/
export const getLineageDocsById = (
medicDb: PouchDB.Database<Doc>
): (id: string) => Promise<Nullable<Doc>[]> => queryDocsByKey(medicDb, 'medic-client/docs_by_id_lineage');
export const getLineageDocsById = (medicDb: PouchDB.Database<Doc>): (id: string) => Promise<Nullable<Doc>[]> => {
const fn = queryDocsByRange(medicDb, 'medic-client/docs_by_id_lineage');
return (id: string) => fn([id], [id, {}]);
};

/** @internal */
export const getPrimaryContactIds = (places: NonEmptyArray<Nullable<Doc>>): string[] => places
Expand Down
29 changes: 24 additions & 5 deletions shared-libs/cht-datasource/src/local/person.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { Doc } from '../libs/doc';
import contactTypeUtils from '@medic/contact-types-utils';
import { deepCopy, isNonEmptyArray, Nullable } from '../libs/core';
import { UuidQualifier } from '../qualifier';
import { ContactTypeQualifier, UuidQualifier } from '../qualifier';
import * as Person from '../person';
import { getDocById, getDocsByIds } from './libs/doc';
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 {
const isPerson = (settings: SettingsService, uuid: string, doc: Nullable<Doc>): doc is Person.v1.Person => {
const isPerson = (settings: SettingsService, doc: Nullable<Doc>, uuid = ''): doc is Person.v1.Person => {
if (!doc) {
logger.warn(`No person found for identifier [${uuid}].`);
return false;
Expand All @@ -28,7 +29,7 @@ export namespace v1 {
const getMedicDocById = getDocById(medicDb);
return async (identifier: UuidQualifier): Promise<Nullable<Person.v1.Person>> => {
const doc = await getMedicDocById(identifier.uuid);
if (!isPerson(settings, identifier.uuid, doc)) {
if (!isPerson(settings, doc, identifier.uuid)) {
return null;
}
return doc;
Expand All @@ -41,7 +42,7 @@ export namespace v1 {
const getMedicDocsById = getDocsByIds(medicDb);
return async (identifier: UuidQualifier): Promise<Nullable<Person.v1.PersonWithLineage>> => {
const [person, ...lineagePlaces] = await getLineageDocs(identifier.uuid);
if (!isPerson(settings, identifier.uuid, person)) {
if (!isPerson(settings, person, identifier.uuid)) {
return null;
}
// Intentionally not further validating lineage. For passivity, lineage problems should not block retrieval.
Expand All @@ -58,4 +59,22 @@ export namespace v1 {
return deepCopy(personWithLineage);
};
};

/** @internal */
export const getPage = ({ medicDb, settings }: LocalDataContext) => {
return async (personType: ContactTypeQualifier, limit: number, skip: number): Promise<Person.v1.Person[]> => {
const personTypes = contactTypeUtils.getPersonTypes(settings.getAll());
const personTypesIds = personTypes.map(item => item.id);

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

const getDocsByPage = queryDocsByKey(medicDb, 'medic-client/contacts_by_type');

const docs = await getDocsByPage([personType.contactType], limit, skip);

return docs.filter((doc): doc is Person.v1.Person => isPerson(settings, doc, doc?._id));
};
};
}
Loading
Loading