Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
48ee674
chore: diagnostic logging
tstephen-nhs Jan 26, 2026
5da82d4
chore: trigger build
tstephen-nhs Jan 26, 2026
848810b
chore: trigger build
tstephen-nhs Jan 27, 2026
e14cc72
chore: update test for revised headers
tstephen-nhs Jan 27, 2026
bb90c49
chore: fix consequential test failure
tstephen-nhs Jan 27, 2026
d35f6b7
chore: another test fix
tstephen-nhs Jan 27, 2026
f1b134a
chore: fix test
tstephen-nhs Jan 27, 2026
b8d4632
Revert "chore: fix test"
tstephen-nhs Jan 27, 2026
d3a5ffd
Revert "chore: another test fix"
tstephen-nhs Jan 27, 2026
936d279
Revert "chore: fix consequential test failure"
tstephen-nhs Jan 27, 2026
9d5bfbf
fix: make sam-sync
tstephen-nhs Jan 27, 2026
2435343
fix: layer failure to load SSv3 key
tstephen-nhs Jan 27, 2026
ce2ba47
chore: simplify condition
tstephen-nhs Jan 27, 2026
613d0b5
chore: tweak logging
tstephen-nhs Jan 27, 2026
cfa9c4d
chore: clean up logging
tstephen-nhs Jan 28, 2026
c1a2af9
Merge branch 'main' into aea-5976-logging2
tstephen-nhs Jan 28, 2026
e6fc73a
test: increase coverage, eg new func getServiceSearchVersion
tstephen-nhs Jan 28, 2026
ad0dcc4
chore: tmp remove URL from
tstephen-nhs Jan 28, 2026
fc6f8a6
Revert "chore: tmp remove URL from serviceSearch"
tstephen-nhs Jan 28, 2026
10acd44
fix: handle diff v3 response
tstephen-nhs Jan 28, 2026
694e71a
test: v3 response data
tstephen-nhs Jan 28, 2026
a7a2f39
fix: revised test approach for less mocks
tstephen-nhs Jan 28, 2026
76c3894
fix: v2 compatibility
tstephen-nhs Jan 28, 2026
8650585
test: new distance selling regression test
tstephen-nhs Jan 28, 2026
13e9f13
ops: pin proxygen regression tests
tstephen-nhs Jan 28, 2026
17d3eee
Merge branch 'main' into aea-5976-logging2
tstephen-nhs Jan 29, 2026
eae8d2e
chore: bump regression tests to 3.8.19
tstephen-nhs Jan 29, 2026
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
8 changes: 4 additions & 4 deletions .github/workflows/run_regression_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ jobs:
GITHUB-TOKEN: ${{ steps.generate-token.outputs.token }}
run: |
if [[ "$TARGET_ENVIRONMENT" != "prod" && "$TARGET_ENVIRONMENT" != "ref" ]]; then
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
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
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
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

if [[ -z "$REGRESSION_TEST_REPO_TAG" || -z "$REGRESSION_TEST_WORKFLOW_TAG" ]]; then
echo "Error: One or both tag variables are not set" >&2
Expand Down Expand Up @@ -121,8 +121,8 @@ jobs:
# GITHUB-TOKEN: ${{ steps.generate-token.outputs.token }}
# run: |
# if [[ "$TARGET_ENVIRONMENT" != "prod" && "$TARGET_ENVIRONMENT" != "ref" ]]; then
# 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
# 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
# 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
# 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

# if [[ -z "$REGRESSION_TEST_REPO_TAG" || -z "$REGRESSION_TEST_WORKFLOW_TAG" ]]; then
# echo "Error: One or both tag variables are not set" >&2
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ sam-sync-sandbox: guard-stack_name compile download-get-secrets-layer

sam-deploy: guard-AWS_DEFAULT_PROFILE guard-stack_name
sam deploy \
--template-file SAMtemplates/main_template.yaml \
--stack-name $$stack_name \
--parameter-overrides \
EnableSplunk=false \
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,8 @@ Note - the command will keep running and should not be stopped.
You can now call this api - note getMyPrescriptions requires an nhsd-nhslogin-user header

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

You can also use the AWS vscode extension to invoke the API or lambda directly
Expand Down
140 changes: 112 additions & 28 deletions packages/serviceSearchClient/src/live-serviceSearch-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {Logger} from "@aws-lambda-powertools/logger"
import {getSecret} from "@aws-lambda-powertools/parameters/secrets"
import axios, {AxiosError, AxiosInstance} from "axios"
import axiosRetry from "axios-retry"
import {handleUrl} from "./handleUrl"
Expand All @@ -14,10 +15,24 @@ type Service = {
"OrganisationSubType": string
}

type Contact = {
"ContactMethodType": string
"ContactValue": string
}

export type ServiceSearchData = {
"value": Array<Service>
}

export type ServiceSearch3Data = {
"@odata.context": string
"value": Array<{
"@search.score": number
"OrganisationSubType": string
"Contacts": Array<Contact>
}>
}

export const SERVICE_SEARCH_BASE_QUERY_PARAMS = {
"api-version": 2,
"searchFields": "ODSCode",
Expand All @@ -26,26 +41,40 @@ export const SERVICE_SEARCH_BASE_QUERY_PARAMS = {
"$top": 1
}

export function getServiceSearchEndpoint(): string {
export function getServiceSearchVersion(logger: Logger | null = null): number {
const endpoint = process.env.TargetServiceSearchServer || "service-search"
const baseUrl = `https://${endpoint}`
if (endpoint.toLowerCase().includes("api.service.nhs.uk")) {
// service search v3
logger?.info("Using service search v3 endpoint")
SERVICE_SEARCH_BASE_QUERY_PARAMS["api-version"] = 3
return `${baseUrl}/service-search-api/`
SERVICE_SEARCH_BASE_QUERY_PARAMS["$select"] = "Contacts,OrganisationSubType"
return 3
}
logger?.warn("Using service search v2 endpoint")
return 2
}

export function getServiceSearchEndpoint(logger: Logger | null = null): string {
switch (getServiceSearchVersion(logger)) {
case 3:
return `https://${process.env.TargetServiceSearchServer}/service-search-api/`
case 2:
default:
return `https://${process.env.TargetServiceSearchServer}/service-search`
}
// service search v2
return `${baseUrl}/service-search`
}

export class LiveServiceSearchClient implements ServiceSearchClient {
private readonly axiosInstance: AxiosInstance
private readonly logger: Logger
private readonly outboundHeaders: {"apikey": string | undefined, "Subscription-Key": string | undefined}
private readonly outboundHeaders: {"apikey"?: string, "Subscription-Key"?: string}

constructor(logger: Logger) {
this.logger = logger

this.logger.info("ServiceSearchClient configured",
{
v2: process.env.ServiceSearchApiKey !== undefined,
v3: process.env.ServiceSearch3ApiKey !== undefined
})
this.axiosInstance = axios.create()
axiosRetry(this.axiosInstance, {retries: 3})

Expand Down Expand Up @@ -95,15 +124,49 @@ export class LiveServiceSearchClient implements ServiceSearchClient {
return Promise.reject(err)
})

this.outboundHeaders = {
"Subscription-Key": process.env.ServiceSearchApiKey,
"apikey": process.env.ServiceSearch3ApiKey
const version = getServiceSearchVersion(this.logger)
if (version === 3) {
this.outboundHeaders = {
"apikey": process.env.ServiceSearch3ApiKey
}
} else {
this.outboundHeaders = {
"Subscription-Key": process.env.ServiceSearchApiKey
}
}
}

private async loadApiKeyFromSecretsManager(): Promise<string | undefined> {
try {
const secretArn = process.env.ServiceSearch3ApiKeyARN
if (!secretArn) {
this.logger.error("ServiceSearch3ApiKeyARN environment variable is not set")
return undefined
}
this.logger.info("Loading ServiceSearch API key from Secrets Manager", {secretArn})

const secret = await getSecret(secretArn, {
maxAge: 300 // Cache for 5 minutes
})

this.logger.info("Successfully loaded ServiceSearch API key from Secrets Manager")
return secret as string
} catch (error) {
this.logger.error("Failed to load ServiceSearch API key from Secrets Manager", {error})
return undefined
}
}

async searchService(odsCode: string): Promise<URL | undefined> {
try {
const address = getServiceSearchEndpoint()
// Load API key if not set in environment (secrets layer is failing to load v3 key)
const apiVsn = getServiceSearchVersion(this.logger)
if (apiVsn === 3 && !this.outboundHeaders.apikey) {
this.logger.info("API key not in environment, attempting to load from Secrets Manager")
this.outboundHeaders.apikey = await this.loadApiKeyFromSecretsManager()
}

const address = getServiceSearchEndpoint(this.logger)
const queryParams = {...SERVICE_SEARCH_BASE_QUERY_PARAMS, search: odsCode}

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

const serviceSearchResponse: ServiceSearchData = response.data
const services = serviceSearchResponse.value
if (services.length === 0) {
return undefined
this.logger.info(`received response from serviceSearch for ods code ${odsCode}`,
{odsCode: odsCode, status: response.status, data: response.data})
if (apiVsn === 2) {
return this.handleV2Response(odsCode, response.data)
} else {
return this.handleV3Response(odsCode, response.data)
}

this.logger.info(`pharmacy with ods code ${odsCode} is of type ${DISTANCE_SELLING}`, {odsCode: odsCode})
const service = services[0]
const urlString = service["URL"]

if (urlString === null) {
this.logger.warn(`ods code ${odsCode} has no URL but is of type ${DISTANCE_SELLING}`, {odsCode: odsCode})
return undefined
}
const serviceUrl = handleUrl(urlString, odsCode, this.logger)
return serviceUrl
} catch (error) {
if (axios.isAxiosError(error)) {
this.stripApiKeyFromHeaders(error)
Expand Down Expand Up @@ -157,14 +212,43 @@ export class LiveServiceSearchClient implements ServiceSearchClient {
throw error
}
}
handleV3Response(odsCode: string, data: ServiceSearch3Data): URL | undefined {
const contacts = data.value[0]?.Contacts
const websiteContact = contacts?.find((contact: Contact) => contact.ContactMethodType === "Website")
const websiteUrl = websiteContact?.ContactValue
if (!websiteUrl) {
this.logger.warn(`pharmacy with ods code ${odsCode} has no website`, {odsCode: odsCode})
return undefined
}
const serviceUrl = handleUrl(websiteUrl, odsCode, this.logger)
return serviceUrl
}

handleV2Response(odsCode: string, data: ServiceSearchData): URL | undefined {
const services = data.value
if (services.length === 0) {
return undefined
}

this.logger.info(`pharmacy with ods code ${odsCode} is of type ${DISTANCE_SELLING}`, {odsCode: odsCode})
const service = services[0]
const urlString = service["URL"]

if (urlString === null) {
this.logger.warn(`ods code ${odsCode} has no URL but is of type ${DISTANCE_SELLING}`, {odsCode: odsCode})
return undefined
}
const serviceUrl = handleUrl(urlString, odsCode, this.logger)
return serviceUrl
}

stripApiKeyFromHeaders(error: AxiosError) {
const headerKeys = ["subscription-key", "apikey"]
headerKeys.forEach((key) => {
if (error.response?.headers) {
if (error.response?.headers?.[key]) {
delete error.response.headers[key]
}
if (error.request?.headers) {
if (error.request?.headers?.[key]) {
delete error.request.headers[key]
}
})
Expand Down
Loading