Skip to content

Commit

Permalink
added species/tsn search endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
KjartanE committed Jan 27, 2024
1 parent 4df6210 commit fcc2c61
Show file tree
Hide file tree
Showing 8 changed files with 432 additions and 2 deletions.
7 changes: 5 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{
"git.ignoreLimitWarning": true
}
"git.ignoreLimitWarning": true,
"cSpell.words": [
"Itis"
]
}
93 changes: 93 additions & 0 deletions api/src/paths/taxonomy/itis/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { RequestHandler } from 'express';
import { Operation } from 'express-openapi';
import qs from 'qs';
import { TaxonomyService } from '../../../services/taxonomy-service';
import { getLogger } from '../../../utils/logger';

const defaultLog = getLogger('paths/taxonomy/itis/list');

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

GET.apiDoc = {
description: 'Gets the labels of the taxonomic units identified by the provided list of ids.',
tags: ['taxonomy'],
parameters: [
{
description: 'Taxonomy ids.',
in: 'query',
name: 'ids',
required: true,
schema: {
type: 'string'
}
}
],
responses: {
200: {
description: 'Taxonomy search response object.',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
searchResponse: {
type: 'array',
items: {
title: 'Species',
type: 'object',
required: ['id', 'label'],
properties: {
id: {
type: 'string'
},
label: {
type: 'string'
},
scientificName: {
type: 'string'
}
}
}
}
}
}
}
}
},
400: {
$ref: '#/components/responses/400'
},
500: {
$ref: '#/components/responses/500'
},
default: {
$ref: '#/components/responses/default'
}
}
};

/**
* Get taxonomic search results.
*
* @returns {RequestHandler}
*/
export function getSpeciesFromIds(): RequestHandler {
return async (req, res) => {
defaultLog.debug({ label: 'getSearchResults', message: 'request body', req_body: req.query });

const ids = Object.values(qs.parse(req.query.ids?.toString() || ''));

try {
const taxonomyService = new TaxonomyService();
const response = await taxonomyService.itisTsnSearch(ids as string[]);

// Overwrite default cache-control header, allow caching up to 7 days
res.setHeader('Cache-Control', 'max-age=604800');

res.status(200).json({ searchResponse: response });
} catch (error) {
defaultLog.error({ label: 'getSearchResults', message: 'error', error });
throw error;
}
};
}
97 changes: 97 additions & 0 deletions api/src/paths/taxonomy/itis/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Ajv from 'ajv';
import chai, { expect } from 'chai';
import { describe } from 'mocha';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import * as db from '../../../database/db';
import { HTTPError } from '../../../errors/http-error';
import { TaxonomyService } from '../../../services/taxonomy-service';
import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db';
import { GET, searchSpecies } from './search';

chai.use(sinonChai);

describe('search', () => {
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('searchSpecies', () => {
afterEach(() => {
sinon.restore();
});

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

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

const getSpeciesFromIdsStub = sinon.stub(TaxonomyService.prototype, 'itisTermSearch').resolves([]);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.query = {
terms: ''
};

const requestHandler = searchSpecies();

await requestHandler(mockReq, mockRes, mockNext);

expect(getSpeciesFromIdsStub).to.have.been.calledWith('');

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

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

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

const mock1 = { id: '1', label: 'something', scientificName: 'string' } as unknown as any;
const mock2 = { id: '2', label: 'anything', scientificName: 'string' } as unknown as any;

const getSpeciesFromIdsStub = sinon.stub(TaxonomyService.prototype, 'itisTermSearch').resolves([mock1, mock2]);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.query = {
terms: 't'
};

const requestHandler = searchSpecies();

await requestHandler(mockReq, mockRes, mockNext);

expect(getSpeciesFromIdsStub).to.have.been.calledWith('t');

expect(mockRes.jsonValue).to.eql({ searchResponse: [mock1, mock2] });
expect(mockRes.statusValue).to.equal(200);
});

it('catches error, and re-throws error', async () => {
const dbConnectionObj = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() });

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

sinon.stub(TaxonomyService.prototype, 'itisTermSearch').rejects(new Error('a test error'));

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.query = {
ids: 'a'
};

try {
const requestHandler = searchSpecies();

await requestHandler(mockReq, mockRes, mockNext);
expect.fail();
} catch (actualError) {
expect((actualError as HTTPError).message).to.equal('a test error');
}
});
});
});
93 changes: 93 additions & 0 deletions api/src/paths/taxonomy/itis/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { RequestHandler } from 'express';
import { Operation } from 'express-openapi';
import { TaxonomyService } from '../../../services/taxonomy-service';
import { getLogger } from '../../../utils/logger';

const defaultLog = getLogger('paths/taxonomy/itis/search');

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

GET.apiDoc = {
description: 'Gets a list of taxonomic units.',
tags: ['taxonomy'],
parameters: [
{
description: 'Taxonomy search parameters.',
in: 'query',
name: 'terms',
required: true,
schema: {
type: 'string'
}
}
],
responses: {
200: {
description: 'Taxonomy search response object.',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
searchResponse: {
type: 'array',
items: {
title: 'Species',
type: 'object',
required: ['id', 'label'],
properties: {
id: {
type: 'string'
},
label: {
type: 'string'
},
scientificName: {
type: 'string'
}
}
}
}
}
}
}
}
},
400: {
$ref: '#/components/responses/400'
},
500: {
$ref: '#/components/responses/500'
},
default: {
$ref: '#/components/responses/default'
}
}
};

/**
* Get taxonomic search results from itis.
*
* @returns {RequestHandler}
*/
export function searchSpecies(): RequestHandler {
return async (req, res) => {
defaultLog.debug({ label: 'getSearchResults', message: 'request params', req_params: req.query.terms });

const term = String(req.query.terms) || '';

try {
const taxonomyService = new TaxonomyService();

const response = await taxonomyService.itisTermSearch(term.toLowerCase());

// Overwrite default cache-control header, allow caching up to 7 days
res.setHeader('Cache-Control', 'max-age=604800');

res.status(200).json({ searchResponse: response });
} catch (error) {
defaultLog.error({ label: 'getSearchResults', message: 'error', error });
throw error;
}
};
}
61 changes: 61 additions & 0 deletions api/src/services/es-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export const ElasticSearchIndices = {
TAXONOMY: process.env.ELASTICSEARCH_TAXONOMY_INDEX || 'taxonomy_3.0.0'
};

export const ITIS_PARAMS = {
SORT: 'wt=json&sort=nameWOInd+asc&rows=25',
FILTER: 'omitHeader=true&fl=tsn+scientificName:nameWOInd+kingdom+parentTSN+commonNames:vernacular+updateDate+usage'
};

/**
* Base class for services that require a elastic search connection.
*
Expand Down Expand Up @@ -81,4 +86,60 @@ export class ESService {
fields: ['*']
});
}

/**
* Returns the ITIS search URL.
*
* @param {string} searchParams
* @return {*} {Promise<string>}
* @memberof ESService
*/
async getItisTermSearchUrl(searchParams: string): Promise<string> {
const itisUrl = process.env.ITIS_URL;
if (!itisUrl) {
throw new Error('ITIS_SEARCH_URL not defined.');
}
const itisSearchSpecies = this._getItisSearchSpeciesQuery(searchParams);

return `${itisUrl}?${ITIS_PARAMS.SORT}&${itisSearchSpecies}&${ITIS_PARAMS.FILTER}`;
}

/**
* Returns the ITIS search URL for TSN ids.
*
* @param {string[]} searchTsnIds
* @return {*} {Promise<string>}
* @memberof ESService
*/
async getItisTsnSearchUrl(searchTsnIds: string[]): Promise<string> {
const itisUrl = process.env.ITIS_URL;
if (!itisUrl) {
throw new Error('ITIS_SEARCH_URL not defined.');
}
const itisSearchSpecies = this._getItisTsnSearch(searchTsnIds);

return `${itisUrl}?${ITIS_PARAMS.SORT}&${ITIS_PARAMS.FILTER}&q=${itisSearchSpecies}`;
}

/**
* Returns the ITIS search species Query.
*
* @param {string} searchSpecies
* @return {*} {string}
* @memberof ESService
*/
_getItisSearchSpeciesQuery(searchSpecies: string): string {
return `q=(nameWOInd:*${searchSpecies}*+AND+usage:/(valid|accepted)/)+(vernacular:*${searchSpecies}*+AND+usage:/(valid|accepted)/)`;
}

/**
* Returns the ITIS search TSN Query.
*
* @param {string[]} searchTsnIds
* @return {*} {string}
* @memberof ESService
*/
_getItisTsnSearch(searchTsnIds: string[]): string {
return searchTsnIds.map((tsn) => `tsn:${tsn}`).join('+');
}
}
Loading

0 comments on commit fcc2c61

Please sign in to comment.