Skip to content

Commit

Permalink
SIMSBIOHUB-239: Proxy BCTW/Critterbase requests through SIMS backend …
Browse files Browse the repository at this point in the history
…using SIMS Service Account (#1077)

* Adds '/telemetry' endpoint to API for proxied BCTW requests.
* Adds new service to handle authenticated requests to Critterbase API.
* Adds new '/critter-data' endpoint to API for proxied Critterbase requests.
* Modifies Add Animal form's api hooks to point at /critter-data endpoint instead of Critterbase directly.
* Adds new method to Keycloak service for service account token retrieval.

---------

Co-authored-by: Mac Deluca <[email protected]>
Co-authored-by: Graham Stewart <[email protected]>
Co-authored-by: Alfred Rosenthal <[email protected]>
Co-authored-by: Curtis Upshall <[email protected]>
Co-authored-by: Nick Phura <[email protected]>
  • Loading branch information
6 people authored Aug 31, 2023
1 parent 68a6d84 commit df258a0
Show file tree
Hide file tree
Showing 49 changed files with 2,736 additions and 105 deletions.
6 changes: 6 additions & 0 deletions api/.pipeline/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ const phases = {
backboneIntakePath: '/api/dwc/submission/queue',
backboneArtifactIntakePath: '/api/artifact/intake',
backboneIntakeEnabled: false,
bctwApiHost: 'https://moe-bctw-api-dev.apps.silver.devops.gov.bc.ca',
critterbaseApiHost: 'https://moe-critterbase-api-dev.apps.silver.devops.gov.bc.ca/api',
env: 'dev',
elasticsearchURL: 'http://es01.a0ec71-dev:9200',
elasticsearchTaxonomyIndex: 'taxonomy_3.0.0',
Expand Down Expand Up @@ -113,6 +115,8 @@ const phases = {
backboneIntakePath: '/api/dwc/submission/queue',
backboneArtifactIntakePath: '/api/artifact/intake',
backboneIntakeEnabled: false,
bctwApiHost: 'https://moe-bctw-api-test.apps.silver.devops.gov.bc.ca',
critterbaseApiHost: 'https://moe-critterbase-api-test.apps.silver.devops.gov.bc.ca/api',
env: 'test',
elasticsearchURL: 'http://es01.a0ec71-dev:9200',
elasticsearchTaxonomyIndex: 'taxonomy_3.0.0',
Expand Down Expand Up @@ -143,6 +147,8 @@ const phases = {
backboneIntakePath: '/api/dwc/submission/queue',
backboneArtifactIntakePath: '/api/artifact/intake',
backboneIntakeEnabled: false,
bctwApiHost: 'https://moe-bctw-api-prod.apps.silver.devops.gov.bc.ca',
critterbaseApiHost: 'https://moe-critterbase-api-prod.apps.silver.devops.gov.bc.ca/api',
env: 'prod',
elasticsearchURL: 'http://es01.a0ec71-prod:9200',
elasticsearchTaxonomyIndex: 'taxonomy_3.0.0',
Expand Down
2 changes: 2 additions & 0 deletions api/.pipeline/lib/api.deploy.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const apiDeploy = async (settings) => {
BACKBONE_INTAKE_PATH: phases[phase].backboneIntakePath,
BACKBONE_ARTIFACT_INTAKE_PATH: phases[phase].backboneArtifactIntakePath,
BACKBONE_INTAKE_ENABLED: phases[phase].backboneIntakeEnabled,
BCTW_API_HOST: phases[phase].bctwApiHost,
CB_API_HOST: phases[phase].critterbaseApiHost,
NODE_ENV: phases[phase].env || 'dev',
ELASTICSEARCH_URL: phases[phase].elasticsearchURL,
ELASTICSEARCH_TAXONOMY_INDEX: phases[phase].elasticsearchTaxonomyIndex,
Expand Down
10 changes: 10 additions & 0 deletions api/.pipeline/templates/api.dc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ parameters:
- name: BACKBONE_API_HOST
required: true
description: API host for BioHub Platform Backbone. Example "https://platform.com".
- name: CB_API_HOST
required: true
description: API host for the Critterbase service, SIMS API will hit this to retrieve critter metadata. Example "https://critterbase.com".
- name: BCTW_API_HOST
required: true
description: API host for the BC Telemetry Warehouse service. SIMS API will hit this for device deployments and other telemetry operations. Example "https://bctw.com".
- name: BACKBONE_INTAKE_PATH
required: true
description: API path for BioHub Platform Backbone DwCA submission intake endpoint. Example "/api/path/to/intake".
Expand Down Expand Up @@ -190,6 +196,10 @@ objects:
value: ${APP_HOST}
- name: BACKBONE_API_HOST
value: ${BACKBONE_API_HOST}
- name: CB_API_HOST
value: ${CB_API_HOST}
- name: BCTW_API_HOST
value: ${BCTW_API_HOST}
- name: BACKBONE_INTAKE_PATH
value: ${BACKBONE_INTAKE_PATH}
- name: BACKBONE_ARTIFACT_INTAKE_PATH
Expand Down
64 changes: 64 additions & 0 deletions api/src/paths/critter-data/critters/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Ajv from 'ajv';
import chai, { expect } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { CritterbaseService, IBulkCreate } from '../../../services/critterbase-service';
import { getRequestHandlerMocks } from '../../../__mocks__/db';
import * as createCritter from './index';

chai.use(sinonChai);

describe('paths/critter-data/critters/post', () => {
const ajv = new Ajv();

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

const payload: IBulkCreate = {
critters: [],
captures: [],
mortalities: [],
locations: [],
markings: [],
qualitative_measurements: [],
quantitative_measurements: [],
families: [],
collections: []
};

describe('createCritter', () => {
afterEach(() => {
sinon.restore();
});
it('should succeed', async () => {
const mockCreateCritter = sinon.stub(CritterbaseService.prototype, 'createCritter').resolves({ count: 0 });
const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.body = payload;
const requestHandler = createCritter.createCritter();

await requestHandler(mockReq, mockRes, mockNext);

expect(mockCreateCritter).to.have.been.calledOnceWith(payload);
//expect(mockCreateCritter).calledWith(payload);
expect(mockRes.statusValue).to.equal(201);
expect(mockRes.json.calledWith({ count: 0 })).to.be.true;
});
it('should fail', async () => {
const mockError = new Error('mock error');
const mockCreateCritter = sinon.stub(CritterbaseService.prototype, 'createCritter').rejects(mockError);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.body = payload;
const requestHandler = createCritter.createCritter();

try {
await requestHandler(mockReq, mockRes, mockNext);
expect.fail();
} catch (actualError) {
expect(actualError).to.equal(mockError);
expect(mockCreateCritter).to.have.been.calledOnceWith(payload);
}
});
});
});
160 changes: 160 additions & 0 deletions api/src/paths/critter-data/critters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { RequestHandler } from 'express';
import { Operation } from 'express-openapi';
import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../constants/roles';
import { authorizeRequestHandler } from '../../../request-handlers/security/authorization';
import { CritterbaseService, ICritterbaseUser } from '../../../services/critterbase-service';
import { getLogger } from '../../../utils/logger';

const defaultLog = getLogger('paths/critter-data/critters');
export const POST: Operation = [
authorizeRequestHandler((req) => {
return {
or: [

Check warning on line 12 in api/src/paths/critter-data/critters/index.ts

View check run for this annotation

Codecov / codecov/patch

api/src/paths/critter-data/critters/index.ts#L11-L12

Added lines #L11 - L12 were not covered by tests
{
validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR],
projectId: Number(req.params.projectId),
discriminator: 'ProjectPermission'
},
{
validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR],
discriminator: 'SystemRole'
}
]
};
}),
createCritter()
];

POST.apiDoc = {
description:
'Creates a new critter in critterbase. Optionally make captures, markings, measurements, etc. along with it.',
tags: ['critterbase'],
security: [
{
Bearer: []
}
],
requestBody: {
description: 'Critterbase bulk creation request object',
content: {
'application/json': {
schema: {
title: 'Bulk post request object',
type: 'object',
properties: {
critters: {
title: 'critters',
type: 'array',
items: {
title: 'critter',
type: 'object'
}
},
captures: {
title: 'captures',
type: 'array',
items: {
title: 'capture',
type: 'object'
}
},
collections: {
title: 'collection units',
type: 'array',
items: {
title: 'collection unit',
type: 'object'
}
},
markings: {
title: 'markings',
type: 'array',
items: {
title: 'marking',
type: 'object'
}
},
locations: {
title: 'locations',
type: 'array',
items: {
title: 'location',
type: 'object'
}
},
mortalities: {
title: 'locations',
type: 'array',
items: {
title: 'location',
type: 'object'
}
},
qualitative_measurements: {
title: 'qualitative measurements',
type: 'array',
items: {
title: 'qualitative measurement',
type: 'object'
}
},
quantitative_measurements: {
title: 'quantitative measurements',
type: 'array',
items: {
title: 'quantitative measurement',
type: 'object'
}
}
}
}
}
}
},
responses: {
201: {
description: 'Responds with counts of objects created in critterbase.',
content: {
'application/json': {
schema: {
title: 'Bulk creation response object',
type: 'object'
}
}
}
},
400: {
$ref: '#/components/responses/400'
},
401: {
$ref: '#/components/responses/401'
},
403: {
$ref: '#/components/responses/401'
},
500: {
$ref: '#/components/responses/500'
},
default: {
$ref: '#/components/responses/default'
}
}
};

export function createCritter(): RequestHandler {
return async (req, res) => {
const user: ICritterbaseUser = {
keycloak_guid: req['system_user']?.user_guid,
username: req['system_user']?.user_identifier
};

const cb = new CritterbaseService(user);
try {
const result = await cb.createCritter(req.body);
return res.status(201).json(result);
} catch (error) {
defaultLog.error({ label: 'createCritter', message: 'error', error });
throw error;
}
};
}
68 changes: 68 additions & 0 deletions api/src/paths/critter-data/critters/{critterId}.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Ajv from 'ajv';
import chai, { expect } from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { CritterbaseService } from '../../../services/critterbase-service';
import { getRequestHandlerMocks } from '../../../__mocks__/db';
import * as critter from './{critterId}';

chai.use(sinonChai);

describe('paths/critter-data/critters/{critterId}', () => {
const ajv = new Ajv();

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

const mockCritter = {
critter_id: 'asdf',
wlh_id: '17-10748',
animal_id: '6',
sex: 'Female',
taxon: 'Caribou',
collection_units: [
{
category_name: 'Population Unit',
unit_name: 'Itcha-Ilgachuz',
collection_unit_id: '0284c4ca-a279-4135-b6ef-d8f4f8c3d1e6',
collection_category_id: '9dcf05a8-9bfe-421b-b487-ce65299441ca'
}
],
mortality_timestamp: new Date()
};

describe('getCritter', async () => {
afterEach(() => {
sinon.restore();
});
it('should succeed', async () => {
const mockGetCritter = sinon.stub(CritterbaseService.prototype, 'getCritter').resolves(mockCritter);
const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
mockReq.params = { critterId: 'asdf' };
const requestHandler = critter.getCritter();

await requestHandler(mockReq, mockRes, mockNext);

expect(mockGetCritter.calledOnce).to.be.true;
expect(mockGetCritter).calledWith('asdf');
expect(mockRes.statusValue).to.equal(200);
expect(mockRes.json.calledWith(mockCritter)).to.be.true;
});
it('should fail', async () => {
const mockError = new Error('mock error');
const mockGetCritter = sinon.stub(CritterbaseService.prototype, 'getCritter').rejects(mockError);

const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
const requestHandler = critter.getCritter();

try {
await requestHandler(mockReq, mockRes, mockNext);
expect.fail();
} catch (actualError) {
expect(actualError).to.equal(mockError);
expect(mockGetCritter.calledOnce).to.be.true;
}
});
});
});
Loading

0 comments on commit df258a0

Please sign in to comment.