Skip to content

Commit 00cb3e0

Browse files
committed
feat: implement env.js configuration pattern and add nginx /api proxy
- Add env.js with relative paths for local dev (proxy.conf.json routes to dev OpenShift) - Add deploy-to-dev.yaml sed commands to modify env.js for deployed environments - Add /api proxy block to nginx default.conf (routes to eagle-api:3000) - Update ConfigService to use env.js + API pattern (matches reserve-rec-public) - Update AnalyticsService to use simplified ANALYTICS_API_URL from env.js - Update proxy.conf.json to route /api to dev OpenShift and /api/analytics to localhost:3001 - Fix api.spec.ts test to mock ConfigService properly - Update ApiService to handle API_PATH as full URL or fallback to API_LOCATION + API_PATH - Clean up nginx-runtime run script with better comments - Add env.js to angular.json assets in development configuration
1 parent 17b7df4 commit 00cb3e0

File tree

12 files changed

+208
-94
lines changed

12 files changed

+208
-94
lines changed

.github/workflows/deploy-to-dev.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ jobs:
4242
cache: "yarn"
4343
- run: yarn install --immutable
4444

45+
- name: Update env.js for dev environment
46+
run: |
47+
# Update env.js to enable configEndpoint for deployed environment
48+
# In deployed environments, nginx proxies /api to the backend
49+
sed -i "s|window.__env.configEndpoint = false;|window.__env.configEndpoint = true;|g" src/env.js
50+
sed -i "s|window.__env.ENVIRONMENT = 'local';|window.__env.ENVIRONMENT = 'dev';|g" src/env.js
51+
echo "Updated env.js for dev environment:"
52+
cat src/env.js
53+
4554
- name: Angular Build
4655
run: yarn build
4756

.yarn/install-state.gz

1.9 KB
Binary file not shown.

angular.json

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,22 @@
8282
]
8383
},
8484
"development": {
85-
"optimization": false
85+
"optimization": false,
86+
"assets": [
87+
"src/favicon.ico",
88+
"src/assets",
89+
"src/env.js",
90+
{
91+
"glob": "**/*",
92+
"input": "./node_modules/@ant-design/icons-angular/src/inline-svg/",
93+
"output": "/assets/"
94+
},
95+
{
96+
"glob": "**/*",
97+
"input": "./node_modules/@ant-design/icons-angular/src/inline-svg/",
98+
"output": "/assets/"
99+
}
100+
]
86101
}
87102
}
88103
},
@@ -135,7 +150,6 @@
135150
"assets": [
136151
"src/favicon.ico",
137152
"src/assets",
138-
"src/env.js",
139153
{
140154
"glob": "**/*",
141155
"input": "./node_modules/@ant-design/icons-angular/src/inline-svg/",

openshift/templates/nginx-runtime/default.conf

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ server {
2929
proxy_read_timeout 10s;
3030
}
3131

32+
# Main API reverse proxy to eagle-api
33+
location /api {
34+
proxy_pass http://eagle-api:3000/api;
35+
proxy_http_version 1.1;
36+
proxy_set_header Host $host;
37+
proxy_set_header X-Real-IP $remote_addr;
38+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
39+
proxy_set_header X-Forwarded-Proto $scheme;
40+
proxy_connect_timeout 10s;
41+
proxy_send_timeout 60s;
42+
proxy_read_timeout 60s;
43+
}
44+
3245
# serve our angular app here
3346
location / {
3447
alias /tmp/app/dist/;
Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
#!/bin/bash
2-
echo run script starting...
2+
echo "Starting nginx-runtime..."
33

44
# Process nginx.conf template (if exists)
55
if [ -f /tmp/nginx.conf.template ]; then
66
sed "s~%RealIpFrom%~${RealIpFrom:-172.51.0.0/16}~g; s~%IpFilterRules%~${IpFilterRules}~g; s~%AdditionalRealIpFromRules%~${AdditionalRealIpFromRules}~g" /tmp/nginx.conf.template > /etc/nginx/nginx.conf
77
fi
88

9+
# HTTP basic auth (if configured)
910
if [ -n "$HTTP_BASIC_USERNAME" ] && [ -n "$HTTP_BASIC_PASSWORD" ]; then
1011
echo "---> Generating .htpasswd file"
11-
`echo "$HTTP_BASIC_USERNAME:$(openssl passwd -crypt $HTTP_BASIC_PASSWORD)" > /tmp/.htpasswd`
12+
echo "$HTTP_BASIC_USERNAME:$(openssl passwd -crypt $HTTP_BASIC_PASSWORD)" > /tmp/.htpasswd
1213
fi
1314

15+
# Note: Configuration is now loaded from the API (/api/config) at runtime
16+
# No need to generate env.js - the frontend fetches config from eagle-api
17+
18+
echo "Starting nginx..."
1419
/usr/sbin/nginx -g "daemon off;"

proxy.conf.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
{
2+
"/api": {
3+
"target": "https://eagle-dev.apps.silver.devops.gov.bc.ca",
4+
"secure": true,
5+
"changeOrigin": true,
6+
"logLevel": "debug"
7+
},
28
"/api/analytics": {
3-
"target": "http://localhost:3000",
9+
"target": "http://localhost:3001",
410
"secure": false,
511
"changeOrigin": true,
612
"pathRewrite": {

src/app/services/analytics/analytics.service.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,11 @@ import { penguinAnalyticsPlugin } from './penguin-analytics-plugin';
66
interface EnvConfig {
77
ANALYTICS_API_URL?: string;
88
ANALYTICS_DEBUG?: boolean;
9-
API_LOCATION?: string;
10-
API_PATH?: string;
9+
ENVIRONMENT?: string;
1110
}
1211

1312
declare const window: Window & { __env?: EnvConfig };
1413

15-
const buildDefaultAnalyticsUrl = (env?: EnvConfig): string => {
16-
const base = env?.API_LOCATION?.replace(/\/$/, '') || '';
17-
const apiPath = env?.API_PATH || '/api';
18-
const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
19-
return base ? `${base}${normalizedPath}/telemetry` : `${normalizedPath}/telemetry`;
20-
};
21-
2214
/**
2315
* Analytics service using Analytics.io with Penguin Analytics plugin.
2416
*
@@ -51,9 +43,9 @@ export class AnalyticsService {
5143
private initialized = false;
5244

5345
constructor() {
54-
const env = window.__env;
55-
const apiUrl = env?.ANALYTICS_API_URL || buildDefaultAnalyticsUrl(env);
56-
const debug = env?.ANALYTICS_DEBUG ?? false;
46+
const env = window.__env || {};
47+
const apiUrl = env.ANALYTICS_API_URL || 'http://localhost:3001';
48+
const debug = env.ANALYTICS_DEBUG ?? (env.ENVIRONMENT === 'local');
5749

5850
this.analytics = Analytics({
5951
app: 'eagle-admin',

src/app/services/api.spec.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,23 @@ describe('api', () => {
1818
const filter = {};
1919
let apiService: ApiService;
2020
let httpTestingController: HttpTestingController;
21+
let mockConfigService: jasmine.SpyObj<ConfigService>;
22+
2123
beforeEach(async () => {
24+
mockConfigService = jasmine.createSpyObj('ConfigService', ['init'], {
25+
config: {
26+
API_PATH: 'https://test-api.gov.bc.ca/api',
27+
ENVIRONMENT: 'test'
28+
}
29+
});
30+
2231
TestBed.configureTestingModule({
2332
imports: [],
2433
providers: [
2534
provideHttpClient(withInterceptorsFromDi()),
2635
provideHttpClientTesting(),
2736
ApiService,
28-
ConfigService,
37+
{ provide: ConfigService, useValue: mockConfigService },
2938
Utils,
3039
KeycloakService
3140
]
@@ -48,7 +57,7 @@ describe('api', () => {
4857
sortBy = '-datePosted';
4958
queryModifier = { documentSource: 'PROJECT' };
5059
populate = true;
51-
const expectedString = 'NaN/search?dataset=Document&Project=588511d9aaecd9001b826b33&keywords=%2522Pre-Application%2522%252C%2522Proponent%2520Comments%252FCorrespondence%2522%252C%2522Updated%2520Project%2520Description%2520%25232%2520for%2520the%2520Prince%2520Rupert%2520Gas%2520Transmission%2520Project%2520-%2520Northeast%2520to%2520British%2520Columbia%2520to%2520the%2520Prince%2520Rupert%2520Area%2520dated%2520%2520August%252014%252C%25202013%2522&pageNum=0&pageSize=10&projectLegislation=default&sortBy=-datePosted&populate=true&and%5BdocumentSource%5D=PROJECT';
60+
const expectedString = 'https://test-api.gov.bc.ca/api/search?dataset=Document&Project=588511d9aaecd9001b826b33&keywords=%2522Pre-Application%2522%252C%2522Proponent%2520Comments%252FCorrespondence%2522%252C%2522Updated%2520Project%2520Description%2520%25232%2520for%2520the%2520Prince%2520Rupert%2520Gas%2520Transmission%2520Project%2520-%2520Northeast%2520to%2520British%2520Columbia%2520to%2520the%2520Prince%2520Rupert%2520Area%2520dated%2520%2520August%252014%252C%25202013%2522&pageNum=0&pageSize=10&projectLegislation=default&sortBy=-datePosted&populate=true&and%5BdocumentSource%5D=PROJECT';
5261
apiService.searchKeywords(keys, schemaName, fields, pageNum, pageSize, projectLegislation, sortBy, queryModifier, populate, filter).subscribe();
5362
const req = httpTestingController.expectOne(expectedString);
5463
expect(req.request.method).toBe('GET');

src/app/services/api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ export class ApiService {
5555

5656
this.bannerColour = this.configService.config['BANNER_COLOUR'];
5757
this.env = this.configService.config['ENVIRONMENT'];
58-
this.pathAPI = this.configService.config['API_LOCATION'] + this.configService.config['API_PATH'];
58+
// API_PATH is now the full URL (e.g., https://eagle-dev.apps.silver.devops.gov.bc.ca/api)
59+
this.pathAPI = this.configService.config['API_PATH'] ||
60+
(this.configService.config['API_LOCATION'] + (this.configService.config['API_PATH'] || '/api'));
5961
}
6062

6163
handleError(error: any): Observable<never> {

src/app/services/config.service.ts

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
import { Injectable, inject } from '@angular/core';
2-
import { HttpClient } from '@angular/common/http';
2+
import { HttpClient, HttpHeaders } from '@angular/common/http';
3+
import { firstValueFrom } from 'rxjs';
4+
5+
interface EnvConfig {
6+
logLevel?: number;
7+
configEndpoint?: boolean;
8+
ENVIRONMENT?: string;
9+
BANNER_COLOUR?: string;
10+
API_PATH?: string;
11+
API_LOCATION?: string;
12+
KEYCLOAK_CLIENT_ID?: string;
13+
KEYCLOAK_URL?: string;
14+
KEYCLOAK_REALM?: string;
15+
KEYCLOAK_ENABLED?: boolean;
16+
ANALYTICS_API_URL?: string | null;
17+
ANALYTICS_DEBUG?: boolean;
18+
REDIRECT_KEY?: string;
19+
}
20+
21+
// env.js sets window.__env before Angular loads
22+
declare global {
23+
interface Window { __env: EnvConfig; }
24+
}
325

426
//
527
// This service/class provides a centralized place to persist config values
@@ -14,46 +36,118 @@ export class ConfigService {
1436
private _baseLayerName = 'World Topographic'; // NB: must match a valid base layer name
1537
private _lists = [];
1638
private _regions = [];
17-
private configuration = { };
39+
private configuration: EnvConfig = {};
40+
private configLoaded = false;
1841

1942
/**
20-
* Initialize the Config Service. Get configuration data from front-end build, or back-end if nginx
21-
* is configured to pass the /config endpoint to a dynamic service that returns JSON.
43+
* Initialize the Config Service.
44+
* Get configuration from env.js first, then from API if configEndpoint is true.
45+
* Pattern follows reserve-rec-public.
2246
*/
23-
public async init() {
24-
try {
25-
this.configuration = await this.httpClient.get('/api/config').toPromise();
47+
public async init(): Promise<void> {
48+
// Start with env.js values (loaded before Angular via script tag in index.html)
49+
this.configuration = window.__env || {};
2650

27-
console.log('Configuration:', this.configuration);
28-
if (this.configuration['debugMode']) {
29-
console.log('Configuration:', this.configuration);
30-
}
31-
} catch (e) {
32-
// Not configured
33-
console.log('Error getting configuration:', e);
34-
this.configuration = window['__env'];
35-
if (this.configuration['debugMode']) {
36-
console.log('Configuration:', this.configuration);
51+
if (this.configuration.logLevel === 0) {
52+
console.log('Initial configuration from env.js:', this.configuration);
53+
}
54+
55+
// If configEndpoint is true (deployed environments), fetch config from API
56+
if (this.configuration.configEndpoint === true) {
57+
try {
58+
const apiConfig = await this.getConfigFromApi();
59+
// Merge API config (API values take precedence)
60+
this.configuration = { ...this.configuration, ...apiConfig };
61+
} catch (e) {
62+
// If API fails, continue with env.js values
63+
console.error('Error getting API configuration, using env.js defaults:', e);
3764
}
3865
}
3966

67+
this.configLoaded = true;
68+
69+
if (this.configuration.logLevel === 0) {
70+
console.log('Final configuration:', this.configuration);
71+
}
72+
73+
// Get the Lists and set the Regions datasets
4074
try {
41-
// Get the Lists and set the Regions datasets
42-
const lists = await this.httpClient.get<any>(`${this.configuration['API_LOCATION']}${this.configuration['API_PATH']}/search?pageSize=1000&dataset=List`, {}).toPromise();
75+
const apiPath = this.getApiPath();
76+
const lists = await firstValueFrom(
77+
this.httpClient.get<any>(`${apiPath}/search?pageSize=1000&dataset=List`)
78+
);
4379
this._lists = lists[0].searchResults;
4480
this.populateRegionsList();
4581
} catch (e) {
46-
if (this.configuration['debugMode']) {
47-
console.log('Getting list error:', e);
82+
console.error('Error loading lists:', e);
83+
}
84+
}
85+
86+
/**
87+
* Get the API path for making API calls.
88+
* Uses API_LOCATION + API_PATH, otherwise falls back to relative /api.
89+
*/
90+
private getApiPath(): string {
91+
if (this.configuration.API_LOCATION) {
92+
return this.configuration.API_LOCATION + (this.configuration.API_PATH || '');
93+
}
94+
// Fallback to relative path (for deployed environments with nginx proxy)
95+
return '/api';
96+
}
97+
98+
/**
99+
* Fetch configuration from API endpoint.
100+
* Retries with fibonacci backoff if API is unavailable.
101+
*/
102+
private async getConfigFromApi(): Promise<EnvConfig> {
103+
let n1 = 0;
104+
let n2 = 1;
105+
let attempts = 0;
106+
const maxAttempts = 5;
107+
108+
while (attempts < maxAttempts) {
109+
try {
110+
const headers = new HttpHeaders().set('Authorization', 'config');
111+
// Use API_LOCATION if set, otherwise relative /api/config (nginx proxies in deployed env)
112+
const apiBase = this.configuration.API_LOCATION || '';
113+
const url = apiBase + '/api/config';
114+
115+
const response = await firstValueFrom(
116+
this.httpClient.get<any>(url, { headers, observe: 'response' })
117+
);
118+
return response.body?.data || response.body;
119+
} catch (err) {
120+
attempts++;
121+
if (attempts >= maxAttempts) {
122+
throw err;
123+
}
124+
console.log(`Config API attempt ${attempts} failed, retrying...`);
125+
const delay = n1 + n2;
126+
await this.delay(delay * 1000);
127+
n1 = n2;
128+
n2 = delay;
48129
}
49130
}
131+
throw new Error('Failed to load config from API');
132+
}
133+
134+
private async delay(ms: number): Promise<void> {
135+
return new Promise(resolve => setTimeout(resolve, ms));
136+
}
50137

51-
return Promise.resolve();
138+
get logLevel(): number {
139+
// Can be overridden by js console
140+
return window.__env?.logLevel ?? 4;
52141
}
53-
get config(): any {
142+
143+
get config(): EnvConfig {
54144
return this.configuration;
55145
}
56146

147+
get isConfigLoaded(): boolean {
148+
return this.configLoaded;
149+
}
150+
57151
// getters/setters
58152
get lists(): any[] { return this._lists; }
59153
get regions(): any[] { return this._regions; }

0 commit comments

Comments
 (0)