11import { Logger } from "@aws-lambda-powertools/logger"
2+ import { getSecret } from "@aws-lambda-powertools/parameters/secrets"
23import axios , { AxiosError , AxiosInstance } from "axios"
34import axiosRetry from "axios-retry"
45import { 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+
1723export 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+
2136export 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
4166export 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