Skip to content

Commit

Permalink
Endpoint for retrieving hierarchy information for multiple TSNs (#250)
Browse files Browse the repository at this point in the history
* Add taxon hierarchy endpoint
  • Loading branch information
mauberti-bc authored Aug 28, 2024
1 parent 75f63fd commit 3fb2a87
Show file tree
Hide file tree
Showing 4 changed files with 410 additions and 3 deletions.
80 changes: 80 additions & 0 deletions api/src/paths/taxonomy/taxon/tsn/hierarchy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Ajv from 'ajv';
import chai, { expect } from 'chai';
import { describe } from 'mocha';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { GET } from '.';
import * as db from '../../../../database/db';
import { ItisService } from '../../../../services/itis-service';
import { getMockDBConnection, getRequestHandlerMocks } from '../../../../__mocks__/db';
import { getHierarchyForTSNs } from './hierarchy';

chai.use(sinonChai);

describe('taxonomy/taxon/tsn/hierarchy', () => {
describe('openapi schema', () => {
const ajv = new Ajv();

it('is valid openapi v3 schema', () => {
expect(ajv.validateSchema(GET.apiDoc as unknown as object)).to.be.true;
});
});

describe('getHierarchyForTSNs', () => {
afterEach(() => {
sinon.restore();
});

it('returns an empty array if no species are found', async () => {
const dbConnectionObj = getMockDBConnection();

sinon.stub(db, 'getDBConnection').returns(dbConnectionObj);

const getHierarchyForTSNsStub = sinon.stub(ItisService.prototype, 'getHierarchyForTSNs').resolves([]);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.query = {
tsn: ['1', '2']
};

const requestHandler = getHierarchyForTSNs();

await requestHandler(mockReq, mockRes, mockNext);

expect(getHierarchyForTSNsStub).to.have.been.calledWith([1, 2]);

expect(mockRes.statusValue).to.equal(200);
expect(mockRes.jsonValue).to.eql([]);
});

it('returns an array of species', async () => {
const dbConnectionObj = getMockDBConnection();

sinon.stub(db, 'getDBConnection').returns(dbConnectionObj);

const mock1 = {
tsn: 1,
commonNames: ['something'],
scientificName: 'string',
hierarchy: [3, 2, 1]
} as unknown as any;
const mock2 = { tsn: '2', commonNames: [], scientificName: 'string', hierarchy: [3, 2] } as unknown as any;

const getHierarchyForTSNsStub = sinon.stub(ItisService.prototype, 'getHierarchyForTSNs').resolves([mock1, mock2]);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.query = {
tsn: ['1', '2']
};

const requestHandler = getHierarchyForTSNs();

await requestHandler(mockReq, mockRes, mockNext);

expect(getHierarchyForTSNsStub).to.have.been.calledWith([1, 2]);

expect(mockRes.jsonValue).to.eql([mock1, mock2]);
expect(mockRes.statusValue).to.equal(200);
});
});
});
108 changes: 108 additions & 0 deletions api/src/paths/taxonomy/taxon/tsn/hierarchy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { RequestHandler } from 'express';
import { Operation } from 'express-openapi';
import { getAPIUserDBConnection } from '../../../../database/db';
import { ItisService } from '../../../../services/itis-service';
import { getLogger } from '../../../../utils/logger';

const defaultLog = getLogger('paths/taxonomy/taxon/tsn/hierarchy');

export const GET: Operation = [getHierarchyForTSNs()];

GET.apiDoc = {
description: 'Get taxon hierarchy information by TSN ids.',
tags: ['taxon_id'],
security: [],
parameters: [
{
description: 'Taxon TSN ids.',
in: 'query',
name: 'tsn',
schema: {
type: 'array',
description: 'One or more Taxon TSN ids.',
items: {
type: 'integer',
minimum: 0,
minItems: 1,
maxItems: 100
}
},
required: true
}
],
responses: {
200: {
description: 'Taxonomy response.',
content: {
'application/json': {
schema: {
type: 'array',
items: {
title: 'Species',
type: 'object',
description: 'Taxon hierarchy response object with an array of parent TSNs',
required: ['tsn', 'hierarchy'],
properties: {
tsn: {
type: 'integer'
},
hierarchy: {
type: 'array',
description:
'Array of parent TSNs in descending order, where the highest-ranking parent is first and the TSN for which the hierarchy was requested is last.',
items: { type: 'integer' }
}
},
additionalProperties: false
}
}
}
}
},
400: {
$ref: '#/components/responses/400'
},
401: {
$ref: '#/components/responses/401'
},
500: {
$ref: '#/components/responses/500'
},
default: {
$ref: '#/components/responses/default'
}
}
};

/**
* Get taxon by ITIS TSN.
*
* @returns {RequestHandler}
*/
export function getHierarchyForTSNs(): RequestHandler {
return async (req, res) => {
defaultLog.debug({ label: 'getHierarchyForTSNs', message: 'query params', query: req.query });

const connection = getAPIUserDBConnection();

const tsnIds: number[] = (req.query.tsn as string[]).map(Number);

try {
await connection.open();

const itisService = new ItisService();

const response = await itisService.getHierarchyForTSNs(tsnIds);

connection.commit();

res.status(200).json(response);
} catch (error) {
defaultLog.error({ label: 'getHierarchyForTSNs', message: 'error', error });
connection.rollback();
throw error;
} finally {
connection.release();
}
};
}
131 changes: 130 additions & 1 deletion api/src/services/itis-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import chai, { expect } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { ApiGeneralError } from '../errors/api-error';
import { ItisService } from './itis-service';
import { ItisService, ItisSolrSearchResponseHierarchy } from './itis-service';

chai.use(sinonChai);

Expand Down Expand Up @@ -182,6 +182,65 @@ describe('ItisService', () => {
});
});

describe('getHierarchyForTSNs', async () => {
it('returns array of hierarchy objects for tsns', async () => {
const mockTsns = [1, 2];
const mockTsnHierarchies = [
{ tsn: mockTsns[0], hierarchyTSN: ['$3$2$1$'] },
{ tsn: mockTsns[1], hierarchyTSN: ['$3$2$'] }
];
const mockAxiosResponse = {
data: {
response: {
docs: mockTsnHierarchies
}
}
};

const getItisSolrTsnHierarchyUrlStub = sinon
.stub(ItisService.prototype, 'getItisSolrTsnHierarchyUrl')
.returns('url');

const axiosStub = sinon.stub(axios, 'get').resolves(mockAxiosResponse);

const itisService = new ItisService();

const response = await itisService.getHierarchyForTSNs(mockTsns);

expect(getItisSolrTsnHierarchyUrlStub).to.have.been.calledWith(mockTsns);
expect(axiosStub).to.have.been.calledWith('url');
expect(response).to.eql([
{
tsn: mockTsns[0],
hierarchy: [3, 2, 1]
},
{
tsn: mockTsns[1],
hierarchy: [3, 2]
}
]);
});

it('catches and re-throws an error', async () => {
const mockTsns = [1, 2];
sinon.stub(axios, 'get').rejects(new Error('a test error'));

const itisService = new ItisService();
const getItisSolrTsnHierarchyUrlStub = sinon
.stub(ItisService.prototype, 'getItisSolrTsnHierarchyUrl')
.resolves('url');

try {
await itisService.getHierarchyForTSNs(mockTsns);

expect.fail();
} catch (error) {
expect((error as ApiGeneralError).message).to.equal('a test error');
expect(getItisSolrTsnHierarchyUrlStub).to.have.been.calledWith(mockTsns);
}
});
});

describe('getItisSolrTermSearchUrl', () => {
it('throws an error when itis solr url is not set', async () => {
process.env.ITIS_SOLR_URL = '';
Expand Down Expand Up @@ -210,6 +269,35 @@ describe('ItisService', () => {
});
});

describe('getItisSolrTsnHierarchyUrl', () => {
const mockTsns = [1];
it('throws an error when itis solr url is not set', async () => {
process.env.ITIS_SOLR_URL = '';

const itisService = new ItisService();

try {
await itisService.getItisSolrTsnHierarchyUrl(mockTsns);

expect.fail();
} catch (error) {
expect((error as ApiGeneralError).message).to.equal('Failed to build ITIS query.');
}
});

it('returns a valid url', async () => {
process.env.ITIS_SOLR_URL = 'https://services.itis.gov/';

const itisService = new ItisService();

const response = await itisService.getItisSolrTsnHierarchyUrl(mockTsns);

expect(response).to.equal(
'https://services.itis.gov/??wt=json&sort=kingdom+asc&rows=150&omitHeader=true&fl=tsn+hierarchyTSN&&q=tsn:1'
);
});
});

describe('getItisSolrTsnSearchUrl', () => {
it('throws an error when itis solr url is not set', async () => {
process.env.ITIS_SOLR_URL = '';
Expand Down Expand Up @@ -237,4 +325,45 @@ describe('ItisService', () => {
);
});
});

describe('getItisSolrTsnSearchUrl', () => {
it('throws an error when itis solr url is not set', async () => {
process.env.ITIS_SOLR_URL = '';

const itisService = new ItisService();

try {
await itisService.getItisSolrTsnSearchUrl([123]);

expect.fail();
} catch (error) {
expect((error as ApiGeneralError).message).to.equal('Failed to build ITIS query.');
}
});

it('returns a valid url', async () => {
process.env.ITIS_SOLR_URL = 'https://services.itis.gov/';

const itisService = new ItisService();

const response = await itisService.getItisSolrTsnSearchUrl([123]);

expect(response).to.equal(
'https://services.itis.gov/??wt=json&sort=kingdom+asc&rows=150&omitHeader=true&fl=tsn+scientificName:nameWOInd+kingdom+parentTSN+commonNames:vernacular+updateDate+usage+rank&&q=tsn:123'
);
});
});

describe('_sanitizeHierarchyData', () => {
it('turns an ITIS hierarchy string into an array'),
() => {
const mockData: ItisSolrSearchResponseHierarchy[] = [{ tsn: '1', hierarchyTSN: ['$3$2$1$'] }];

const itisService = new ItisService();

const result = itisService._sanitizeHierarchyData(mockData);

expect(result).to.eql([3, 2, 1]);
};
});
});
Loading

0 comments on commit 3fb2a87

Please sign in to comment.