Skip to content

Commit 4a316f7

Browse files
authored
Add API Proxy, Enhance Tabs Content and add Proper Data Types (#2766)
1 parent a4d9a24 commit 4a316f7

85 files changed

Lines changed: 9266 additions & 213 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-dcm': minor
3+
'@red-hat-developer-hub/backstage-plugin-dcm-backend': minor
4+
'@red-hat-developer-hub/backstage-plugin-dcm-common': minor
5+
---
6+
7+
Refactor DCM frontend plugin for reusability, maintainability, and test coverage.
8+
9+
**New shared utilities & hooks**
10+
11+
- `createYupValidator` – factory that wraps a Yup schema and returns stable `validate` / `isValid` helpers, eliminating per-tab validation boilerplate.
12+
- `useCrudTab` – custom React hook that centralises data loading, client-side search/pagination, and create/edit/delete dialog state for every CRUD tab. Tabs now consist only of feature-specific rendering logic.
13+
14+
**New shared components**
15+
16+
- `DcmCrudTabLayout` – generic layout that handles loading spinners, load-error alerts with a Retry button, empty states, and a searchable paginated table inside an `InfoCard`.
17+
- `DcmFormDialogActions` – reusable Save / Cancel button row with loading spinner and disabled states, used by all form dialogs.
18+
- `DcmErrorSnackbar` – transient error snackbar for surfacing delete-operation failures.
19+
- `DcmDeleteDialog` – standalone confirmation dialog component extracted from inline usage.
20+
21+
**Per-feature file decomposition**
22+
23+
Each CRUD tab now has dedicated files for form types, Yup schema, field components, and column definitions:
24+
25+
- `providers/``providerFormTypes.ts`, `components/ProviderFormFields.tsx`, `components/ProviderStatus.tsx`, `components/CopyButton.tsx`
26+
- `policies/``policyFormTypes.ts`, `components/PolicyFormFields.tsx`
27+
- `catalog-items/``catalogItemFormTypes.ts`, `components/CatalogItemFormFields.tsx`
28+
- `catalog-item-instances/``instanceFormTypes.ts`, `components/InstanceFormFields.tsx`
29+
- `resources/``resourceFormTypes.ts`, `components/ResourceFormFields.tsx`
30+
31+
**Error handling improvements**
32+
33+
Load errors and delete errors are now surfaced in the UI via `DcmCrudTabLayout` (inline alert with Retry) and `DcmErrorSnackbar` respectively, replacing silent `.catch(() => {})` handlers.
34+
35+
**Dead code removal**
36+
37+
Removed the unused `ExampleComponent` directory and its tests.
38+
39+
**Test coverage**
40+
41+
Added unit tests for `extractApiError`, `createYupValidator`, `useCrudTab`, `DcmFormDialogActions`, `DcmDeleteDialog`, and form-type validators for providers, policies, and resources.

workspaces/dcm/app-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ dcm:
6565
- security-baseline
6666
- compliance-pci
6767
- audit-logging
68+
# Base URL of the DCM API Gateway. All three API services (catalog,
69+
# policy-manager, providers) are routed through this single endpoint.
70+
# Override in app-config.local.yaml for local development.
71+
# apiGatewayUrl: https://your-api-gateway.example.com
72+
#
73+
# SSO credentials for the backend to obtain a bearer token:
74+
# ssoBaseUrl: https://sso.redhat.com
75+
# clientId: your-client-id
76+
# clientSecret: your-client-secret
6877

6978
catalog:
7079
import:

workspaces/dcm/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"tsc:full": "tsc --skipLibCheck true --incremental false",
1717
"build:all": "backstage-cli repo build --all",
1818
"build:api-reports": "yarn build:api-reports:only --tsc",
19-
"build:api-reports:only": "backstage-repo-tools api-reports -o ae-wrong-input-file-type,ae-missing-release-tag --validate-release-tags",
19+
"build:api-reports:only": "backstage-repo-tools api-reports -o ae-wrong-input-file-type,ae-missing-release-tag,ae-undocumented --validate-release-tags",
2020
"build:knip-reports": "backstage-repo-tools knip-reports",
2121
"clean": "backstage-cli repo clean",
2222
"test": "backstage-cli repo test",
@@ -68,5 +68,8 @@
6868
"*.{json,md}": [
6969
"prettier --write"
7070
]
71+
},
72+
"dependencies": {
73+
"yup": "^1.7.1"
7174
}
7275
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export interface Config {
18+
dcm: {
19+
/**
20+
* Base URL of the DCM API Gateway.
21+
*
22+
* All API services (catalog, policy-manager, providers) are routed
23+
* through this single gateway. The backend appends `/api/v1alpha1/<path>`
24+
* to construct the upstream URL.
25+
*
26+
* @example "http://localhost:9080"
27+
* @visibility backend
28+
*/
29+
apiGatewayUrl?: string;
30+
31+
/**
32+
* Base URL for the SSO token endpoint.
33+
*
34+
* Defaults to "https://sso.redhat.com".
35+
*
36+
* @visibility backend
37+
*/
38+
ssoBaseUrl?: string;
39+
40+
/**
41+
* SSO client ID used to obtain a bearer token for upstream API calls.
42+
*
43+
* @visibility secret
44+
*/
45+
clientId: string;
46+
47+
/**
48+
* SSO client secret used to obtain a bearer token for upstream API calls.
49+
*
50+
* @visibility secret
51+
*/
52+
clientSecret: string;
53+
};
54+
}

workspaces/dcm/plugins/dcm-backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"url": "https://github.com/redhat-developer/rhdh-plugins",
1515
"directory": "workspaces/dcm/plugins/dcm-backend"
1616
},
17+
"configSchema": "config.d.ts",
1718
"backstage": {
1819
"role": "backend-plugin",
1920
"pluginId": "dcm",
@@ -38,7 +39,6 @@
3839
"@backstage/plugin-permission-common": "^0.8.4",
3940
"@backstage/plugin-permission-node": "^0.8.7",
4041
"@red-hat-developer-hub/backstage-plugin-dcm-common": "workspace:^",
41-
"assert": "^2.1.0",
4242
"express": "^4.17.1",
4343
"express-promise-router": "^4.1.0"
4444
},

workspaces/dcm/plugins/dcm-backend/src/plugin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export const dcmPlugin = createBackendPlugin({
5757
path: '/access',
5858
allow: 'user-cookie',
5959
});
60+
httpRouter.addAuthPolicy({
61+
path: '/proxy',
62+
allow: 'user-cookie',
63+
});
6064
},
6165
});
6266
},

workspaces/dcm/plugins/dcm-backend/src/router.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import Router from 'express-promise-router';
2121
import type { RouterOptions } from './models/RouterOptions';
2222
import { getToken } from './routes/token';
2323
import { getAccess } from './routes/access';
24+
import { createDcmProxy } from './routes/proxy';
2425

2526
export async function createRouter(
2627
options: RouterOptions,
@@ -41,5 +42,7 @@ export async function createRouter(
4142
router.get('/token', getToken(options));
4243
router.get('/access', getAccess(options));
4344

45+
router.all('/proxy/*', createDcmProxy(options));
46+
4447
return router;
4548
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/* eslint-disable @backstage/no-undeclared-imports -- deps in dcm-backend package.json */
18+
import { mockServices } from '@backstage/backend-test-utils';
19+
import express from 'express';
20+
import request from 'supertest';
21+
22+
import { createDcmProxy } from './proxy';
23+
import type { RouterOptions } from '../models/RouterOptions';
24+
25+
const TOKEN_RESPONSE = {
26+
ok: true,
27+
json: async () => ({ access_token: 'test-token', expires_in: 3600 }),
28+
} as Response;
29+
30+
function makeApp(configData: Record<string, Record<string, string>> = {}) {
31+
const options: RouterOptions = {
32+
logger: mockServices.rootLogger(),
33+
config: mockServices.rootConfig({ data: configData }),
34+
httpAuth: mockServices.httpAuth.mock({
35+
credentials: jest.fn().mockResolvedValue({
36+
principal: { userEntityRef: 'user:default/test' },
37+
}),
38+
}),
39+
permissions: mockServices.permissions.mock({
40+
authorize: jest.fn().mockResolvedValue([{ result: 'ALLOW' }]),
41+
}),
42+
cache: mockServices.cache.mock(),
43+
};
44+
const app = express();
45+
app.use(express.json());
46+
// Mount using a wildcard path matching the router convention
47+
app.all('/proxy/*', createDcmProxy(options));
48+
return app;
49+
}
50+
51+
describe('createDcmProxy', () => {
52+
let fetchSpy: jest.SpyInstance;
53+
54+
afterEach(() => {
55+
fetchSpy?.mockRestore();
56+
});
57+
58+
it('returns 503 when dcm.apiGatewayUrl is not configured', async () => {
59+
const app = makeApp({ dcm: { clientId: 'id', clientSecret: 'secret' } });
60+
61+
const res = await request(app).get('/proxy/providers');
62+
63+
expect(res.status).toBe(503);
64+
expect(res.body).toMatchObject({
65+
error: expect.stringContaining('not configured'),
66+
});
67+
});
68+
69+
it('returns 502 when token acquisition fails', async () => {
70+
fetchSpy = jest
71+
.spyOn(globalThis, 'fetch')
72+
.mockRejectedValueOnce(new Error('SSO unreachable'));
73+
74+
const app = makeApp({
75+
dcm: {
76+
apiGatewayUrl: 'https://gateway.example.com',
77+
clientId: 'id',
78+
clientSecret: 'secret',
79+
},
80+
});
81+
82+
const res = await request(app).get('/proxy/providers');
83+
84+
expect(res.status).toBe(502);
85+
expect(res.body).toMatchObject({
86+
error: expect.stringContaining('access token'),
87+
});
88+
});
89+
90+
it('returns 502 when the upstream fetch throws', async () => {
91+
fetchSpy = jest
92+
.spyOn(globalThis, 'fetch')
93+
// First call: token fetch succeeds
94+
.mockResolvedValueOnce(TOKEN_RESPONSE)
95+
// Second call: upstream fetch throws
96+
.mockRejectedValueOnce(new Error('Connection refused'));
97+
98+
const app = makeApp({
99+
dcm: {
100+
apiGatewayUrl: 'https://gateway.example.com',
101+
clientId: 'id',
102+
clientSecret: 'secret',
103+
},
104+
});
105+
106+
const res = await request(app).get('/proxy/providers');
107+
108+
expect(res.status).toBe(502);
109+
expect(res.body).toMatchObject({
110+
error: expect.stringContaining('DCM API gateway'),
111+
});
112+
});
113+
114+
it('proxies a GET request and forwards the upstream response', async () => {
115+
const upstreamBody = JSON.stringify({ items: [] });
116+
fetchSpy = jest
117+
.spyOn(globalThis, 'fetch')
118+
// Token fetch
119+
.mockResolvedValueOnce(TOKEN_RESPONSE)
120+
// Upstream GET
121+
.mockResolvedValueOnce({
122+
status: 200,
123+
ok: true,
124+
headers: {
125+
get: (h: string) =>
126+
h === 'content-type' ? 'application/json' : null,
127+
},
128+
text: async () => upstreamBody,
129+
} as unknown as Response);
130+
131+
const app = makeApp({
132+
dcm: {
133+
apiGatewayUrl: 'https://gateway.example.com',
134+
clientId: 'id',
135+
clientSecret: 'secret',
136+
},
137+
});
138+
139+
const res = await request(app).get('/proxy/providers?foo=bar');
140+
141+
expect(res.status).toBe(200);
142+
expect(res.text).toBe(upstreamBody);
143+
144+
// Verify upstream URL contains path and query param
145+
const upstreamCall = fetchSpy.mock.calls[1];
146+
expect(upstreamCall[0]).toContain('/api/v1alpha1/providers');
147+
expect(upstreamCall[0]).toContain('foo=bar');
148+
149+
// Verify auth header was injected
150+
expect(upstreamCall[1].headers.Authorization).toBe('Bearer test-token');
151+
});
152+
153+
it('proxies a POST request and forwards the request body', async () => {
154+
const requestBody = { name: 'my-provider' };
155+
fetchSpy = jest
156+
.spyOn(globalThis, 'fetch')
157+
.mockResolvedValueOnce(TOKEN_RESPONSE)
158+
.mockResolvedValueOnce({
159+
status: 201,
160+
ok: true,
161+
headers: {
162+
get: (h: string) =>
163+
h === 'content-type' ? 'application/json' : null,
164+
},
165+
text: async () => JSON.stringify(requestBody),
166+
} as unknown as Response);
167+
168+
const app = makeApp({
169+
dcm: {
170+
apiGatewayUrl: 'https://gateway.example.com',
171+
clientId: 'id',
172+
clientSecret: 'secret',
173+
},
174+
});
175+
176+
const res = await request(app)
177+
.post('/proxy/providers')
178+
.send(requestBody)
179+
.set('Content-Type', 'application/json');
180+
181+
expect(res.status).toBe(201);
182+
183+
const upstreamCall = fetchSpy.mock.calls[1];
184+
expect(upstreamCall[1].method).toBe('POST');
185+
expect(JSON.parse(upstreamCall[1].body)).toEqual(requestBody);
186+
});
187+
188+
it('handles 204 No Content upstream responses without a body', async () => {
189+
fetchSpy = jest
190+
.spyOn(globalThis, 'fetch')
191+
.mockResolvedValueOnce(TOKEN_RESPONSE)
192+
.mockResolvedValueOnce({
193+
status: 204,
194+
ok: true,
195+
headers: { get: () => null },
196+
text: async () => '',
197+
} as unknown as Response);
198+
199+
const app = makeApp({
200+
dcm: {
201+
apiGatewayUrl: 'https://gateway.example.com',
202+
clientId: 'id',
203+
clientSecret: 'secret',
204+
},
205+
});
206+
207+
const res = await request(app).delete('/proxy/providers/test-id');
208+
209+
expect(res.status).toBe(204);
210+
expect(res.text).toBe('');
211+
});
212+
});

0 commit comments

Comments
 (0)