Skip to content

Commit 233fcb6

Browse files
committed
FLS-1452 - Create and register new PreAwardApiService
This new service facilitates calls to endpoints in the Pre-Award API - the GET /forms/{name}/hash endpoint, and the GET /forms/{name}/published endpoint. It handles authentication and errors.
1 parent 940503e commit 233fcb6

File tree

5 files changed

+190
-1
lines changed

5 files changed

+190
-1
lines changed

runner/config/custom-environment-variables.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,6 @@
6969
"sentryDsn": "SENTRY_DSN",
7070
"sentryTracesSampleRate": "SENTRY_TRACES_SAMPLE_RATE",
7171
"copilotEnv": "COPILOT_ENV",
72-
"enableVirusScan": "ENABLE_VIRUS_SCAN"
72+
"enableVirusScan": "ENABLE_VIRUS_SCAN",
73+
"preAwardApiUrl": "PRE_AWARD_API_URL"
7374
}

runner/config/default.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,6 @@ module.exports = {
114114
copilotEnv: "",
115115

116116
enableVirusScan: false,
117+
118+
preAwardApiUrl: "https://api.communities.gov.localhost:4004/forms"
117119
};

runner/src/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import LanguagePlugin from "./plugins/LanguagePlugin";
4040
import {TranslationLoaderService} from "./services/TranslationLoaderService";
4141
import {WebhookService} from "./services/WebhookService";
4242
import {pluginLog} from "./plugins/logging";
43+
import { PreAwardApiService } from "./services/PreAwardApiService";
4344

4445
const Sentry = require('@sentry/node');
4546

@@ -133,6 +134,7 @@ async function createServer(routeConfig: RouteConfig) {
133134
await server.register(pluginAuth);
134135
await server.register(LanguagePlugin);
135136

137+
server.registerService([PreAwardApiService]);
136138
server.registerService([AdapterCacheService, NotifyService, PayService, WebhookService, AddressService, TranslationLoaderService]);
137139
if (config.isE2EModeEnabled && config.isE2EModeEnabled == "true") {
138140
console.log("E2E Mode enabled")
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { HapiServer, HapiRequest } from "../types";
2+
import { config } from "../plugins/utils/AdapterConfigurationSchema";
3+
import Boom from "boom";
4+
import wreck from "@hapi/wreck";
5+
6+
export interface FormHashResponse {
7+
hash: string;
8+
}
9+
10+
export interface PublishedFormResponse {
11+
configuration: any;
12+
hash: string;
13+
}
14+
15+
const LOGGER_DATA = {
16+
class: "PreAwardApiService",
17+
}
18+
19+
export class PreAwardApiService {
20+
private logger: any;
21+
private apiBaseUrl: string;
22+
private wreck: typeof wreck;
23+
24+
constructor(server: HapiServer) {
25+
this.logger = server.logger;
26+
this.apiBaseUrl = config.preAwardApiUrl;
27+
// Create a wreck client with default options for all requests
28+
this.wreck = wreck.defaults({
29+
timeout: 10000,
30+
headers: {
31+
'accept': 'application/json',
32+
'content-type': 'application/json'
33+
}
34+
});
35+
this.logger.info({
36+
...LOGGER_DATA,
37+
message: `Service initialized with base URL: ${this.apiBaseUrl}`
38+
});
39+
}
40+
41+
/**
42+
* Extracts and formats authentication headers from the incoming request.
43+
* The Pre-Award API uses the same authentication mechanism as the Runner,
44+
* so we forward the user's authentication token to maintain security.
45+
*
46+
* This supports both cookie-based JWT tokens and Authorization headers,
47+
* depending on how the Runner is configured.
48+
*/
49+
private getAuthHeaders(request?: HapiRequest): Record<string, string> {
50+
const headers: Record<string, string> = {};
51+
if (!request) {
52+
this.logger.warn({
53+
...LOGGER_DATA,
54+
message: "No request context provided for authentication"
55+
});
56+
return headers;
57+
}
58+
// Handle JWT cookie authentication (primary method)
59+
if (config.jwtAuthEnabled === "true") {
60+
const cookieName = config.jwtAuthCookieName;
61+
// Check for JWT token in cookies
62+
if (request.state && request.state[cookieName]) {
63+
headers['Cookie'] = `${cookieName}=${request.state[cookieName]}`;
64+
this.logger.debug({
65+
...LOGGER_DATA,
66+
message: "Using JWT cookie for Pre-Award API authentication"
67+
});
68+
}
69+
// Fallback to Authorization header if no cookie
70+
else if (request.headers.authorization) {
71+
headers['Authorization'] = request.headers.authorization;
72+
this.logger.debug({
73+
...LOGGER_DATA,
74+
message: "Using Authorization header for Pre-Award API authentication"
75+
});
76+
}
77+
else {
78+
this.logger.warn({
79+
...LOGGER_DATA,
80+
message: "No authentication token found in request"
81+
});
82+
}
83+
}
84+
return headers;
85+
}
86+
87+
/**
88+
* Fetches the published form data including hash for a specific form.
89+
* This is the primary method used by the cache service when a form
90+
* is requested by a user. It only returns forms that have been
91+
* explicitly published in the Pre-Award system.
92+
*/
93+
async getPublishedForm(name: string, request?: HapiRequest): Promise<PublishedFormResponse | null> {
94+
const url = `${this.apiBaseUrl}/${name}/published`;
95+
const authHeaders = this.getAuthHeaders(request);
96+
this.logger.info({
97+
...LOGGER_DATA,
98+
message: `Fetching published form: ${name}`,
99+
url: url
100+
});
101+
try {
102+
const { payload } = await this.wreck.get(url, {
103+
headers: {
104+
...authHeaders,
105+
'accept': 'application/json'
106+
},
107+
json: true
108+
});
109+
this.logger.info({
110+
...LOGGER_DATA,
111+
message: `Successfully fetched published form: ${name}`
112+
});
113+
return payload as PublishedFormResponse;
114+
} catch (error: any) {
115+
// Handle 404 - form doesn't exist or isn't published
116+
if (error.output?.statusCode === 404) {
117+
this.logger.info({
118+
...LOGGER_DATA,
119+
message: `Form ${name} not found or not published in Pre-Award API`
120+
});
121+
return null;
122+
}
123+
// Handle authentication failures
124+
if (error.output?.statusCode === 401 || error.output?.statusCode === 403) {
125+
this.logger.error({
126+
...LOGGER_DATA,
127+
message: `Authentication failed when fetching form ${name}`,
128+
statusCode: error.output?.statusCode
129+
});
130+
throw Boom.unauthorized('Failed to authenticate with Pre-Award API');
131+
}
132+
// Handle other errors (network, timeout, server errors)
133+
this.logger.error({
134+
...LOGGER_DATA,
135+
message: `Failed to fetch published form ${name}`,
136+
error: error.message
137+
});
138+
// Don't expose internal error details to the client
139+
throw Boom.serverUnavailable('Pre-Award API is temporarily unavailable');
140+
}
141+
}
142+
143+
/**
144+
* Fetches just the hash of a published form.
145+
* This lightweight endpoint allows us to validate our cache without
146+
* downloading the entire form definition. We use this for periodic
147+
* cache freshness checks.
148+
*/
149+
async getFormHash(name: string, request?: HapiRequest): Promise<string | null> {
150+
const url = `${this.apiBaseUrl}/${name}/hash`;
151+
const authHeaders = this.getAuthHeaders(request);
152+
try {
153+
const { payload } = await this.wreck.get(url, {
154+
headers: {
155+
...authHeaders,
156+
'accept': 'application/json'
157+
},
158+
json: true,
159+
timeout: 5000 // Shorter timeout for hash checks
160+
});
161+
const data = payload as FormHashResponse;
162+
this.logger.debug({
163+
...LOGGER_DATA,
164+
message: `Retrieved hash for form ${name}`
165+
});
166+
return data.hash;
167+
} catch (error: any) {
168+
if (error.output?.statusCode === 404) {
169+
// Form doesn't exist or isn't published - this is normal
170+
return null;
171+
}
172+
// For hash validation failures, we don't want to fail the entire request.
173+
// We'll continue using the cached version and try again later.
174+
this.logger.warn({
175+
...LOGGER_DATA,
176+
message: `Could not fetch hash for form ${name}, will use cached version`,
177+
error: error.message
178+
});
179+
return null;
180+
}
181+
}
182+
}

runner/src/server/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {AdapterCacheService, S3UploadService} from "./services";
1717
import {AdapterStatusService} from "./services";
1818
import {WebhookService} from "./services/WebhookService";
1919
import {TranslationLoaderService} from "./services/TranslationLoaderService";
20+
import { PreAwardApiService } from "./services/PreAwardApiService";
2021

2122

2223
export type ChangeRequest = {
@@ -32,6 +33,7 @@ export type Services = (services: string[]) => {
3233
webhookService: WebhookService;
3334
adapterStatusService: AdapterStatusService;
3435
translationLoaderService: TranslationLoaderService;
36+
preAwardApiService: PreAwardApiService;
3537
};
3638

3739
export type RouteConfig = {

0 commit comments

Comments
 (0)