Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docker/.env
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ CPX_DB_CONNECTION_STRING=postgres://${CPX_DB_USERNAME}:${CPX_DB_PASSWORD}@capxml
CPX_REDIS_HOST=cap-xml-redis
CPX_REDIS_PORT=6379
CPX_REDIS_TLS=false
CPX_METEOALARM_API_URL=http://mock-api:8080 # wiremock url
CPX_METEOALARM_API_USERNAME=username
CPX_METEOALARM_API_PASSWORD=password
PGADMIN_DEFAULT_PASSWORD=pgadmin
POSTGRES_PASSWORD=postgres
LIQUIBASE_COMMAND_CHANGELOG_FILE=./changelog/db.changelog-master.xml
Expand Down
9 changes: 9 additions & 0 deletions docker/dev-tools.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ services:
networks:
ls:
command: /bin/sh -c "lpm add postgresql && liquibase update"
mock-api:
image: wiremock/wiremock:latest
ports:
- "8081:8080"
volumes:
- ./docker/wiremock/mappings:/home/wiremock/mappings
command: ["--global-response-templating", "--verbose"]
networks:
ls:
volumes:
capxmlpgadmin:
external: true
Expand Down
24 changes: 24 additions & 0 deletions docker/docker/wiremock/mappings/third-party-api.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"mappings": [
{
"request": {
"method": "POST",
"urlPath": "/warnings"
},
"response": {
"status": 201,
"body": "{\"warning\": {\"uuid\": \"{{randomValue type='UUID'}}\" } }"
}
},
{
"request": {
"method": "POST",
"urlPath": "/tokens"
},
"response": {
"status": 200,
"body": "{\"tokenType\": \"Bearer\", \"token\": \"{{randomValue length=64 type='ALPHANUMERIC'}}\", \"expiresIn\": \"300\" }"
}
}
]
}
5 changes: 4 additions & 1 deletion docker/scripts/register-lambda-functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ cpx_agw_url=$(echo CPX_AGW_URL=$deployed_cpx_agw_url)
cpx_redis_host=$(echo CPX_REDIS_HOST=$CPX_REDIS_HOST)
cpx_redis_port=$(echo CPX_REDIS_PORT=$CPX_REDIS_PORT)
cpx_redis_tls=$(echo CPX_REDIS_TLS=$CPX_REDIS_TLS)
set -- $cpx_db_username $cpx_db_password $cpx_db_name $cpx_db_host $cpx_agw_url $cpx_redis_host $cpx_redis_port $cpx_redis_tls
cpx_meteoalarm_api_url=$(echo CPX_METEOALARM_API_URL=$CPX_METEOALARM_API_URL)
cpx_meteoalarm_api_username=$(echo CPX_METEOALARM_API_USERNAME=$CPX_METEOALARM_API_USERNAME)
cpx_meteoalarm_api_password=$(echo CPX_METEOALARM_API_PASSWORD=$CPX_METEOALARM_API_PASSWORD)
set -- $cpx_db_username $cpx_db_password $cpx_db_name $cpx_db_host $cpx_agw_url $cpx_redis_host $cpx_redis_port $cpx_redis_tls $cpx_meteoalarm_api_url $cpx_meteoalarm_api_username $cpx_meteoalarm_api_password
custom_environment_variables=$(printf '%s,' "$@" | sed 's/,*$//g')

# Iterate over each file in lambda_functions_dir
Expand Down
14 changes: 11 additions & 3 deletions lib/functions/processMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ const path = require('node:path')
const xsdSchema = fs.readFileSync(path.join(__dirname, '..', 'schemas', 'CAP-v1.2.xsd'), 'utf8')
const additionalCapMessageSchema = require('../schemas/additionalCapMessageSchema')
const Message = require('../models/message')
const EA_WHO = '2.49.0.0.826.1'
const EA_WHO = '2.49.0.1.826.1'
const CODE = 'MCP:v2.0'
const severityV2Mapping = require('../models/v2MessageMapping')
const redis = require('../helpers/redis')
const meteoalarm = require('../helpers/meteoalarm')

module.exports.processMessage = async (event) => {
try {
Expand Down Expand Up @@ -58,8 +59,12 @@ module.exports.processMessage = async (event) => {
}

const { message: redisMessage, query: dbQuery } = message.putQuery(message, messageV2)
// store the message in database and redis/elasticache
await Promise.all([service.putMessage(dbQuery), redis.set(redisMessage.identifier, redisMessage)])
// store the message in database, redis/elasticache, and post to Meteoalarm
await Promise.all([
service.putMessage(dbQuery),
redis.set(redisMessage.identifier, redisMessage),
meteoalarm.postWarning(messageV2.toString(), message.identifier)
])
console.log(`Finished processing CAP message: ${message.identifier} for ${message.fwisCode}`)

return {
Expand Down Expand Up @@ -167,6 +172,7 @@ const processMessageV2 = (message, lastMessage) => {
messageV2.references = referencesV2
}
messageV2.event = `${severityV2Mapping[message.severity]?.description}: ${messageV2.areaDesc}`
messageV2.responseType = 'Monitor'
messageV2.severity = severityV2Mapping[message.severity]?.severity || ''
messageV2.onset = message.sent
messageV2.headline = `${severityV2Mapping[message.severity]?.headline}: ${messageV2.areaDesc}`
Expand All @@ -188,5 +194,7 @@ const processMessageV2 = (message, lastMessage) => {
messageV2.addParameter('use_polygon_over_geocode', 'true')
messageV2.addParameter('uk_ea_ta_code', message.fwisCode)

messageV2.removeNode('geocode')

return messageV2
}
116 changes: 116 additions & 0 deletions lib/helpers/meteoalarm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
'use strict'

const axios = require('axios')
const https = require('node:https')
let cachedToken = null
let tokenExpiry = null
const CPX_METEOALARM_API_URL = process.env.CPX_METEOALARM_API_URL
const CPX_METEOALARM_API_USERNAME = process.env.CPX_METEOALARM_API_USERNAME
const CPX_METEOALARM_API_PASSWORD = process.env.CPX_METEOALARM_API_PASSWORD
const MAX_RETRIES = 3
const TOKEN_EXPIRY_MS = 3600000 // 1 hour in milliseconds
const API_REQUEST_TIMEOUT_MS = 10000 // 10 seconds
const DEFAULT_RETRY_DELAY_MULTIPLIER = 1000 // 1 second base delay
const HTTP_STATUS_OK = 200
const HTTP_STATUS_CREATED = 201
const HTTP_STATUS_UNAUTHORIZED = 401
const config = {
retryDelayMultiplier: DEFAULT_RETRY_DELAY_MULTIPLIER // Can be overridden for testing
}

const getValidToken = async () => {
// Check if we have a cached token that hasn't expired
if (cachedToken && tokenExpiry && new Date() < tokenExpiry) {
return cachedToken
}

try {
const response = await axios.post(`${CPX_METEOALARM_API_URL}/tokens`, {
username: CPX_METEOALARM_API_USERNAME,
password: CPX_METEOALARM_API_PASSWORD
}, {
headers: {
'Content-Type': 'application/json'
},
httpsAgent: new https.Agent({
rejectUnauthorized: false
})
})

if (response.status !== HTTP_STATUS_OK) {
throw new Error(`Failed to authenticate: ${response.status} ${response.statusText}`)
}

cachedToken = response.data.token
// Set token expiry to 1 hour from now
tokenExpiry = new Date(Date.now() + TOKEN_EXPIRY_MS)
return cachedToken
} catch (err) {
console.error('Error fetching bearer token:', err.message)
throw new Error(`Failed to authenticate with Meteoalarm: ${err.message}`)
}
}

const postWarning = async (xmlMessage, identifier) => {
let lastError = null
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const token = await getValidToken()
const response = await axios.post(`${CPX_METEOALARM_API_URL}/warnings`, xmlMessage, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/xml'
},
timeout: API_REQUEST_TIMEOUT_MS,
httpsAgent: new https.Agent({
rejectUnauthorized: false
})
})

if (response.status === HTTP_STATUS_CREATED) {
console.log(`Successfully posted warning to Meteoalarm: ${identifier}`)
console.log(response.data)
return response.data
}
throw new Error(`Received non-201 response: ${response.status}`)
} catch (err) {
lastError = err
console.error(`Meteoalarm post attempt ${attempt} failed: ${err.message}`)
if (err.response?.data) {
console.error(JSON.stringify(err.response.data))
}

// If it's a 401 error, clear the cached token and retry
if (err.response?.status === HTTP_STATUS_UNAUTHORIZED) {
console.log('Received 401, clearing cached token')
cachedToken = null
tokenExpiry = null
}

// If this isn't the last attempt, wait before retrying
if (attempt < MAX_RETRIES) {
const delayMs = attempt * config.retryDelayMultiplier
console.log(`Waiting ${delayMs}ms before retry...`)
await new Promise(resolve => setTimeout(resolve, delayMs))
}
}
}
throw new Error(`Failed to post warning to Meteoalarm after ${MAX_RETRIES} attempts: ${lastError.message}`)
}

const clearTokenCache = () => {
cachedToken = null
tokenExpiry = null
}

const setRetryDelayMultiplier = (multiplier) => {
config.retryDelayMultiplier = multiplier
}

module.exports = {
postWarning,
clearTokenCache,
// Export for testing
getValidToken,
setRetryDelayMultiplier
}
22 changes: 22 additions & 0 deletions lib/models/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ class Message {
this.getFirstElement('event').textContent = value
}

get responseType () {
return this.getFirstElement('responseType')?.textContent || ''
}

set responseType (value) {
const responseTypeEl = this.getFirstElement('responseType')
if (responseTypeEl) {
responseTypeEl.textContent = value
} else {
this.addElement('event', 'responseType', value)
}
}

get severity () {
return this.getFirstElement('severity')?.textContent || ''
}
Expand Down Expand Up @@ -176,6 +189,15 @@ class Message {
}
}

removeNode (name) {
const nodes = this.doc.getElementsByTagName(name)
// Using parentNode.removeChild() because @xmldom/xmldom doesn't support node.remove()
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i]
node.parentNode.removeChild(node) // NOSONAR - remove() not available in xmldom
}
}

toString () {
return xmlFormat(new xmldom.XMLSerializer().serializeToString(this.doc), { indentation: ' ', collapseContent: true })
}
Expand Down
Loading