diff --git a/api/.pipeline/config.js b/api/.pipeline/config.js index d8c104b6d8..975609512b 100644 --- a/api/.pipeline/config.js +++ b/api/.pipeline/config.js @@ -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', @@ -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', @@ -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', diff --git a/api/.pipeline/lib/api.deploy.js b/api/.pipeline/lib/api.deploy.js index 3ca8d141bd..d9781f7306 100644 --- a/api/.pipeline/lib/api.deploy.js +++ b/api/.pipeline/lib/api.deploy.js @@ -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, diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index 2718ad12fe..1b821fcd13 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -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". @@ -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 diff --git a/api/src/paths/critter-data/critters/index.test.ts b/api/src/paths/critter-data/critters/index.test.ts new file mode 100644 index 0000000000..e93af6b0db --- /dev/null +++ b/api/src/paths/critter-data/critters/index.test.ts @@ -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); + } + }); + }); +}); diff --git a/api/src/paths/critter-data/critters/index.ts b/api/src/paths/critter-data/critters/index.ts new file mode 100644 index 0000000000..944f50e4c2 --- /dev/null +++ b/api/src/paths/critter-data/critters/index.ts @@ -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: [ + { + 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; + } + }; +} diff --git a/api/src/paths/critter-data/critters/{critterId}.test.ts b/api/src/paths/critter-data/critters/{critterId}.test.ts new file mode 100644 index 0000000000..8bab9ca13b --- /dev/null +++ b/api/src/paths/critter-data/critters/{critterId}.test.ts @@ -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; + } + }); + }); +}); diff --git a/api/src/paths/critter-data/critters/{critterId}.ts b/api/src/paths/critter-data/critters/{critterId}.ts new file mode 100644 index 0000000000..dacdaf6187 --- /dev/null +++ b/api/src/paths/critter-data/critters/{critterId}.ts @@ -0,0 +1,94 @@ +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/{critterId}'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getCritter() +]; + +GET.apiDoc = { + description: 'Gets detailed information about a critter.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'critterId', + schema: { + type: 'string', + format: 'uuid' + }, + required: true + } + ], + responses: { + 200: { + description: 'Critter response object', + content: { + 'application/json': { + schema: { + title: 'Critter 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 getCritter(): 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.getCritter(req.params.critterId); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getCritter', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/family/index.test.ts b/api/src/paths/critter-data/family/index.test.ts new file mode 100644 index 0000000000..16fdbb3570 --- /dev/null +++ b/api/src/paths/critter-data/family/index.test.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { getFamilies } from '.'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; + +describe('getFamilies', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets families', async () => { + const mockFamilies = ['family1', 'family2']; + const mockGetFamilies = sinon.stub(CritterbaseService.prototype, 'getFamilies').resolves(mockFamilies); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getFamilies(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetFamilies.calledOnce).to.be.true; + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.json.calledWith(mockFamilies)).to.be.true; + }); + + it('handles error', async () => { + const mockError = new Error('mock error'); + const mockGetFamilies = sinon.stub(CritterbaseService.prototype, 'getFamilies').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getFamilies(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetFamilies.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/family/index.ts b/api/src/paths/critter-data/family/index.ts new file mode 100644 index 0000000000..66833d1d27 --- /dev/null +++ b/api/src/paths/critter-data/family/index.ts @@ -0,0 +1,87 @@ +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/family'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getFamilies() +]; + +GET.apiDoc = { + description: 'Gets a list of all families available in critterbase.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + responses: { + 200: { + description: 'Family response', + content: { + 'application/json': { + schema: { + title: 'Family response object', + type: 'array', + items: { + title: 'Family', + 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 getFamilies(): 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.getFamilies(); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getFamilies', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/family/{familyId}.test.ts b/api/src/paths/critter-data/family/{familyId}.test.ts new file mode 100644 index 0000000000..3bccc2a3b1 --- /dev/null +++ b/api/src/paths/critter-data/family/{familyId}.test.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getFamilyById } from './{familyId}'; + +describe('getFamilyById', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets a family by id', async () => { + const mockFamily = { id: '1', name: 'family1' }; + const mockGetFamilyById = sinon.stub(CritterbaseService.prototype, 'getFamilyById').resolves(mockFamily); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getFamilyById(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetFamilyById.calledOnce).to.be.true; + expect(mockRes.json.calledOnce).to.be.true; + expect(mockRes.json.args[0][0]).to.deep.equal(mockFamily); + }); + + it('handles errors', async () => { + const mockError = new Error('error'); + const mockGetFamilyById = sinon.stub(CritterbaseService.prototype, 'getFamilyById').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getFamilyById(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetFamilyById.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/family/{familyId}.ts b/api/src/paths/critter-data/family/{familyId}.ts new file mode 100644 index 0000000000..a5756b5970 --- /dev/null +++ b/api/src/paths/critter-data/family/{familyId}.ts @@ -0,0 +1,112 @@ +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'; + +// TODO: Put this all into an existing endpoint +const defaultLog = getLogger('paths/critter-data/family'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getFamilyById() +]; + +GET.apiDoc = { + description: 'Gets allowed values for colours in Critterbase.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'familyId', + schema: { + type: 'string', + format: 'uuid' + }, + required: true + } + ], + responses: { + 200: { + description: 'Family by ID response object', + content: { + 'application/json': { + schema: { + title: 'Family object', + type: 'object', + properties: { + parents: { + type: 'array', + items: { + title: 'Parent critter', + type: 'object' + } + }, + children: { + type: 'array', + items: { + title: 'Child critter', + 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 getFamilyById(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + + try { + const key: string = req.params.familyId; + const cb = new CritterbaseService(user); + const result = await cb.getFamilyById(key); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getFamilyById', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/lookups/{key}.test.ts b/api/src/paths/critter-data/lookups/{key}.test.ts new file mode 100644 index 0000000000..d6764309e2 --- /dev/null +++ b/api/src/paths/critter-data/lookups/{key}.test.ts @@ -0,0 +1,53 @@ +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 key from './{key}'; + +chai.use(sinonChai); + +describe('paths/critter-data/lookups/{key}', () => { + const ajv = new Ajv(); + + it('is valid openapi v3 schema', () => { + expect(ajv.validateSchema((key.GET.apiDoc as unknown) as object)).to.be.true; + }); + + const mockSelectOptions = [{ key: 'a', value: 'a', id: 'a' }]; + + describe('getSelectOptions', () => { + afterEach(() => { + sinon.restore(); + }); + it('should succeed', async () => { + const mockGetLookupValues = sinon + .stub(CritterbaseService.prototype, 'getLookupValues') + .resolves(mockSelectOptions); + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = key.getLookupValues(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetLookupValues.calledOnce).to.be.true; + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.json.calledWith(mockSelectOptions)).to.be.true; + }); + it('should fail', async () => { + const mockError = new Error('mock error'); + const mockGetLookupValues = sinon.stub(CritterbaseService.prototype, 'getLookupValues').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = key.getLookupValues(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetLookupValues.calledOnce).to.be.true; + } + }); + }); +}); diff --git a/api/src/paths/critter-data/lookups/{key}.ts b/api/src/paths/critter-data/lookups/{key}.ts new file mode 100644 index 0000000000..65696e70ee --- /dev/null +++ b/api/src/paths/critter-data/lookups/{key}.ts @@ -0,0 +1,129 @@ +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 { CbRouteKey, CritterbaseService, ICritterbaseUser } from '../../../services/critterbase-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/lookups'); +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getLookupValues() +]; + +GET.apiDoc = { + description: 'Gets allowed values for colours in Critterbase.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'key', + schema: { + type: 'string' + }, + required: true + }, + { + in: 'query', + name: 'format', + schema: { + type: 'string' + } + } + ], + responses: { + 200: { + description: 'Lookup response object', + content: { + 'application/json': { + schema: { + anyOf: [ + { + title: 'asSelect', + type: 'array', + items: { + type: 'object', + properties: { + key: { + type: 'string' + }, + id: { + type: 'string' + }, + value: { + type: 'string' + } + } + } + }, + { + title: 'Enum response', + type: 'array', + items: { + title: 'Enum value', + type: 'string' + } + } + ] + } + } + } + }, + 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 getLookupValues(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const key: CbRouteKey = req.params.key as CbRouteKey; + const cb = new CritterbaseService(user); + const params = []; + for (const [a, b] of Object.entries(req.query)) { + params.push({ key: String(a), value: String(b) }); + } + try { + const result = await cb.getLookupValues(key, params); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'lookups', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/signup.test.ts b/api/src/paths/critter-data/signup.test.ts new file mode 100644 index 0000000000..63d139918e --- /dev/null +++ b/api/src/paths/critter-data/signup.test.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../__mocks__/db'; +import { signUp } from './signup'; + +describe('signUp', () => { + afterEach(() => { + sinon.restore(); + }); + it('adds a user to critterbase and returns status 200', async () => { + const mockAddUser = sinon.stub(CritterbaseService.prototype, 'signUp').resolves({ message: 'User created' }); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = signUp(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockAddUser.calledOnce).to.be.true; + expect(mockRes.status.calledOnce).to.be.true; + expect(mockRes.status.args[0][0]).to.equal(200); + expect(mockRes.json.calledOnce).to.be.true; + expect(mockRes.json.args[0][0]).to.deep.equal({ message: 'User created' }); + }); + it('catches and re-throws error', async () => { + const mockError = new Error('mockError'); + const mockAddUser = sinon.stub(CritterbaseService.prototype, 'signUp').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = signUp(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockAddUser.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/signup.ts b/api/src/paths/critter-data/signup.ts new file mode 100644 index 0000000000..e4f5cfe48f --- /dev/null +++ b/api/src/paths/critter-data/signup.ts @@ -0,0 +1,82 @@ +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/signup'); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + signUp() +]; + +POST.apiDoc = { + description: 'Creates a new user in critterbase based on this SIMS user"s keycloak details.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + responses: { + 200: { + description: 'Responds with the users UUID in critterbase.', + content: { + 'application/json': { + schema: { + title: 'User 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 signUp(): 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.signUp(); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'signUp', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/xref/taxon-marking-body-locations.test.ts b/api/src/paths/critter-data/xref/taxon-marking-body-locations.test.ts new file mode 100644 index 0000000000..2a5bb46f09 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-marking-body-locations.test.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getTaxonBodyLocations } from './taxon-marking-body-locations'; + +describe('getTaxonBodyLocations', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets taxon body locations', async () => { + const mockTaxonBodyLocations = ['bodyLocation1', 'bodyLocation2']; + const mockGetTaxonBodyLocations = sinon + .stub(CritterbaseService.prototype, 'getTaxonBodyLocations') + .resolves(mockTaxonBodyLocations); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getTaxonBodyLocations(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetTaxonBodyLocations.calledOnce).to.be.true; + expect(mockRes.status.calledOnceWith(200)).to.be.true; + expect(mockRes.json.calledOnceWith(mockTaxonBodyLocations)).to.be.true; + }); + + it('handles errors', async () => { + const mockError = new Error('mock error'); + const mockGetTaxonBodyLocations = sinon + .stub(CritterbaseService.prototype, 'getTaxonBodyLocations') + .rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getTaxonBodyLocations(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetTaxonBodyLocations.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/xref/taxon-marking-body-locations.ts b/api/src/paths/critter-data/xref/taxon-marking-body-locations.ts new file mode 100644 index 0000000000..117222de12 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-marking-body-locations.ts @@ -0,0 +1,92 @@ +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'; +import { critterbaseCommonLookupResponse } from '../../../utils/shared-api-docs'; + +const defaultLog = getLogger('paths/critter-data/xref'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getTaxonBodyLocations() +]; + +GET.apiDoc = { + description: 'Gets allowed values for a particular taxons body locations in Critterbase.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'taxon_id', + schema: { + type: 'string', + format: 'uuid' + } + } + ], + responses: { + 200: { + description: 'Taxon marking body location response object', + content: { + 'application/json': { + schema: critterbaseCommonLookupResponse + } + } + }, + 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 getTaxonBodyLocations(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const taxon_id = String(req.query.taxon_id); + const cb = new CritterbaseService(user); + + try { + const result = await cb.getTaxonBodyLocations(taxon_id); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getTaxonBodyLocations', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/xref/taxon-measurements.test.ts b/api/src/paths/critter-data/xref/taxon-measurements.test.ts new file mode 100644 index 0000000000..e6318f3078 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-measurements.test.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getTaxonMeasurements } from './taxon-measurements'; + +describe('getTaxonMeasurements', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets taxon measurements', async () => { + const mockTaxonMeasurements = ['measurement1', 'measurement2']; + const mockGetTaxonMeasurements = sinon + .stub(CritterbaseService.prototype, 'getTaxonMeasurements') + .resolves(mockTaxonMeasurements); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getTaxonMeasurements(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetTaxonMeasurements.calledOnce).to.be.true; + expect(mockRes.statusValue).to.equal(200); + expect(mockRes.json.calledWith(mockTaxonMeasurements)).to.be.true; + }); + + it('handles errors', async () => { + const mockError = new Error('mock error'); + const mockGetTaxonMeasurements = sinon + .stub(CritterbaseService.prototype, 'getTaxonMeasurements') + .rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getTaxonMeasurements(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetTaxonMeasurements.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/xref/taxon-measurements.ts b/api/src/paths/critter-data/xref/taxon-measurements.ts new file mode 100644 index 0000000000..0fe0709004 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-measurements.ts @@ -0,0 +1,97 @@ +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/xref'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getTaxonMeasurements() +]; + +GET.apiDoc = { + description: 'Gets allowed values a particular taxons measurement types in Critterbase.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'taxon_id', + schema: { + type: 'string', + format: 'uuid' + } + } + ], + responses: { + 200: { + description: 'Lookup response object', + content: { + 'application/json': { + schema: { + title: 'Taxon measurements', + type: 'array', + items: { + title: 'Measurement type', + 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 getTaxonMeasurements(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const taxon_id = String(req.query.taxon_id); + const cb = new CritterbaseService(user); + try { + const result = await cb.getTaxonMeasurements(taxon_id); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getTaxonMeasurements', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.test.ts b/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.test.ts new file mode 100644 index 0000000000..6d20f7f297 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.test.ts @@ -0,0 +1,47 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { CritterbaseService } from '../../../services/critterbase-service'; +import { getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getQualMeasurementOptions } from './taxon-qualitative-measurement-options'; + +describe('getQualMeasurementOptions', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets qualitative measurement options', async () => { + const mockQualMeasurementOptions = ['qualMeasurementOption1', 'qualMeasurementOption2']; + const mockGetQualMeasurementOptions = sinon + .stub(CritterbaseService.prototype, 'getQualitativeOptions') + .resolves(mockQualMeasurementOptions); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getQualMeasurementOptions(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockGetQualMeasurementOptions.calledOnce).to.be.true; + expect(mockRes.status.calledOnce).to.be.true; + expect(mockRes.status.getCall(0).args[0]).to.equal(200); + expect(mockRes.json.calledOnce).to.be.true; + expect(mockRes.json.getCall(0).args[0]).to.deep.equal(mockQualMeasurementOptions); + }); + + it('handles errors', async () => { + const mockError = new Error('mockError'); + const mockGetQualMeasurementOptions = sinon + .stub(CritterbaseService.prototype, 'getQualitativeOptions') + .rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getQualMeasurementOptions(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetQualMeasurementOptions.calledOnce).to.be.true; + } + }); +}); diff --git a/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.ts b/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.ts new file mode 100644 index 0000000000..9393435e20 --- /dev/null +++ b/api/src/paths/critter-data/xref/taxon-qualitative-measurement-options.ts @@ -0,0 +1,98 @@ +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'; +import { critterbaseCommonLookupResponse } from '../../../utils/shared-api-docs'; + +const defaultLog = getLogger('paths/critter-data/xref'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + projectId: Number(req.params.projectId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getQualMeasurementOptions() +]; + +GET.apiDoc = { + description: 'Gets allowed values for a qualitative measurement, dependent on taxon.', + tags: ['critterbase'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'taxon_measurement_id', + schema: { + type: 'string', + format: 'uuid' + } + }, + { + in: 'query', + name: 'format', + schema: { + type: 'string' + } + } + ], + responses: { + 200: { + description: 'Allowed values for qualitative measurement', + content: { + 'application/json': { + schema: critterbaseCommonLookupResponse + } + } + }, + 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 getQualMeasurementOptions(): RequestHandler { + return async (req, res) => { + const user: ICritterbaseUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const taxon_id = String(req.query.taxon_measurement_id); + const cb = new CritterbaseService(user); + try { + const result = await cb.getQualitativeOptions(taxon_id); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getQualMeasurementOptions', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/paths/telemetry/vendors.test.ts b/api/src/paths/telemetry/vendors.test.ts new file mode 100644 index 0000000000..eb3622797f --- /dev/null +++ b/api/src/paths/telemetry/vendors.test.ts @@ -0,0 +1,42 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BctwService } from '../../services/bctw-service'; +import { getRequestHandlerMocks } from '../../__mocks__/db'; +import { getCollarVendors } from './vendors'; + +describe('getCollarVendors', () => { + afterEach(() => { + sinon.restore(); + }); + + it('gets collar vendors', async () => { + const mockVendors = ['vendor1', 'vendor2']; + const mockGetCollarVendors = sinon.stub(BctwService.prototype, 'getCollarVendors').resolves(mockVendors); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getCollarVendors(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockVendors); + expect(mockRes.statusValue).to.equal(200); + expect(mockGetCollarVendors).to.have.been.calledOnce; + }); + + it('catches and re-throws error', async () => { + const mockError = new Error('a test error'); + + const mockGetCollarVendors = sinon.stub(BctwService.prototype, 'getCollarVendors').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getCollarVendors(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(actualError).to.equal(mockError); + expect(mockGetCollarVendors).to.have.been.calledOnce; + } + }); +}); diff --git a/api/src/paths/telemetry/vendors.ts b/api/src/paths/telemetry/vendors.ts new file mode 100644 index 0000000000..2f8ad336dd --- /dev/null +++ b/api/src/paths/telemetry/vendors.ts @@ -0,0 +1,77 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; +import { BctwService, IBctwUser } from '../../services/bctw-service'; +import { getLogger } from '../../utils/logger'; + +const defaultLog = getLogger('paths/telemetry/vendors'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + getCollarVendors() +]; + +GET.apiDoc = { + description: 'Get a list of supported collar vendors.', + tags: ['telemetry'], + security: [ + { + Bearer: [] + } + ], + responses: { + 200: { + description: 'Collar vendors response object.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'string' + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function getCollarVendors(): RequestHandler { + return async (req, res) => { + const user: IBctwUser = { + keycloak_guid: req['system_user']?.user_guid, + username: req['system_user']?.user_identifier + }; + const bctwService = new BctwService(user); + try { + const result = await bctwService.getCollarVendors(); + return res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getCollarVendors', message: 'error', error }); + throw error; + } + }; +} diff --git a/api/src/services/bctw-service.test.ts b/api/src/services/bctw-service.test.ts new file mode 100755 index 0000000000..b4efad9ffa --- /dev/null +++ b/api/src/services/bctw-service.test.ts @@ -0,0 +1,214 @@ +import { AxiosResponse } from 'axios'; +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { + BctwService, + BCTW_API_HOST, + DEPLOY_DEVICE_ENDPOINT, + GET_COLLAR_VENDORS_ENDPOINT, + GET_DEPLOYMENTS_ENDPOINT, + HEALTH_ENDPOINT, + IDeployDevice, + IDeploymentUpdate, + UPDATE_DEPLOYMENT_ENDPOINT +} from './bctw-service'; +import { KeycloakService } from './keycloak-service'; + +chai.use(sinonChai); + +describe('BctwService', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockUser = { keycloak_guid: 'abc123', username: 'testuser' }; + + describe('getUserHeader', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should return a JSON string', async () => { + const bctwService = new BctwService(mockUser); + const result = bctwService.getUserHeader(); + expect(result).to.be.a('string'); + expect(JSON.parse(result)).to.deep.equal(mockUser); + }); + }); + + describe('getToken', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should return a string from the keycloak service', async () => { + const mockToken = 'abc123'; + const bctwService = new BctwService(mockUser); + const getKeycloakServiceTokenStub = sinon + .stub(KeycloakService.prototype, 'getKeycloakServiceToken') + .resolves(mockToken); + + const result = await bctwService.getToken(); + expect(result).to.equal(mockToken); + expect(getKeycloakServiceTokenStub).to.have.been.calledOnce; + }); + }); + + // describe('handleRequestError', () => { + // afterEach(() => { + // sinon.restore(); + // }); + + // it('should throw an error if the status is not 200', async () => { + // const bctwService = new BctwService(mockUser); + // const response = { data: 'data', status: 400 } as AxiosResponse; + // const endpoint = '/endpoint'; + // try { + // await bctwService.handleRequestError(response, endpoint); + // } catch (error) { + // expect((error as Error).message).to.equal( + // `API request to ${endpoint} failed with status code ${response.status}` + // ); + // } + // }); + + // it('should throw an error if the response has no data', async () => { + // const bctwService = new BctwService(mockUser); + // const response = { data: null, status: 200 } as AxiosResponse; + // const endpoint = '/endpoint'; + // try { + // await bctwService.handleRequestError(response, endpoint); + // } catch (error) { + // expect((error as Error).message).to.equal( + // `API request to ${endpoint} failed with status code ${response.status}` + // ); + // } + // }); + // }); + + describe('_makeGetRequest', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should make an axios get request', async () => { + const bctwService = new BctwService(mockUser); + const endpoint = '/endpoint'; + const mockResponse = { data: 'data' } as AxiosResponse; + + const mockAxios = sinon.stub(bctwService.axiosInstance, 'get').resolves(mockResponse); + + const result = await bctwService._makeGetRequest(endpoint); + + expect(result).to.equal(mockResponse.data); + expect(mockAxios).to.have.been.calledOnceWith(`${BCTW_API_HOST}${endpoint}`); + }); + + it('should make an axios get request with params', async () => { + const bctwService = new BctwService(mockUser); + const endpoint = '/endpoint'; + const queryParams = { param: 'param' }; + const mockResponse = { data: 'data' } as AxiosResponse; + + const mockAxios = sinon.stub(bctwService.axiosInstance, 'get').resolves(mockResponse); + + const result = await bctwService._makeGetRequest(endpoint, queryParams); + + expect(result).to.equal(mockResponse.data); + expect(mockAxios).to.have.been.calledOnceWith(`${BCTW_API_HOST}${endpoint}?param=${queryParams['param']}`); + }); + }); + + // describe('makePostPatchRequest', () => { + // afterEach(() => { + // sinon.restore(); + // }); + + // it('should make an axios post/patch request', async () => { + // const bctwService = new BctwService(mockUser); + // const endpoint = '/endpoint'; + // const mockResponse = { data: 'data' } as AxiosResponse; + + // const mockAxios = sinon.stub(bctwService.axiosInstance, 'post').resolves(mockResponse); + + // const result = await bctwService.makePostPatchRequest('post', endpoint, { foo: 'bar' }); + + // expect(result).to.equal(mockResponse.data); + // expect(mockAxios).to.have.been.calledOnce; + // }); + // }); + + describe('BctwService public methods', () => { + afterEach(() => { + sinon.restore(); + }); + + const bctwService = new BctwService(mockUser); + const mockDevice: IDeployDevice = { + device_id: 1, + frequency: 100, + manufacturer: 'Lotek', + model: 'model', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02', + critter_id: 'abc123' + }; + const mockDeployment: IDeploymentUpdate = { + deployment_id: 'adcd', + attachment_start: '2020-01-01', + attachment_end: '2020-01-02' + }; + + describe('deployDevice', () => { + it('should send a post request', async () => { + const mockAxios = sinon.stub(bctwService.axiosInstance, 'post'); + + await bctwService.deployDevice(mockDevice); + + expect(mockAxios).to.have.been.calledOnceWith(DEPLOY_DEVICE_ENDPOINT, mockDevice); + }); + }); + + describe('getDeployments', () => { + it('should send a get request', async () => { + const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); + + await bctwService.getDeployments(); + + expect(mockGetRequest).to.have.been.calledOnceWith(GET_DEPLOYMENTS_ENDPOINT); + }); + }); + + describe('updateDeployment', () => { + it('should send a patch request', async () => { + const mockAxios = sinon.stub(bctwService.axiosInstance, 'patch'); + + await bctwService.updateDeployment(mockDeployment); + + expect(mockAxios).to.have.been.calledOnceWith(UPDATE_DEPLOYMENT_ENDPOINT, mockDeployment); + }); + }); + + describe('getCollarVendors', () => { + it('should send a get request', async () => { + const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); + + await bctwService.getCollarVendors(); + + expect(mockGetRequest).to.have.been.calledOnceWith(GET_COLLAR_VENDORS_ENDPOINT); + }); + }); + + describe('getHealth', () => { + it('should send a get request', async () => { + const mockGetRequest = sinon.stub(bctwService, '_makeGetRequest'); + + await bctwService.getHealth(); + + expect(mockGetRequest).to.have.been.calledOnceWith(HEALTH_ENDPOINT); + }); + }); + }); +}); diff --git a/api/src/services/bctw-service.ts b/api/src/services/bctw-service.ts new file mode 100644 index 0000000000..cb65b86e0a --- /dev/null +++ b/api/src/services/bctw-service.ts @@ -0,0 +1,188 @@ +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { URLSearchParams } from 'url'; +import { z } from 'zod'; +import { ApiError, ApiErrorType } from '../errors/api-error'; +import { KeycloakService } from './keycloak-service'; + +export const IDeployDevice = z.object({ + device_id: z.number(), + frequency: z.number(), + manufacturer: z.string(), + model: z.string(), + attachment_start: z.string(), + attachment_end: z.string(), + critter_id: z.string() +}); + +export type IDeployDevice = z.infer; + +export const IDeploymentUpdate = z.object({ + deployment_id: z.string(), + attachment_start: z.string(), + attachment_end: z.string() +}); + +export type IDeploymentUpdate = z.infer; + +export const IDeploymentRecord = z.object({ + assignment_id: z.string(), + collar_id: z.string(), + critter_id: z.string(), + created_at: z.string(), + created_by_user_id: z.string(), + updated_at: z.string(), + updated_by_user_id: z.string(), + valid_from: z.string(), + valid_to: z.string(), + attachment_start: z.string(), + attachment_end: z.string(), + deployment_id: z.number() +}); + +export type IDeploymentRecord = z.infer; + +export const IBctwUser = z.object({ + keycloak_guid: z.string(), + username: z.string() +}); + +export type IBctwUser = z.infer; + +export const BCTW_API_HOST = process.env.BCTW_API_HOST || ''; +export const DEPLOY_DEVICE_ENDPOINT = '/deploy-device'; +export const GET_DEPLOYMENTS_ENDPOINT = '/get-deployments'; +export const UPDATE_DEPLOYMENT_ENDPOINT = '/update-deployment'; +export const GET_COLLAR_VENDORS_ENDPOINT = '/get-collar-vendors'; +export const HEALTH_ENDPOINT = '/health'; + +export class BctwService { + user: IBctwUser; + keycloak: KeycloakService; + axiosInstance: AxiosInstance; + + constructor(user: IBctwUser) { + this.user = user; + this.keycloak = new KeycloakService(); + this.axiosInstance = axios.create({ + headers: { + user: this.getUserHeader() + }, + baseURL: BCTW_API_HOST + }); + + this.axiosInstance.interceptors.response.use( + (response: AxiosResponse) => { + return response; + }, + (error: AxiosError) => { + return Promise.reject( + new ApiError(ApiErrorType.UNKNOWN, `API request failed with status code ${error?.response?.status}`) + ); + } + ); + + // Async request interceptor + this.axiosInstance.interceptors.request.use( + async (config) => { + const token = await this.getToken(); + config.headers['Authorization'] = `Bearer ${token}`; + + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + } + + /** + * Return user information as a JSON string. + * + * @return {*} {string} + * @memberof BctwService + */ + getUserHeader(): string { + return JSON.stringify(this.user); + } + + /** + * Retrieve an authentication token using Keycloak service. + * + * @return {*} {Promise} + * @memberof BctwService + */ + async getToken(): Promise { + const token = await this.keycloak.getKeycloakServiceToken(); + return token; + } + + /** + * Send an authorized get request to the BCTW API. + * + * @param {string} endpoint + * @param {Record} [queryParams] - An object containing query parameters as key-value pairs + * @return {*} + * @memberof BctwService + */ + async _makeGetRequest(endpoint: string, queryParams?: Record) { + let url = endpoint; + if (queryParams) { + const params = new URLSearchParams(queryParams); + url += `?${params.toString()}`; + } + const response = await this.axiosInstance.get(url); + return response.data; + } + + /** + * Create a new deployment for a telemetry device on a critter. + * + * @param {IDeployDevice} device + * @return {*} {Promise} + * @memberof BctwService + */ + async deployDevice(device: IDeployDevice): Promise { + return await this.axiosInstance.post(DEPLOY_DEVICE_ENDPOINT, device); + } + + /** + * Get all existing deployments. + * + * @return {*} {Promise} + * @memberof BctwService + */ + async getDeployments(): Promise { + return this._makeGetRequest(GET_DEPLOYMENTS_ENDPOINT); + } + + /** + * Update the start and end dates of an existing deployment. + * + * @param {IDeploymentUpdate} deployment + * @return {*} {Promise} + * @memberof BctwService + */ + async updateDeployment(deployment: IDeploymentUpdate): Promise { + return await this.axiosInstance.patch(UPDATE_DEPLOYMENT_ENDPOINT, deployment); + } + + /** + * Get a list of all supported collar vendors. + * + * @return {*} {Promise} + * @memberof BctwService + */ + async getCollarVendors(): Promise { + return this._makeGetRequest(GET_COLLAR_VENDORS_ENDPOINT); + } + + /** + * Get the health of the platform. + * + * @return {*} {Promise} + * @memberof BctwService + */ + async getHealth(): Promise { + return this._makeGetRequest(HEALTH_ENDPOINT); + } +} diff --git a/api/src/services/critterbase-service.test.ts b/api/src/services/critterbase-service.test.ts new file mode 100644 index 0000000000..85ad7e83e2 --- /dev/null +++ b/api/src/services/critterbase-service.test.ts @@ -0,0 +1,160 @@ +import { AxiosResponse } from 'axios'; +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { CritterbaseService, CRITTERBASE_API_HOST, IBulkCreate } from './critterbase-service'; +import { KeycloakService } from './keycloak-service'; + +chai.use(sinonChai); + +describe('CritterbaseService', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockUser = { keycloak_guid: 'abc123', username: 'testuser' }; + + describe('getUserHeader', () => { + const cb = new CritterbaseService(mockUser); + const result = cb.getUserHeader(); + expect(result).to.be.a('string'); + }); + + describe('getToken', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should return a string from the keycloak service', async () => { + const mockToken = 'abc123'; + const cb = new CritterbaseService(mockUser); + const getKeycloakServiceTokenStub = sinon + .stub(KeycloakService.prototype, 'getKeycloakServiceToken') + .resolves(mockToken); + + const result = await cb.getToken(); + expect(result).to.equal(mockToken); + expect(getKeycloakServiceTokenStub).to.have.been.calledOnce; + }); + }); + + describe('makeGetRequest', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should make an axios get request', async () => { + const cb = new CritterbaseService(mockUser); + const endpoint = '/endpoint'; + const mockResponse = { data: 'data' } as AxiosResponse; + + const mockAxios = sinon.stub(cb.axiosInstance, 'get').resolves(mockResponse); + + const result = await cb._makeGetRequest(endpoint, []); + + expect(result).to.equal(mockResponse.data); + expect(mockAxios).to.have.been.calledOnceWith(`${CRITTERBASE_API_HOST}${endpoint}?`); + }); + + it('should make an axios get request with params', async () => { + const cb = new CritterbaseService(mockUser); + const endpoint = '/endpoint'; + const queryParams = [{ key: 'param', value: 'param' }]; + const mockResponse = { data: 'data' } as AxiosResponse; + + const mockAxios = sinon.stub(cb.axiosInstance, 'get').resolves(mockResponse); + + const result = await cb._makeGetRequest(endpoint, queryParams); + + expect(result).to.equal(mockResponse.data); + expect(mockAxios).to.have.been.calledOnceWith(`${CRITTERBASE_API_HOST}${endpoint}?param=param`); + }); + }); + + describe('makePostPatchRequest', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should make an axios post/patch request', async () => { + const cb = new CritterbaseService(mockUser); + const endpoint = '/endpoint'; + const mockResponse = { data: 'data' } as AxiosResponse; + + const mockAxios = sinon.stub(cb.axiosInstance, 'post').resolves(mockResponse); + + const result = await cb.axiosInstance.post(endpoint, { foo: 'bar' }); + + expect(result).to.equal(mockResponse); + expect(mockAxios).to.have.been.calledOnce; + }); + }); + + describe('Critterbase service public methods', () => { + afterEach(() => { + sinon.restore(); + }); + + const cb = new CritterbaseService(mockUser); + + describe('getLookupValues', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getLookupValues('colours', []); + expect(mockGetRequest).to.have.been.calledOnceWith('lookups/colours', [{ key: 'format', value: 'asSelect ' }]); + }); + describe('getTaxonMeasurements', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getTaxonMeasurements('asdf'); + expect(mockGetRequest).to.have.been.calledOnceWith('xref/taxon-measurements', [ + { key: 'taxon_id', value: 'asdf ' } + ]); + }); + describe('getTaxonBodyLocations', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getTaxonBodyLocations('asdf'); + expect(mockGetRequest).to.have.been.calledOnceWith('xref/taxon-marking-body-locations', [ + { key: 'taxon_id', value: 'asdf' }, + { key: 'format', value: 'asSelect' } + ]); + }); + describe('getQualitativeOptions', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getQualitativeOptions('asdf'); + expect(mockGetRequest).to.have.been.calledOnceWith('xref/taxon-qualitative-measurement-options', [ + { key: 'taxon_measurement_id', value: 'asdf' }, + { key: 'format', value: 'asSelect' } + ]); + }); + describe('getFamilies', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getFamilies(); + expect(mockGetRequest).to.have.been.calledOnceWith('family', []); + }); + describe('getCritter', async () => { + const mockGetRequest = sinon.stub(cb, '_makeGetRequest'); + await cb.getCritter('asdf'); + expect(mockGetRequest).to.have.been.calledOnceWith('critters/' + 'asdf', [{ key: 'format', value: 'detail' }]); + }); + describe('createCritter', async () => { + const mockPostPatchRequest = sinon.stub(cb.axiosInstance, 'post'); + const data: IBulkCreate = { + locations: [{ latitude: 2, longitude: 2 }], + critters: [], + captures: [], + mortalities: [], + markings: [], + qualitative_measurements: [], + quantitative_measurements: [], + families: [], + collections: [] + }; + await cb.createCritter(data); + expect(mockPostPatchRequest).to.have.been.calledOnceWith('post', 'critters', data); + }); + describe('signUp', async () => { + const mockPostPatchRequest = sinon.stub(cb.axiosInstance, 'post'); + await cb.signUp(); + expect(mockPostPatchRequest).to.have.been.calledOnceWith('post', 'signup'); + }); + }); +}); diff --git a/api/src/services/critterbase-service.ts b/api/src/services/critterbase-service.ts new file mode 100644 index 0000000000..f8c5c7ce32 --- /dev/null +++ b/api/src/services/critterbase-service.ts @@ -0,0 +1,279 @@ +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { URLSearchParams } from 'url'; +import { ApiError, ApiErrorType } from '../errors/api-error'; +import { KeycloakService } from './keycloak-service'; + +export interface ICritterbaseUser { + username: string; + keycloak_guid: string; +} + +export interface QueryParam { + key: string; + value: string; +} + +export interface ICritter { + critter_id?: string; + wlh_id: string; + animal_id: string; + sex: string; + critter_comment: string; +} + +export interface ICapture { + capture_id?: string; + critter_id: string; + capture_location_id: string; + release_location_id: string; + capture_timestamp: string; + release_timestamp: string; + capture_comment: string; + release_comment: string; +} + +export interface IMortality { + mortality_id?: string; + critter_id: string; + location_id: string; + mortality_timestamp: string; + proximate_cause_of_death_id: string; + proximate_cause_of_death_confidence: string; + proximate_predated_by_taxon_id: string; + ultimate_cause_of_death_id: string; + ultimate_cause_of_death_confidence: string; + ultimate_predated_by_taxon_id: string; + mortality_comment: string; +} + +export interface ILocation { + location_id?: string; + latitude: number; + longitude: number; +} + +export interface IMarking { + marking_id?: string; + critter_id: string; + capture_id: string; + mortality_id: string; + taxon_marking_body_location_id: string; + marking_type_id: string; + marking_material_id: string; + primary_colour_id: string; + secondary_colour_id: string; + text_colour_id: string; + identifier: string; + frequency: number; + frequency_unit: string; + order: number; + comment: string; + attached_timestamp: string; + removed_timestamp: string; +} + +export interface IQualMeasurement { + measurement_qualitative_id?: string; + critter_id: string; + taxon_measurement_id: string; + capture_id?: string; + mortality_id?: string; + qualitative_option_id: string; + measurement_comment: string; + measured_timestamp: string; +} + +export interface IQuantMeasurement { + measurement_quantitative_id?: string; + taxon_measurement_id: string; + capture_id?: string; + mortality_id?: string; + value: number; + measurement_comment?: string; + measured_timestamp?: string; +} + +export interface IFamilyPayload { + families: { family_id: string; family_label: string }[]; + parents: { family_id: string; parent_critter_id: string }[]; + children: { family_id: string; child_critter_id: string }[]; +} + +export interface ICollection { + critter_collection_unit?: string; + critter_id: string; + collection_unit_id: string; +} + +export interface IBulkCreate { + critters: ICritter[]; + captures: ICapture[]; + collections: ICollection[]; + mortalities: IMortality[]; + locations: ILocation[]; + markings: IMarking[]; + quantitative_measurements: IQuantMeasurement[]; + qualitative_measurements: IQualMeasurement[]; + families: IFamilyPayload[]; +} + +export interface ICbSelectRows { + key: string; + id: string; + value: string; +} + +const lookups = '/lookups'; +const xref = '/xref'; +const lookupsEnum = lookups + '/enum'; +const lookupsTaxons = lookups + '/taxons'; +export const CbRoutes = { + // lookups + ['region-envs']: `${lookups}/region-envs`, + ['region_nrs']: `${lookups}/region-nrs`, + wmus: `${lookups}/wmus`, + cods: `${lookups}/cods`, + ['marking-materials']: `${lookups}/marking-materials`, + ['marking-types']: `${lookups}/marking-types`, + ['collection-categories']: `${lookups}/collection-unit-categories`, + taxons: lookupsTaxons, + species: `${lookupsTaxons}/species`, + colours: `${lookups}/colours`, + + // lookups/enum + sex: `${lookupsEnum}/sex`, + ['critter-status']: `${lookupsEnum}/critter-status`, + ['cause-of-death-confidence']: `${lookupsEnum}/cod-confidence`, + ['coordinate-uncertainty-unit']: `${lookupsEnum}/coordinate-uncertainty-unit`, + ['frequency-units']: `${lookupsEnum}/frequency-units`, + ['measurement-units']: `${lookupsEnum}/measurement-units`, + + // xref + ['collection-units']: `${xref}/collection-units`, + + // taxon xrefs + ['taxon-measurements']: `${xref}/taxon-measurements`, + ['taxon_qualitative_measurements']: `${xref}/taxon-qualitative-measurements`, + ['taxon-qualitative-measurement-options']: `${xref}/taxon-qualitative-measurement-options`, + ['taxon-quantitative-measurements']: `${xref}/taxon-quantitative-measurements`, + ['taxon-collection-categories']: `${xref}/taxon-collection-categories`, + ['taxon-marking-body-locations']: `${xref}/taxon-marking-body-locations` +} as const; + +export type CbRouteKey = keyof typeof CbRoutes; + +export const CRITTERBASE_API_HOST = process.env.CB_API_HOST || ``; +const CRITTER_ENDPOINT = '/critters'; +const BULK_ENDPOINT = '/bulk'; +const SIGNUP_ENDPOINT = '/signup'; +const FAMILY_ENDPOINT = '/family'; + +export class CritterbaseService { + user: ICritterbaseUser; + keycloak: KeycloakService; + axiosInstance: AxiosInstance; + + constructor(user: ICritterbaseUser) { + this.user = user; + this.keycloak = new KeycloakService(); + this.axiosInstance = axios.create({ + headers: { + user: this.getUserHeader() + }, + baseURL: CRITTERBASE_API_HOST + }); + + this.axiosInstance.interceptors.response.use( + (response: AxiosResponse) => { + return response; + }, + (error: AxiosError) => { + return Promise.reject( + new ApiError(ApiErrorType.UNKNOWN, `API request failed with status code ${error?.response?.status}`) + ); + } + ); + + // Async request interceptor + this.axiosInstance.interceptors.request.use( + async (config) => { + const token = await this.getToken(); + config.headers['Authorization'] = `Bearer ${token}`; + + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + } + + async getToken(): Promise { + const token = await this.keycloak.getKeycloakServiceToken(); + return token; + } + + /** + * Return user information as a JSON string. + * + * @return {*} {string} + * @memberof BctwService + */ + getUserHeader(): string { + return JSON.stringify(this.user); + } + + async _makeGetRequest(endpoint: string, params: QueryParam[]) { + const appendParams = new URLSearchParams(); + for (const p of params) { + appendParams.append(p.key, p.value); + } + const url = `${endpoint}?${appendParams.toString()}`; + const response = await this.axiosInstance.get(url); + return response.data; + } + + async getLookupValues(route: CbRouteKey, params: QueryParam[]) { + return this._makeGetRequest(CbRoutes[route], params); + } + + async getTaxonMeasurements(taxon_id: string) { + return this._makeGetRequest(CbRoutes['taxon-measurements'], [{ key: 'taxon_id', value: taxon_id }]); + } + + async getTaxonBodyLocations(taxon_id: string) { + return this._makeGetRequest(CbRoutes['taxon-marking-body-locations'], [ + { key: 'taxon_id', value: taxon_id }, + { key: 'format', value: 'asSelect' } + ]); + } + + async getQualitativeOptions(taxon_measurement_id: string, format = 'asSelect') { + return this._makeGetRequest(CbRoutes['taxon-qualitative-measurement-options'], [ + { key: 'taxon_measurement_id', value: taxon_measurement_id }, + { key: 'format', value: format } + ]); + } + + async getFamilies() { + return this._makeGetRequest(FAMILY_ENDPOINT, []); + } + + async getFamilyById(family_id: string) { + return this._makeGetRequest(`${FAMILY_ENDPOINT}/${family_id}`, []); + } + + async getCritter(critter_id: string) { + return this._makeGetRequest(`${CRITTER_ENDPOINT}/${critter_id}`, [{ key: 'format', value: 'detail' }]); + } + + async createCritter(data: IBulkCreate) { + const response = await this.axiosInstance.post(BULK_ENDPOINT, data); + return response.data; + } + + async signUp() { + const response = await this.axiosInstance.post(SIGNUP_ENDPOINT); + return response.data; + } +} diff --git a/api/src/services/keycloak-service.ts b/api/src/services/keycloak-service.ts index 9da4af9e3f..9e221d0250 100644 --- a/api/src/services/keycloak-service.ts +++ b/api/src/services/keycloak-service.ts @@ -57,6 +57,11 @@ const defaultLog = getLogger('services/keycloak-service'); * @class KeycloakService */ export class KeycloakService { + // Used to authenticate with the SIMS Service Credentials + keycloakHost: string; + keycloakServiceClientId: string; + keycloakServiceClientSecret: string; + // Used to authenticate with the CSS API using the SIMS API credentials keycloakApiTokenUrl: string; keycloakApiClientId: string; @@ -68,6 +73,10 @@ export class KeycloakService { keycloakEnvironment: string; constructor() { + this.keycloakHost = `${process.env.KEYCLOAK_HOST}`; + this.keycloakServiceClientId = `${process.env.KEYCLOAK_ADMIN_USERNAME}`; + this.keycloakServiceClientSecret = `${process.env.KEYCLOAK_ADMIN_PASSWORD}`; + this.keycloakApiTokenUrl = `${process.env.KEYCLOAK_API_TOKEN_URL}`; this.keycloakApiClientId = `${process.env.KEYCLOAK_API_CLIENT_ID}`; this.keycloakApiClientSecret = `${process.env.KEYCLOAK_API_CLIENT_SECRET}`; @@ -78,7 +87,36 @@ export class KeycloakService { } /** - * Get an access token from keycloak for the service account user. + * Get an access token from keycloak for the SIMS Service account. + * + * @return {*} {Promise} + * @memberof KeycloakService + */ + async getKeycloakServiceToken(): Promise { + try { + const { data } = await axios.post( + `${this.keycloakHost}/realms/standard/protocol/openid-connect/token`, + qs.stringify({ + grant_type: 'client_credentials', + client_id: this.keycloakServiceClientId, + client_secret: this.keycloakServiceClientSecret + }), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + + return data.access_token as string; + } catch (error) { + defaultLog.debug({ label: 'getKeycloakServiceToken', message: 'error', error: error }); + throw new ApiGeneralError('Failed to authenticate with keycloak', [(error as Error).message]); + } + } + + /** + * Get an access token from keycloak for the sims-team account user. * * @return {*} {Promise} * @memberof KeycloakService diff --git a/api/src/utils/shared-api-docs.ts b/api/src/utils/shared-api-docs.ts index 3de10aa287..89170780b3 100644 --- a/api/src/utils/shared-api-docs.ts +++ b/api/src/utils/shared-api-docs.ts @@ -1,3 +1,5 @@ +import { SchemaObject } from 'ajv'; + export const attachmentApiDocObject = (basicDescription: string, successDescription: string) => { return { description: basicDescription, @@ -45,3 +47,22 @@ export const attachmentApiDocObject = (basicDescription: string, successDescript } }; }; + +export const critterbaseCommonLookupResponse: SchemaObject = { + title: 'asSelect', + type: 'array', + items: { + type: 'object', + properties: { + key: { + type: 'string' + }, + id: { + type: 'string' + }, + value: { + type: 'string' + } + } + } +}; diff --git a/app/src/components/fields/CbSelectField.tsx b/app/src/components/fields/CbSelectField.tsx index a1193b4da2..dacd6d1611 100644 --- a/app/src/components/fields/CbSelectField.tsx +++ b/app/src/components/fields/CbSelectField.tsx @@ -1,6 +1,6 @@ import { FormControlProps, MenuItem, SelectChangeEvent } from '@mui/material'; import { useFormikContext } from 'formik'; -import { ICbRouteKey, ICbSelectRows } from 'hooks/cb_api/useLookupApi'; +import { ICbSelectRows } from 'hooks/cb_api/useLookupApi'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; import useIsMounted from 'hooks/useIsMounted'; @@ -16,7 +16,7 @@ export interface ICbSelectSharedProps { export interface ICbSelectField extends ICbSelectSharedProps { id: string; - route: ICbRouteKey; + route: string; param?: string; query?: string; handleChangeSideEffect?: (value: string, label: string) => void; diff --git a/app/src/contexts/configContext.tsx b/app/src/contexts/configContext.tsx index 29c31204bf..87e4b0ada5 100644 --- a/app/src/contexts/configContext.tsx +++ b/app/src/contexts/configContext.tsx @@ -5,7 +5,6 @@ import { ensureProtocol } from 'utils/Utils'; export interface IConfig { API_HOST: string; - CB_API_HOST: string; CHANGE_VERSION: string; NODE_ENV: string; REACT_APP_NODE_ENV: string; @@ -19,7 +18,6 @@ export interface IConfig { export const ConfigContext = React.createContext({ API_HOST: '', - CB_API_HOST: '', CHANGE_VERSION: '', NODE_ENV: '', REACT_APP_NODE_ENV: '', @@ -44,17 +42,12 @@ const getLocalConfig = (): IConfig => { const API_HOST = process.env.REACT_APP_API_HOST; const API_PORT = process.env.REACT_APP_API_PORT; - const CB_API_HOST = process.env.REACT_APP_CRITTERBASE_API_HOST; - const CB_API_PORT = process.env.REACT_APP_CRITTERBASE_API_PORT; - const API_URL = (API_PORT && `${API_HOST}:${API_PORT}`) || API_HOST || 'localhost'; - const CB_API_URL = (CB_API_PORT && `${CB_API_HOST}:${CB_API_PORT}`) || CB_API_HOST || 'localhost'; const OBJECT_STORE_URL = process.env.OBJECT_STORE_URL || 'nrs.objectstore.gov.bc.ca'; const OBJECT_STORE_BUCKET_NAME = process.env.OBJECT_STORE_BUCKET_NAME || 'gblhvt'; return { API_HOST: ensureProtocol(API_URL, 'http://'), - CB_API_HOST: ensureProtocol(CB_API_URL, 'http://'), CHANGE_VERSION: process.env.CHANGE_VERSION || 'NA', NODE_ENV: process.env.NODE_ENV, REACT_APP_NODE_ENV: process.env.REACT_APP_NODE_ENV || 'dev', diff --git a/app/src/features/surveys/view/SurveyAnimals.tsx b/app/src/features/surveys/view/SurveyAnimals.tsx index fbc53006f1..6e7ae78cc7 100644 --- a/app/src/features/surveys/view/SurveyAnimals.tsx +++ b/app/src/features/surveys/view/SurveyAnimals.tsx @@ -40,12 +40,12 @@ const SurveyAnimals: React.FC = () => { const handleOnSave = async (animal: IAnimal) => { const critter = new Critter(animal); const postCritterPayload = async () => { - const res = await cbApi.critters.createCritter(critter); + await cbApi.critters.createCritter(critter); dialogContext.setSnackbar({ open: true, snackbarMessage: ( - {`${pluralize('Animal', res.count)} added to Survey`} + {`Animal added to Survey`} ) }); @@ -54,10 +54,7 @@ const SurveyAnimals: React.FC = () => { try { await postCritterPayload(); } catch (err) { - //Temp solution for keycloak timeout bug - //TODO fix this in useAxios or higher level component. - //This error can occur when keycloak token refresh happens - await postCritterPayload(); + console.log(`Critter submission error ${JSON.stringify(err)}`); } }; @@ -104,5 +101,4 @@ const SurveyAnimals: React.FC = () => { ); }; - export default SurveyAnimals; diff --git a/app/src/features/surveys/view/SurveyPage.tsx b/app/src/features/surveys/view/SurveyPage.tsx index 210413b501..bf7450b173 100644 --- a/app/src/features/surveys/view/SurveyPage.tsx +++ b/app/src/features/surveys/view/SurveyPage.tsx @@ -7,7 +7,6 @@ import SurveySubmissionAlertBar from 'components/publish/SurveySubmissionAlertBa import { SystemRoleGuard } from 'components/security/Guards'; import { SYSTEM_ROLE } from 'constants/roles'; import { CodesContext } from 'contexts/codesContext'; -import { ConfigContext } from 'contexts/configContext'; import { SurveyContext } from 'contexts/surveyContext'; import SurveyDetails from 'features/surveys/view/SurveyDetails'; import React, { useContext, useEffect } from 'react'; @@ -26,7 +25,6 @@ import SurveyHeader from './SurveyHeader'; * @return {*} */ const SurveyPage: React.FC = () => { - const config = useContext(ConfigContext); const codesContext = useContext(CodesContext); const surveyContext = useContext(SurveyContext); @@ -66,14 +64,11 @@ const SurveyPage: React.FC = () => { - {/* Temporarily hiding section while integrating */} - {config?.CB_API_HOST ? ( - - - - - - ) : null} + + + + + diff --git a/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx index f4c72c025a..8b71888fac 100644 --- a/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/IndividualAnimalForm.tsx @@ -1,7 +1,7 @@ import { Typography } from '@mui/material'; import FormikDevDebugger from 'components/formik/FormikDevDebugger'; import { Form, useFormikContext } from 'formik'; -import React, { useEffect } from 'react'; +import { useEffect } from 'react'; import { Critter, IAnimal } from './animal'; import CaptureAnimalForm from './form-sections/CaptureAnimalForm'; import FamilyAnimalForm from './form-sections/FamilyAnimalForm'; diff --git a/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx index e1287aa969..65bc776455 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/GeneralAnimalForm.tsx @@ -36,7 +36,7 @@ const GeneralAnimalForm = () => { controlProps={{ required: isRequiredInSchema(AnimalGeneralSchema, 'taxon_id'), size: 'small' }} label={'Taxon'} id={'taxon'} - route={'taxons'} + route={'lookups/taxons'} handleChangeSideEffect={handleTaxonName} /> diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx index a8e16c80b7..9042e375df 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/MarkingAnimalForm.tsx @@ -5,7 +5,7 @@ import { SurveyAnimalsI18N } from 'constants/i18n'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; -import React, { Fragment, useEffect } from 'react'; +import { Fragment, useEffect } from 'react'; import { v4 } from 'uuid'; import { AnimalMarkingSchema, @@ -68,7 +68,7 @@ const MarkingAnimalForm = () => { label="Marking Type" name={getAnimalFieldName(name, 'marking_type_id', index)} id="marking_type" - route="marking_type" + route="lookups/marking-types" controlProps={{ size: 'small', required: isRequiredInSchema(AnimalMarkingSchema, 'marking_type_id') @@ -80,7 +80,7 @@ const MarkingAnimalForm = () => { label="Marking Body Location" name={getAnimalFieldName(name, 'taxon_marking_body_location_id', index)} id="marking_body_location" - route="taxon_marking_body_locations" + route="xref/taxon-marking-body-locations" query={`taxon_id=${values.general.taxon_id}`} controlProps={{ size: 'small', @@ -93,7 +93,7 @@ const MarkingAnimalForm = () => { label="Primary Colour" name={getAnimalFieldName(name, 'primary_colour_id', index)} id="primary_colour_id" - route="colours" + route="lookups/colours" controlProps={{ size: 'small', required: isRequiredInSchema(AnimalMarkingSchema, 'primary_colour_id') @@ -105,7 +105,7 @@ const MarkingAnimalForm = () => { label="Secondary Colour" name={getAnimalFieldName(name, 'secondary_colour_id', index)} id="secondary_colour_id" - route="colours" + route="lookups/colours" controlProps={{ size: 'small', required: isRequiredInSchema(AnimalMarkingSchema, 'secondary_colour_id') diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx index 72a8ae8b1b..ad8c8898b6 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/MeasurementAnimalForm.tsx @@ -9,7 +9,7 @@ import { IMeasurementStub } from 'hooks/cb_api/useLookupApi'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; import { has } from 'lodash-es'; -import React, { Fragment, useEffect, useState } from 'react'; +import { Fragment, useEffect, useState } from 'react'; import { v4 } from 'uuid'; import { AnimalMeasurementSchema, @@ -156,7 +156,7 @@ const MeasurementFormContent = ({ index, measurements }: MeasurementFormContentP label="Value" name={optionName} id="qualitative_option" - route="taxon_qualitative_measurement_options" + route="xref/taxon-qualitative-measurement-options" query={`taxon_measurement_id=${taxonMeasurementId}`} controlProps={{ size: 'small', diff --git a/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx b/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx index 347403dd22..00a2009e3b 100644 --- a/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx +++ b/app/src/features/surveys/view/survey-animals/form-sections/MortalityAnimalForm.tsx @@ -109,7 +109,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_pcod_reason') }} id={`${index}-pcod-reason`} - route={'cod'} + route={'lookups/cods'} /> @@ -121,7 +121,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_pcod_confidence') }} id={`${index}-pcod-confidence`} - route={'cause_of_death_confidence'} + route={'lookups/cause-of-death-confidence'} /> @@ -134,7 +134,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_pcod_taxon_id') }} id={`${index}-pcod-taxon`} - route={'taxons'} + route={'lookups/taxons'} /> @@ -149,7 +149,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_ucod_reason') }} id={`${index}-ucod-reason`} - route={'cod'} + route={'lookups/cods'} /> @@ -161,7 +161,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_ucod_confidence') }} id={`${index}-ucod-confidence`} - route={'cause_of_death_confidence'} + route={'lookups/cause-of-death-confidence'} /> @@ -174,7 +174,7 @@ const MortalityAnimalFormContent = ({ name, index, value }: MortalityAnimalFormC required: isRequiredInSchema(AnimalMortalitySchema, 'mortality_ucod_taxon_id') }} id={`${index}-ucod-taxon`} - route={'taxons'} + route={'lookups/taxons'} /> diff --git a/app/src/hooks/cb_api/useAuthenticationApi.test.tsx b/app/src/hooks/cb_api/useAuthenticationApi.test.tsx new file mode 100644 index 0000000000..b1097435a0 --- /dev/null +++ b/app/src/hooks/cb_api/useAuthenticationApi.test.tsx @@ -0,0 +1,28 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { v4 } from 'uuid'; +import { useAuthentication } from './useAuthenticationApi'; + +describe('useAuthenticationApi', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + const mockUuid = v4(); + + it('basic success case', async () => { + mock.onPost('/api/critter-data/signup').reply(200, { + user_id: mockUuid + }); + + const result = await useAuthentication(axios).signUp(); + + expect(result?.user_id).toBe(mockUuid); + }); +}); diff --git a/app/src/hooks/cb_api/useAuthenticationApi.tsx b/app/src/hooks/cb_api/useAuthenticationApi.tsx index 1095bd0d25..cbda33f22b 100644 --- a/app/src/hooks/cb_api/useAuthenticationApi.tsx +++ b/app/src/hooks/cb_api/useAuthenticationApi.tsx @@ -2,8 +2,15 @@ import { AxiosInstance } from 'axios'; const useAuthentication = (axios: AxiosInstance) => { const signUp = async (): Promise<{ user_id: string } | null> => { - const { data } = await axios.post('/api/signup'); - return data; + try { + const { data } = await axios.post('/api/critter-data/signup'); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return null; }; return { diff --git a/app/src/hooks/cb_api/useCritterApi.test.tsx b/app/src/hooks/cb_api/useCritterApi.test.tsx new file mode 100644 index 0000000000..a7da80bee3 --- /dev/null +++ b/app/src/hooks/cb_api/useCritterApi.test.tsx @@ -0,0 +1,81 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { Critter, IAnimal } from 'features/surveys/view/survey-animals/animal'; +import { v4 } from 'uuid'; +import { useCritterApi } from './useCritterApi'; + +describe('useCritterApi', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + const mockId = v4(); + + const mockCritter = { + critter_id: mockId, + 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() + }; + + /*it('should fetch an array of critter objects', async () => { + mock.onGet('/api/critter-data/critters').reply(200, [mockCritter]); + + const result = await useCritterApi(axios).getAllCritters(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0].critter_id).toBeDefined(); + });*/ + + it('should fetch a single critter by id', async () => { + mock.onGet('/api/critter-data/critters/' + mockId).reply(200, mockCritter); + + const result = await useCritterApi(axios).getCritterByID(mockId); + expect(result.critter_id).toBe(mockId); + expect(typeof result.wlh_id).toBe('string'); + expect(typeof result.sex).toBe('string'); + expect(typeof result.taxon).toBe('string'); + expect(Array.isArray(result.collection_units)).toBe(true); + expect(typeof result.mortality_timestamp).toBe('string'); + }); + + it('should create a critter in critterbase', async () => { + const forCritter: IAnimal = { + ...mockCritter, + captures: [], + markings: [], + measurements: [], + general: { + taxon_id: mockCritter.taxon, + animal_id: mockCritter.animal_id, + taxon_name: 'Joe' + }, + mortality: [], + family: [], + images: [], + device: undefined + }; + const payload = new Critter(forCritter); + + mock.onPost('/api/critter-data/critters').reply(201, { count: 1 }); + + const result = await useCritterApi(axios).createCritter(payload); + expect(result.count).toBe(1); + }); +}); diff --git a/app/src/hooks/cb_api/useCritterApi.tsx b/app/src/hooks/cb_api/useCritterApi.tsx index b99faab49b..63f7c48275 100644 --- a/app/src/hooks/cb_api/useCritterApi.tsx +++ b/app/src/hooks/cb_api/useCritterApi.tsx @@ -3,13 +3,27 @@ import { Critter } from 'features/surveys/view/survey-animals/animal'; const useCritterApi = (axios: AxiosInstance) => { const getAllCritters = async (): Promise[]> => { - const { data } = await axios.get('/api/critters'); - return data; + try { + const { data } = await axios.get('/api/critter-data/critters'); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return []; }; const getCritterByID = async (critter_id: string): Promise> => { - const { data } = await axios.get('/api/critters/' + critter_id); - return data; + try { + const { data } = await axios.get(`/api/critter-data/critters/${critter_id}`); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return {}; }; const createCritter = async (critter: Critter): Promise<{ count: number }> => { @@ -21,7 +35,7 @@ const useCritterApi = (axios: AxiosInstance) => { quantitative_measurements: critter.measurements.quantitative, ...critter }; - const { data } = await axios.post('/api/bulk', payload); + const { data } = await axios.post('/api/critter-data/critters', payload); return data; }; diff --git a/app/src/hooks/cb_api/useFamilyApi.test.tsx b/app/src/hooks/cb_api/useFamilyApi.test.tsx new file mode 100644 index 0000000000..a98cbf5ab5 --- /dev/null +++ b/app/src/hooks/cb_api/useFamilyApi.test.tsx @@ -0,0 +1,42 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { v4 } from 'uuid'; +import { useFamilyApi } from './useFamilyApi'; + +describe('useFamily', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + const family = { + family_id: v4(), + family_label: 'fam' + }; + + const immediateFamily = { + parents: [], + children: [] + }; + + it('should return a list of families', async () => { + mock.onGet('/api/critter-data/family').reply(200, [family]); + const result = await useFamilyApi(axios).getAllFamilies(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0].family_id).toBeDefined(); + }); + + it('should return an immediate family by id', async () => { + const familyId = v4(); + mock.onGet('/api/critter-data/family/' + familyId).reply(200, immediateFamily); + const result = await useFamilyApi(axios).getImmediateFamily(familyId); + expect(Array.isArray(result.parents)).toBe(true); + expect(Array.isArray(result.children)).toBe(true); + }); +}); diff --git a/app/src/hooks/cb_api/useFamilyApi.tsx b/app/src/hooks/cb_api/useFamilyApi.tsx index 84f547c418..428e9a7dae 100644 --- a/app/src/hooks/cb_api/useFamilyApi.tsx +++ b/app/src/hooks/cb_api/useFamilyApi.tsx @@ -7,12 +7,19 @@ export type IFamily = { const useFamilyApi = (axios: AxiosInstance) => { const getAllFamilies = async (): Promise => { - const { data } = await axios.get('/api/family'); - return data; + try { + const { data } = await axios.get('/api/critter-data/family'); + return data; + } catch (e) { + if (e instanceof Error) { + console.log(e.message); + } + } + return []; }; const getImmediateFamily = async (family_id: string): Promise<{ parents: any[]; siblings: any[]; children: any }> => { - const { data } = await axios.get('/api/family/' + family_id); + const { data } = await axios.get(`/api/critter-data/family/${family_id}`); return data; }; diff --git a/app/src/hooks/cb_api/useLookupApi.test.tsx b/app/src/hooks/cb_api/useLookupApi.test.tsx new file mode 100644 index 0000000000..7ce0cb21d1 --- /dev/null +++ b/app/src/hooks/cb_api/useLookupApi.test.tsx @@ -0,0 +1,57 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { v4 } from 'uuid'; +import { ICbSelectRows, useLookupApi } from './useLookupApi'; + +describe('useLookup', () => { + let mock: any; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + const mockLookup = [ + { + key: 'colour_id', + id: '7a516697-c7ee-43b3-9e17-2fc31572d819', + value: 'Blue' + } + ]; + + const mockMeasurement = [ + { + taxon_measurement_id: '29425067-e5ea-4284-b629-26c3cac4cbbf', + taxon_id: '0db0129f-5969-4892-824d-459e5ac38dc2', + measurement_name: 'Life Stage', + measurement_desc: null, + create_user: 'dab7cd7c-75af-474e-abbf-fd31ae166577', + update_user: 'dab7cd7c-75af-474e-abbf-fd31ae166577', + create_timestamp: '2023-07-25T18:18:27.933Z', + update_timestamp: '2023-07-25T18:18:27.933Z' + } + ]; + + it('should return a lookup table in a format to be used by select components', async () => { + mock.onGet('/api/critter-data/lookups/colours?format=asSelect').reply(200, mockLookup); + const result = await useLookupApi(axios).getSelectOptions({ route: 'lookups/colours' }); + expect(Array.isArray(result)).toBe(true); + expect(typeof result).not.toBe('string'); + const res = result as ICbSelectRows[]; + expect(res[0].key).toBe('colour_id'); + expect(res[0].value).toBe('Blue'); + expect(res[0].id).toBeDefined(); + }); + + it('should retrieve all possible measurements for a specific taxon', async () => { + const taxon_id = v4(); + mock.onGet('/api/critter-data/xref/taxon-measurements?taxon_id=' + taxon_id).reply(200, mockMeasurement); + const result = await useLookupApi(axios).getTaxonMeasurements(taxon_id); + expect(Array.isArray(result)).toBe(true); + expect(typeof result?.[0].taxon_measurement_id).toBe('string'); + expect(typeof result?.[0].measurement_name).toBe('string'); + }); +}); diff --git a/app/src/hooks/cb_api/useLookupApi.tsx b/app/src/hooks/cb_api/useLookupApi.tsx index 868c2c2299..1090f60aba 100644 --- a/app/src/hooks/cb_api/useLookupApi.tsx +++ b/app/src/hooks/cb_api/useLookupApi.tsx @@ -6,48 +6,10 @@ export interface ICbSelectRows { value: string; } -const lookups = '/api/lookups'; -const xref = '/api/xref'; -const lookupsEnum = lookups + '/enum'; -const lookupsTaxons = lookups + '/taxons'; -const CbRoutes = { - // lookups - region_env: `${lookups}/region-envs`, - region_nr: `${lookups}/region-nrs`, - wmu: `${lookups}/wmus`, - cod: `${lookups}/cods`, - marking_materials: `${lookups}/marking-materials`, - marking_type: `${lookups}/marking-types`, - collection_category: `${lookups}/collection-unit-categories`, - taxons: lookupsTaxons, - species: `${lookupsTaxons}/species`, - colours: `${lookups}/colours`, - - // lookups/enum - sex: `${lookupsEnum}/sex`, - critter_status: `${lookupsEnum}/critter-status`, - cause_of_death_confidence: `${lookupsEnum}/cod-confidence`, - coordinate_uncertainty_unit: `${lookupsEnum}/coordinate-uncertainty-unit`, - frequency_units: `${lookupsEnum}/frequency-units`, - measurement_units: `${lookupsEnum}/measurement-units`, - supported_systems: `${lookupsEnum}/supported-systems`, - - // xref - collection_units: `${xref}/collection-units`, - - // taxon xrefs - taxon_measurements: `${xref}/taxon-measurements`, - taxon_qualitative_measurements: `${xref}/taxon-qualitative-measurements`, - taxon_qualitative_measurement_options: `${xref}/taxon-qualitative-measurement-options`, - taxon_quantitative_measurements: `${xref}/taxon-quantitative-measurements`, - taxon_collection_categories: `${xref}/taxon-collection-categories`, - taxon_marking_body_locations: `${xref}/taxon-marking-body-locations` -} as const; - -export type ICbRouteKey = keyof typeof CbRoutes; +//export type ICbRouteKey = keyof typeof CbRoutes; interface SelectOptionsProps { - route: ICbRouteKey; + route: string; param?: string; query?: string; asSelect?: boolean; @@ -60,12 +22,6 @@ export interface IMeasurementStub { max_value?: number; unit?: string; } -export interface IMarkingStub { - taxon_marking_body_location_id: string; - body_location: string; - taxon_id: string; -} - const useLookupApi = (axios: AxiosInstance) => { const getSelectOptions = async ({ route, @@ -74,19 +30,24 @@ const useLookupApi = (axios: AxiosInstance) => { }: SelectOptionsProps): Promise> => { const _param = param ? `/${param}` : ``; const _query = query ? `&${query}` : ``; - - const { data } = await axios.get(`${CbRoutes[route]}${_param}?format=asSelect${_query}`); + const { data } = await axios.get(`/api/critter-data/${route}${_param}?format=asSelect${_query}`); return data; }; - const getTaxonMeasurements = async (taxon_id: string): Promise | undefined> => { - const { data } = await axios.get(`${CbRoutes.taxon_measurements}?taxon_id=${taxon_id}`); + const getTaxonMeasurements = async (taxon_id?: string): Promise | undefined> => { + if (!taxon_id) { + return; + } + const { data } = await axios.get(`/api/critter-data/xref/taxon-measurements?taxon_id=${taxon_id}`); return data; }; - const getTaxonMarkingBodyLocations = async (taxon_id: string): Promise | undefined> => { - const { data } = await axios.get(`${CbRoutes.taxon_marking_body_locations}?taxon_id=${taxon_id}`); + const getTaxonMarkingBodyLocations = async (taxon_id?: string): Promise> => { + if (!taxon_id) { + return []; + } + const { data } = await axios.get(`/api/critter-data/xref/taxon-marking-body-locations?taxon_id=${taxon_id}`); return data; }; diff --git a/app/src/hooks/useCritterbaseApi.ts b/app/src/hooks/useCritterbaseApi.ts index 1042699576..97e966d4e1 100644 --- a/app/src/hooks/useCritterbaseApi.ts +++ b/app/src/hooks/useCritterbaseApi.ts @@ -14,7 +14,7 @@ import { useMarkings } from './cb_api/useMarkings'; */ export const useCritterbaseApi = () => { const config = useContext(ConfigContext); - const apiAxios = useAxios(config?.CB_API_HOST); + const apiAxios = useAxios(config?.API_HOST); const markings = useMarkings(apiAxios); const authentication = useAuthentication(apiAxios); const lookup = useLookupApi(apiAxios); diff --git a/docker-compose.yml b/docker-compose.yml index c646dd1795..cc2bcb52cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,6 +84,8 @@ services: - BACKBONE_INTAKE_PATH=${BACKBONE_INTAKE_PATH} - BACKBONE_ARTIFACT_INTAKE_PATH=${BACKBONE_ARTIFACT_INTAKE_PATH} - BACKBONE_INTAKE_ENABLED=${BACKBONE_INTAKE_ENABLED} + - BCTW_API_HOST=${BCTW_API_HOST} + - CB_API_HOST=${CB_API_HOST} volumes: - ./api:/opt/app-root/src - /opt/app-root/src/node_modules # prevents local node_modules overriding container node_modules diff --git a/env_config/env.docker b/env_config/env.docker index 1a49ca1270..9e88783c92 100644 --- a/env_config/env.docker +++ b/env_config/env.docker @@ -57,6 +57,22 @@ BACKBONE_ARTIFACT_INTAKE_PATH=/api/artifact/intake # Set to `true` to enable SIMS submitting data to the BioHub Backbone BACKBONE_INTAKE_ENABLED=false +# ------------------------------------------------------------------------------ +# API - BC Telemetry Warehouse Connection +# ------------------------------------------------------------------------------ +# BCTW Platform - BCTW API URL +# If running the BCTW api locally, use `http://:` +# To find the ip use `ip addr show eth0 | grep 'inet\b' | awk '{print $2}' | cut -d/ -f1` +BCTW_API_HOST=https://moe-bctw-api-prod.apps.silver.devops.gov.bc.ca + +# ------------------------------------------------------------------------------ +# API - Critterbase Connection +# ------------------------------------------------------------------------------ +# Critterbase API URL +# If running the Critterbase api locally, use `http://:` +# To find the ip use `ip addr show eth0 | grep 'inet\b' | awk '{print $2}' | cut -d/ -f1` +CB_API_HOST=https://moe-critterbase-api-dev.apps.silver.devops.gov.bc.ca/api + # ------------------------------------------------------------------------------ # Postgres Database # ------------------------------------------------------------------------------