Skip to content

Commit adc0584

Browse files
authored
Chore: [AEA-5976] - diagnostic logging around service search v3 (#2338)
## Summary - 🤖 Operational or Infrastructure Change ### Details - diagnostic logging around service search v3 - fallback to powertools if secrets not read
1 parent d415bb8 commit adc0584

File tree

5 files changed

+321
-36
lines changed

5 files changed

+321
-36
lines changed

.github/workflows/run_regression_tests.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ jobs:
7979
GITHUB-TOKEN: ${{ steps.generate-token.outputs.token }}
8080
run: |
8181
if [[ "$TARGET_ENVIRONMENT" != "prod" && "$TARGET_ENVIRONMENT" != "ref" ]]; then
82-
REGRESSION_TEST_REPO_TAG="v3.8.18" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name
83-
REGRESSION_TEST_WORKFLOW_TAG="v3.8.18" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG
82+
REGRESSION_TEST_REPO_TAG="v3.8.19" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name
83+
REGRESSION_TEST_WORKFLOW_TAG="v3.8.19" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG
8484
8585
if [[ -z "$REGRESSION_TEST_REPO_TAG" || -z "$REGRESSION_TEST_WORKFLOW_TAG" ]]; then
8686
echo "Error: One or both tag variables are not set" >&2
@@ -121,8 +121,8 @@ jobs:
121121
# GITHUB-TOKEN: ${{ steps.generate-token.outputs.token }}
122122
# run: |
123123
# if [[ "$TARGET_ENVIRONMENT" != "prod" && "$TARGET_ENVIRONMENT" != "ref" ]]; then
124-
# REGRESSION_TEST_REPO_TAG="v3.8.18" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name
125-
# REGRESSION_TEST_WORKFLOW_TAG="v3.8.18" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG
124+
# REGRESSION_TEST_REPO_TAG="v3.8.19" # This is the tag or branch of the regression test code to run, usually a version tag like v3.1.0 or a branch name
125+
# REGRESSION_TEST_WORKFLOW_TAG="v3.8.19" # This is the tag of the github workflow to run, usually the same as REGRESSION_TEST_REPO_TAG
126126

127127
# if [[ -z "$REGRESSION_TEST_REPO_TAG" || -z "$REGRESSION_TEST_WORKFLOW_TAG" ]]; then
128128
# echo "Error: One or both tag variables are not set" >&2

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ sam-sync-sandbox: guard-stack_name compile download-get-secrets-layer
4747

4848
sam-deploy: guard-AWS_DEFAULT_PROFILE guard-stack_name
4949
sam deploy \
50+
--template-file SAMtemplates/main_template.yaml \
5051
--stack-name $$stack_name \
5152
--parameter-overrides \
5253
EnableSplunk=false \

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@ Note - the command will keep running and should not be stopped.
201201
You can now call this api - note getMyPrescriptions requires an nhsd-nhslogin-user header
202202

203203
```bash
204-
curl --header "nhsd-nhslogin-user: P9:9446041481" https://${stack_name}.dev.prescriptionsforpatients.national.nhs.uk/Bundle
204+
curl --header "nhsd-nhslogin-user: P9:9446041481" --header "x-request-id: $(uuid)" \
205+
https://${stack_name}.dev.eps.national.nhs.uk/Bundle
205206
```
206207

207208
You can also use the AWS vscode extension to invoke the API or lambda directly

packages/serviceSearchClient/src/live-serviceSearch-client.ts

Lines changed: 112 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {Logger} from "@aws-lambda-powertools/logger"
2+
import {getSecret} from "@aws-lambda-powertools/parameters/secrets"
23
import axios, {AxiosError, AxiosInstance} from "axios"
34
import axiosRetry from "axios-retry"
45
import {handleUrl} from "./handleUrl"
@@ -14,10 +15,24 @@ type Service = {
1415
"OrganisationSubType": string
1516
}
1617

18+
type Contact = {
19+
"ContactMethodType": string
20+
"ContactValue": string
21+
}
22+
1723
export type ServiceSearchData = {
1824
"value": Array<Service>
1925
}
2026

27+
export type ServiceSearch3Data = {
28+
"@odata.context": string
29+
"value": Array<{
30+
"@search.score": number
31+
"OrganisationSubType": string
32+
"Contacts": Array<Contact>
33+
}>
34+
}
35+
2136
export const SERVICE_SEARCH_BASE_QUERY_PARAMS = {
2237
"api-version": 2,
2338
"searchFields": "ODSCode",
@@ -26,26 +41,40 @@ export const SERVICE_SEARCH_BASE_QUERY_PARAMS = {
2641
"$top": 1
2742
}
2843

29-
export function getServiceSearchEndpoint(): string {
44+
export function getServiceSearchVersion(logger: Logger | null = null): number {
3045
const endpoint = process.env.TargetServiceSearchServer || "service-search"
31-
const baseUrl = `https://${endpoint}`
3246
if (endpoint.toLowerCase().includes("api.service.nhs.uk")) {
33-
// service search v3
47+
logger?.info("Using service search v3 endpoint")
3448
SERVICE_SEARCH_BASE_QUERY_PARAMS["api-version"] = 3
35-
return `${baseUrl}/service-search-api/`
49+
SERVICE_SEARCH_BASE_QUERY_PARAMS["$select"] = "Contacts,OrganisationSubType"
50+
return 3
51+
}
52+
logger?.warn("Using service search v2 endpoint")
53+
return 2
54+
}
55+
56+
export function getServiceSearchEndpoint(logger: Logger | null = null): string {
57+
switch (getServiceSearchVersion(logger)) {
58+
case 3:
59+
return `https://${process.env.TargetServiceSearchServer}/service-search-api/`
60+
case 2:
61+
default:
62+
return `https://${process.env.TargetServiceSearchServer}/service-search`
3663
}
37-
// service search v2
38-
return `${baseUrl}/service-search`
3964
}
4065

4166
export class LiveServiceSearchClient implements ServiceSearchClient {
4267
private readonly axiosInstance: AxiosInstance
4368
private readonly logger: Logger
44-
private readonly outboundHeaders: {"apikey": string | undefined, "Subscription-Key": string | undefined}
69+
private readonly outboundHeaders: {"apikey"?: string, "Subscription-Key"?: string}
4570

4671
constructor(logger: Logger) {
4772
this.logger = logger
48-
73+
this.logger.info("ServiceSearchClient configured",
74+
{
75+
v2: process.env.ServiceSearchApiKey !== undefined,
76+
v3: process.env.ServiceSearch3ApiKey !== undefined
77+
})
4978
this.axiosInstance = axios.create()
5079
axiosRetry(this.axiosInstance, {retries: 3})
5180

@@ -95,15 +124,49 @@ export class LiveServiceSearchClient implements ServiceSearchClient {
95124
return Promise.reject(err)
96125
})
97126

98-
this.outboundHeaders = {
99-
"Subscription-Key": process.env.ServiceSearchApiKey,
100-
"apikey": process.env.ServiceSearch3ApiKey
127+
const version = getServiceSearchVersion(this.logger)
128+
if (version === 3) {
129+
this.outboundHeaders = {
130+
"apikey": process.env.ServiceSearch3ApiKey
131+
}
132+
} else {
133+
this.outboundHeaders = {
134+
"Subscription-Key": process.env.ServiceSearchApiKey
135+
}
136+
}
137+
}
138+
139+
private async loadApiKeyFromSecretsManager(): Promise<string | undefined> {
140+
try {
141+
const secretArn = process.env.ServiceSearch3ApiKeyARN
142+
if (!secretArn) {
143+
this.logger.error("ServiceSearch3ApiKeyARN environment variable is not set")
144+
return undefined
145+
}
146+
this.logger.info("Loading ServiceSearch API key from Secrets Manager", {secretArn})
147+
148+
const secret = await getSecret(secretArn, {
149+
maxAge: 300 // Cache for 5 minutes
150+
})
151+
152+
this.logger.info("Successfully loaded ServiceSearch API key from Secrets Manager")
153+
return secret as string
154+
} catch (error) {
155+
this.logger.error("Failed to load ServiceSearch API key from Secrets Manager", {error})
156+
return undefined
101157
}
102158
}
103159

104160
async searchService(odsCode: string): Promise<URL | undefined> {
105161
try {
106-
const address = getServiceSearchEndpoint()
162+
// Load API key if not set in environment (secrets layer is failing to load v3 key)
163+
const apiVsn = getServiceSearchVersion(this.logger)
164+
if (apiVsn === 3 && !this.outboundHeaders.apikey) {
165+
this.logger.info("API key not in environment, attempting to load from Secrets Manager")
166+
this.outboundHeaders.apikey = await this.loadApiKeyFromSecretsManager()
167+
}
168+
169+
const address = getServiceSearchEndpoint(this.logger)
107170
const queryParams = {...SERVICE_SEARCH_BASE_QUERY_PARAMS, search: odsCode}
108171

109172
this.logger.info(`making request to ${address} with ods code ${odsCode}`, {odsCode: odsCode})
@@ -113,22 +176,14 @@ export class LiveServiceSearchClient implements ServiceSearchClient {
113176
timeout: SERVICE_SEARCH_TIMEOUT
114177
})
115178

116-
const serviceSearchResponse: ServiceSearchData = response.data
117-
const services = serviceSearchResponse.value
118-
if (services.length === 0) {
119-
return undefined
179+
this.logger.info(`received response from serviceSearch for ods code ${odsCode}`,
180+
{odsCode: odsCode, status: response.status, data: response.data})
181+
if (apiVsn === 2) {
182+
return this.handleV2Response(odsCode, response.data)
183+
} else {
184+
return this.handleV3Response(odsCode, response.data)
120185
}
121186

122-
this.logger.info(`pharmacy with ods code ${odsCode} is of type ${DISTANCE_SELLING}`, {odsCode: odsCode})
123-
const service = services[0]
124-
const urlString = service["URL"]
125-
126-
if (urlString === null) {
127-
this.logger.warn(`ods code ${odsCode} has no URL but is of type ${DISTANCE_SELLING}`, {odsCode: odsCode})
128-
return undefined
129-
}
130-
const serviceUrl = handleUrl(urlString, odsCode, this.logger)
131-
return serviceUrl
132187
} catch (error) {
133188
if (axios.isAxiosError(error)) {
134189
this.stripApiKeyFromHeaders(error)
@@ -157,14 +212,43 @@ export class LiveServiceSearchClient implements ServiceSearchClient {
157212
throw error
158213
}
159214
}
215+
handleV3Response(odsCode: string, data: ServiceSearch3Data): URL | undefined {
216+
const contacts = data.value[0]?.Contacts
217+
const websiteContact = contacts?.find((contact: Contact) => contact.ContactMethodType === "Website")
218+
const websiteUrl = websiteContact?.ContactValue
219+
if (!websiteUrl) {
220+
this.logger.warn(`pharmacy with ods code ${odsCode} has no website`, {odsCode: odsCode})
221+
return undefined
222+
}
223+
const serviceUrl = handleUrl(websiteUrl, odsCode, this.logger)
224+
return serviceUrl
225+
}
226+
227+
handleV2Response(odsCode: string, data: ServiceSearchData): URL | undefined {
228+
const services = data.value
229+
if (services.length === 0) {
230+
return undefined
231+
}
232+
233+
this.logger.info(`pharmacy with ods code ${odsCode} is of type ${DISTANCE_SELLING}`, {odsCode: odsCode})
234+
const service = services[0]
235+
const urlString = service["URL"]
236+
237+
if (urlString === null) {
238+
this.logger.warn(`ods code ${odsCode} has no URL but is of type ${DISTANCE_SELLING}`, {odsCode: odsCode})
239+
return undefined
240+
}
241+
const serviceUrl = handleUrl(urlString, odsCode, this.logger)
242+
return serviceUrl
243+
}
160244

161245
stripApiKeyFromHeaders(error: AxiosError) {
162246
const headerKeys = ["subscription-key", "apikey"]
163247
headerKeys.forEach((key) => {
164-
if (error.response?.headers) {
248+
if (error.response?.headers?.[key]) {
165249
delete error.response.headers[key]
166250
}
167-
if (error.request?.headers) {
251+
if (error.request?.headers?.[key]) {
168252
delete error.request.headers[key]
169253
}
170254
})

0 commit comments

Comments
 (0)