Skip to content

Commit 9553eb4

Browse files
authored
Merge pull request #645 from bcgov/tech-spike-canadapost
CCFRI-4492 - implement Canada Post AddressComplete API
2 parents acc3416 + aa85957 commit 9553eb4

20 files changed

+576
-316
lines changed

.github/workflows/deploy-to-openshift-backend-dev.yml

+4-2
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,9 @@ jobs:
179179
${{ secrets.SOAM_CLIENT_SECRET_IDIR }} \
180180
${{ secrets.SPLUNK_TOKEN }} \
181181
${{ secrets.REDIS_PASSWORD }} \
182-
${{ secrets.D365_API_PREFIX }}
182+
${{ secrets.D365_API_PREFIX }} \
183+
${{ secrets.CANADA_POST_API_ENDPOINT }} \
184+
${{ secrets.CANADA_POST_API_KEY }}
183185
184186
# Start rollout of the application
185187
oc rollout restart deployment/${{ env.APP_NAME }}-${{ env.IMAGE_NAME }}-${{ env.APP_ENVIRONMENT }} 2> /dev/null \
@@ -191,4 +193,4 @@ jobs:
191193
- name: ZAP Scan
192194
uses: zaproxy/[email protected]
193195
with:
194-
target: 'https://${{ env.HOST_ROUTE }}/api'
196+
target: "https://${{ env.HOST_ROUTE }}/api"

backend/package-lock.json

+45-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"dotenv": "^8.0.0",
4040
"express": "^4.21.2",
4141
"express-prometheus-middleware": "^1.2.0",
42+
"express-rate-limit": "^7.5.0",
4243
"express-session": "^1.18.1",
4344
"express-validator": "^6.9.2",
4445
"fast-safe-stringify": "^2.0.7",
@@ -63,6 +64,7 @@
6364
"path": "0.12.7",
6465
"puppeteer": "^23.5.0",
6566
"querystring": "0.2.0",
67+
"rate-limit-redis": "^4.2.0",
6668
"redlock": "^4.2.0",
6769
"strip-ansi": "^6.0.0",
6870
"uuid": "^8.3.2",

backend/src/app.js

+14
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ const licenseUploadRouter = require('./routes/licenseUpload');
3434
const supportingDocumentUploadRouter = require('./routes/supportingDocuments');
3535
const changeRequestRouter = require('./routes/changeRequest');
3636
const pdfRouter = require('./routes/pdf');
37+
const canadaPostRouter = require('./routes/canadaPost');
3738
const connectRedis = require('connect-redis');
39+
const { RedisStore } = require('rate-limit-redis');
40+
const rateLimit = require('express-rate-limit');
3841

3942
const promMid = require('express-prometheus-middleware');
4043

@@ -188,6 +191,16 @@ utils.getOidcDiscovery().then((discovery) => {
188191
passport.serializeUser((user, next) => next(null, user));
189192
passport.deserializeUser((obj, next) => next(null, obj));
190193

194+
// Setup Rate limit for the number of frontend requests allowed per windowMs to avoid DDOS attack
195+
const limiter = rateLimit({
196+
windowMs: 15 * 60 * 1000, // 15 minutes
197+
limit: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
198+
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
199+
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
200+
store: dbSession ? new RedisStore({ sendCommand: (...args) => dbSession.client.call(...args) }) : undefined,
201+
});
202+
app.use('/api/canadaPost', limiter);
203+
191204
app.use(morgan(config.get('server:morganFormat'), { stream: logStream }));
192205
//set up routing to auth and main API
193206
app.use(/(\/api)?/, apiRouter);
@@ -206,6 +219,7 @@ apiRouter.use('/licenseUpload', licenseUploadRouter);
206219
apiRouter.use('/supportingDocument', supportingDocumentUploadRouter);
207220
apiRouter.use('/changeRequest', changeRequestRouter);
208221
apiRouter.use('/pdf', pdfRouter);
222+
apiRouter.use('/canadaPost', canadaPostRouter);
209223

210224
//Handle 500 error
211225
app.use((err, _req, res, next) => {

backend/src/components/canadaPost.js

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use strict';
2+
3+
const axios = require('axios');
4+
const cache = require('memory-cache');
5+
const config = require('../config/index');
6+
const log = require('./logger');
7+
const HttpStatus = require('http-status-codes');
8+
const { ApiError } = require('./error');
9+
10+
const addressSearchResultsCache = new cache.Cache();
11+
const ONE_DAY_MS = 24 * 60 * 60 * 1000; // Cache timeout set for one day
12+
13+
/*
14+
The documentation of the Canada Post's AddressComplete API: https://www.canadapost-postescanada.ca/ac/support/api/
15+
*/
16+
async function findAddresses(req, res) {
17+
try {
18+
let url = `${config.get('canadaPostApi:apiEndpoint')}?key=${config.get('canadaPostApi:apiKey')}`;
19+
20+
if (req?.query?.searchTerm) {
21+
const cachedSearchResult = addressSearchResultsCache.get(req?.query?.searchTerm);
22+
if (cachedSearchResult) {
23+
log.info(`Canada Post findAddresses :: Cache hit for search term: '${req?.query?.searchTerm}'`);
24+
return res.status(HttpStatus.OK).json(cachedSearchResult);
25+
}
26+
url += `&SearchTerm=${req.query.searchTerm}`;
27+
}
28+
29+
if (req?.query?.lastId) {
30+
url += `&LastId=${req.query.lastId}`;
31+
}
32+
33+
const headers = {
34+
Accept: 'text/plain',
35+
'Content-Type': 'application/json',
36+
};
37+
const response = await axios.get(url, headers);
38+
if (req?.query?.searchTerm) {
39+
addressSearchResultsCache.put(req?.query?.searchTerm, response.data, ONE_DAY_MS);
40+
}
41+
log.info(`Canada Post findAddresses :: Cache miss for search term: '${req?.query?.searchTerm}'. Calling AddressComplete API.`);
42+
return res.status(HttpStatus.OK).json(response.data);
43+
} catch (e) {
44+
log.error(e);
45+
throw new ApiError(HttpStatus.INTERNAL_SERVER_ERROR, { message: 'API Find error' }, e);
46+
}
47+
}
48+
49+
module.exports = {
50+
findAddresses,
51+
};

backend/src/components/facility.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ function mapCCFRIObjectForFront(data) {
8080
}
8181

8282
async function getFacilityByFacilityId(facilityId) {
83-
const operation = `accounts(${facilityId})?$select=ccof_accounttype,name,ccof_facilitystartdate,address1_line1,address1_city,address1_stateorprovince,address1_postalcode,ccof_position,emailaddress1,address1_primarycontactname,telephone1,ccof_facilitylicencenumber,ccof_licensestartdate,ccof_formcomplete,ccof_everreceivedfundingundertheccofprogram,ccof_facilityreceived_ccof_funding,accountnumber,ccof_facilitystatus`;
83+
const operation = `accounts(${facilityId})?$select=ccof_accounttype,name,ccof_facilitystartdate,address1_line1,address1_city,address1_stateorprovince,address1_postalcode,ccof_position,emailaddress1,address1_primarycontactname,telephone1,ccof_facilitylicencenumber,ccof_licensestartdate,ccof_formcomplete,ccof_everreceivedfundingundertheccofprogram,ccof_facilityreceived_ccof_funding,accountnumber,ccof_facilitystatus,ccof_is_facility_address_entered_manually`;
8484
const facility = await getOperation(operation);
8585

8686
if (ACCOUNT_TYPE.FACILITY != facility?.ccof_accounttype) {
@@ -92,7 +92,7 @@ async function getFacilityByFacilityId(facilityId) {
9292

9393
async function getFacility(req, res) {
9494
try {
95-
let facility = await getFacilityByFacilityId(req.params.facilityId);
95+
const facility = await getFacilityByFacilityId(req.params.facilityId);
9696

9797
if (facility === null) {
9898
return res.status(HttpStatus.NOT_FOUND).json({ message: 'Account found but is not facility.' });

backend/src/config/index.js

+18-16
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ dotenv.config();
66

77
const env = process.env.NODE_ENV || 'local';
88

9-
nconf.argv()
9+
nconf
10+
.argv()
1011
.env()
1112
.file({ file: path.join(__dirname, `${env}.json`) });
1213

@@ -17,12 +18,10 @@ nconf.overrides({
1718
server: {
1819
logLevel: process.env.LOG_LEVEL,
1920
morganFormat: 'dev',
20-
port: 8080
21-
}
21+
port: 8080,
22+
},
2223
});
2324

24-
25-
2625
nconf.defaults({
2726
environment: env,
2827
logoutEndpoint: process.env.SOAM_URL,
@@ -31,15 +30,15 @@ nconf.defaults({
3130
frontend: process.env.SERVER_FRONTEND,
3231
logLevel: process.env.LOG_LEVEL,
3332
morganFormat: 'dev',
34-
port: process.env.SERVER_PORT
33+
port: process.env.SERVER_PORT,
3534
},
3635
oidc: {
3736
publicKey: process.env.SOAM_PUBLIC_KEY,
3837
clientId: process.env.SOAM_CLIENT_ID,
3938
clientSecret: process.env.SOAM_CLIENT_SECRET,
4039
clientIdIDIR: process.env.SOAM_CLIENT_ID_IDIR,
4140
clientSecretIDIR: process.env.SOAM_CLIENT_SECRET_IDIR,
42-
discovery: process.env.SOAM_DISCOVERY
41+
discovery: process.env.SOAM_DISCOVERY,
4342
},
4443
secureExchange: {
4544
apiEndpoint: process.env.CCOF_API_ENDPOINT,
@@ -48,31 +47,34 @@ nconf.defaults({
4847
privateKey: process.env.UI_PRIVATE_KEY,
4948
publicKey: process.env.UI_PUBLIC_KEY,
5049
audience: process.env.SERVER_FRONTEND,
51-
issuer: process.env.ISSUER
50+
issuer: process.env.ISSUER,
5251
},
5352
organization: {
5453
apiEndpoint: process.env.ORGANIZATION_API_ENDPOINT,
5554
},
5655
dynamicsApi: {
57-
apiEndpoint: process.env.D365_API_ENDPOINT
56+
apiEndpoint: process.env.D365_API_ENDPOINT,
5857
},
59-
messaging:{
60-
natsUrl:process.env.NATS_URL,
61-
natsCluster:process.env.NATS_CLUSTER
58+
messaging: {
59+
natsUrl: process.env.NATS_URL,
60+
natsCluster: process.env.NATS_CLUSTER,
6261
},
6362
ccof: {
6463
rootURL: process.env.CCOF_API_ENDPOINT,
6564
organizationUR: process.env.CCOF_API_ENDPOINT + '/organizations',
66-
ccofFormURL: process.env.CCOF_API_ENDPOINT + '/ccof'
65+
ccofFormURL: process.env.CCOF_API_ENDPOINT + '/ccof',
6766
},
6867
redis: {
6968
use: process.env.USE_REDIS,
7069
host: process.env.REDIS_HOST,
7170
port: process.env.REDIS_PORT,
7271
password: process.env.REDIS_PASSWORD,
7372
clustered: process.env.REDIS_USE_CLUSTERED,
74-
facilityTTL: process.env.REDIS_FACILITY_TTL
75-
}
76-
73+
facilityTTL: process.env.REDIS_FACILITY_TTL,
74+
},
75+
canadaPostApi: {
76+
apiEndpoint: process.env.CANADA_POST_API_ENDPOINT,
77+
apiKey: process.env.CANADA_POST_API_KEY,
78+
},
7779
});
7880
module.exports = nconf;

backend/src/routes/canadaPost.js

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const express = require('express');
2+
const passport = require('passport');
3+
const router = express.Router();
4+
const auth = require('../components/auth');
5+
const isValidBackendToken = auth.isValidBackendToken();
6+
const { findAddresses } = require('../components/canadaPost');
7+
const { oneOf, query, validationResult } = require('express-validator');
8+
9+
module.exports = router;
10+
11+
router.get(
12+
'/find',
13+
passport.authenticate('jwt', { session: false }),
14+
isValidBackendToken,
15+
oneOf([query('searchTerm').notEmpty(), query('lastId').notEmpty()], {
16+
message: 'URL query: [searchTerm or lastId] is required',
17+
}),
18+
(req, res) => {
19+
validationResult(req).throw();
20+
return findAddresses(req, res);
21+
},
22+
);
23+
24+
module.exports = router;

backend/src/util/mapping/Mappings.js

+3-6
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,9 @@ const OrganizationMappings = [
1919
// { back: 'ccof_typeoforganization@OData.Community.Display.V1.FormattedValue', front: 'organizationTypeDesc' },
2020
{ back: 'ccof_formcomplete', front: 'isOrganizationComplete' },
2121
{ back: 'ccof_is_mailing_address_same', front: 'isSameAsMailing' },
22-
{ back: 'ccof_is_mailing_address_same', front: 'isSameAsMailing' },
22+
{ back: 'ccof_is_org_mailing_address_entered_manually', front: 'isOrgMailingAddressEnteredManually' },
23+
{ back: 'ccof_is_org_street_address_entered_manually', front: 'isOrgStreetAddressEnteredManually' },
2324
{ back: 'ccof_providername', front: 'nameOfCareProvider' },
24-
// { back: 'QQQQQQQQ', front: 'nameOfCareProvider' },
25-
// { back: 'QQQQQQQQ', front: 'facilityName' },
2625
];
2726

2827
const FacilityMappings = [
@@ -42,9 +41,7 @@ const FacilityMappings = [
4241
{ back: 'ccof_formcomplete', front: 'isFacilityComplete' },
4342
{ back: 'accountnumber', front: 'facilityAccountNumber' },
4443
{ back: '_ccof_change_request_value', front: 'changeRequestId' }, //likely won't stay here
45-
46-
// XXXXXXXXXXXXX: 'licenseEffectiveDate',
47-
// XXXXXXXXXXXXX: 'hasReceivedFunding',
44+
{ back: 'ccof_is_facility_address_entered_manually', front: 'isFacilityAddressEnteredManually' },
4845
];
4946

5047
const CCFRIFacilityMappings = [

0 commit comments

Comments
 (0)