Skip to content

Commit ff43a83

Browse files
dpanshugd0w
authored andcommitted
Feature Store Context and API setup (opendatahub-io#4509)
* Feature Store Context and API setup: * Backend proxy setup * Routes * Context * Hooks * API * Unit tests * remove title icon * import updates and few changes * adding unit tests * PR review updates * Invalid project selector * adding prefix in proxy
1 parent 02efb0b commit ff43a83

38 files changed

+1699
-233
lines changed

.env.development

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ TRUSTYAI_TAIS_SERVICE_PORT=9443
1919
MODEL_REGISTRY_NAME=modelregistry-sample
2020
MODEL_REGISTRY_SERVICE_HOST=localhost
2121
MODEL_REGISTRY_SERVICE_PORT=8085
22-
MODEL_REGISTRY_NAMESPACE=odh-model-registries
22+
MODEL_REGISTRY_NAMESPACE=odh-model-registries
23+
24+
FEAST_REGISTRY_SERVICE_HOST=localhost
25+
FEAST_REGISTRY_SERVICE_PORT=8443
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { FeatureStoreKind } from '../../../../types';
2+
import { proxyService } from '../../../../utils/proxy';
3+
4+
export default proxyService<FeatureStoreKind>(
5+
{
6+
apiGroup: 'feast.dev',
7+
apiVersion: 'v1alpha1',
8+
kind: 'FeatureStore',
9+
plural: 'featurestores',
10+
},
11+
{
12+
internalPort: 443,
13+
suffix: '-registry-rest',
14+
prefix: 'feast-',
15+
},
16+
{
17+
// Use port forwarding for local development:
18+
// kubectl port-forward -n <namespace> svc/feast-<name>-registry-rest 8443:443
19+
host: process.env.FEAST_REGISTRY_SERVICE_HOST,
20+
port: process.env.FEAST_REGISTRY_SERVICE_PORT,
21+
},
22+
(resource) =>
23+
!!resource.status?.conditions?.find((c: any) => c.type === 'Registry' && c.status === 'True'),
24+
);

backend/src/types.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,52 @@ export type AuthKind = K8sResourceCommon & {
13131313
};
13141314
};
13151315

1316+
export type FeatureStoreKind = K8sResourceCommon & {
1317+
metadata: {
1318+
name: string;
1319+
namespace: string;
1320+
annotations?: Record<string, string>;
1321+
};
1322+
spec: {
1323+
feastProject: string;
1324+
services: Record<string, any>;
1325+
authz?: {
1326+
kubernetes?: {
1327+
roles?: string[];
1328+
};
1329+
oidc?: {
1330+
secretRef: {
1331+
name: string;
1332+
};
1333+
};
1334+
};
1335+
cronJob?: Record<string, never>;
1336+
volumes?: Record<string, never>[];
1337+
};
1338+
status?: {
1339+
applied?: {
1340+
cronJob?: {
1341+
concurrencyPolicy: string;
1342+
containerConfigs: {
1343+
commands: string[];
1344+
image: string;
1345+
};
1346+
schedule: string;
1347+
startingDeadlineSeconds: number;
1348+
suspend: boolean;
1349+
};
1350+
feastProject: string;
1351+
services?: Record<string, any>;
1352+
};
1353+
clientConfigMap?: string;
1354+
conditions?: K8sCondition[];
1355+
cronJob?: string;
1356+
feastVersion?: string;
1357+
phase?: string;
1358+
serviceHostnames?: Record<string, string>;
1359+
};
1360+
};
1361+
13161362
export enum OdhPlatformType {
13171363
OPEN_DATA_HUB = 'Open Data Hub',
13181364
SELF_MANAGED_RHOAI = 'OpenShift AI Self-Managed',

frontend/src/__mocks__/mockDataSources.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable camelcase */
22

3-
import { DataSource, DataSources } from '#~/concepts/featureStore/types.ts';
3+
import { DataSource, DataSources } from '#~/pages/featureStore/types';
44

55
/* eslint-disable @typescript-eslint/naming-convention */
66
export const mockDataSource_REQUEST_SOURCE = (partial?: Partial<DataSource>): DataSource => ({

frontend/src/__mocks__/mockEntities.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Entities, Entity } from '#~/concepts/featureStore/types.ts';
1+
/* eslint-disable camelcase */
2+
import { Entity, EntityList } from '#~/pages/featureStore/types.ts';
23

34
export const mockEntity = (partial?: Partial<Entity>): Entity => ({
45
spec: {
@@ -24,6 +25,14 @@ export const mockEntity = (partial?: Partial<Entity>): Entity => ({
2425
...partial,
2526
});
2627

27-
export const mockEntities = ({ entities = [mockEntity({})] }: Partial<Entities>): Entities => ({
28+
export const mockEntities = ({ entities = [mockEntity({})] }: Partial<EntityList>): EntityList => ({
2829
entities,
30+
pagination: {
31+
page: 1,
32+
limit: 10,
33+
total_count: 1,
34+
total_pages: 1,
35+
has_next: false,
36+
has_previous: false,
37+
},
2938
});

frontend/src/__mocks__/mockFeatureServices.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable camelcase */
2-
import { FeatureService, FeatureServices } from '#~/concepts/featureStore/types.ts';
2+
import { FeatureService, FeatureServices } from '#~/pages/featureStore/types.ts';
33

44
export const mockFeatureService = (partial?: Partial<FeatureService>): FeatureService => ({
55
spec: {

frontend/src/__mocks__/mockFeatureStoreProject.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FeatureStoreProject } from '#~/concepts/featureStore/types.ts';
1+
import { FeatureStoreProject } from '#~/pages/featureStore/types';
22

33
export const mockFeatureStoreProject = (
44
partial?: Partial<FeatureStoreProject>,

frontend/src/__mocks__/mockFeatureViews.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable camelcase */
2-
import { FeatureView } from '#~/concepts/featureStore/types.ts';
2+
import { FeatureView } from '#~/pages/featureStore/types';
33

44
export const mockFeatureView = (partial?: Partial<FeatureView>): FeatureView => ({
55
featureView: {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { proxyGET } from '#~/api/proxyUtils';
2+
import { handleFeatureStoreFailures } from '#~/api/featureStore/errorUtils';
3+
import { listFeatureStoreProject, getEntities } from '#~/api/featureStore/custom';
4+
import { FEATURE_STORE_API_VERSION } from '#~/pages/featureStore/const';
5+
6+
const mockProxyPromise = Promise.resolve();
7+
8+
jest.mock('#~/api/proxyUtils', () => ({
9+
proxyGET: jest.fn(() => mockProxyPromise),
10+
}));
11+
12+
jest.mock('#~/api/featureStore/errorUtils', () => ({
13+
handleFeatureStoreFailures: jest.fn((promise) => promise),
14+
}));
15+
16+
const proxyGETMock = jest.mocked(proxyGET);
17+
const handleFeatureStoreFailuresMock = jest.mocked(handleFeatureStoreFailures);
18+
19+
describe('listFeatureStoreProject', () => {
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
});
23+
24+
it('should call proxyGET and handleFeatureStoreFailures to fetch projects', () => {
25+
const hostPath = 'test-host';
26+
const opts = { dryRun: true };
27+
28+
const result = listFeatureStoreProject(hostPath)(opts);
29+
30+
expect(proxyGETMock).toHaveBeenCalledTimes(1);
31+
expect(proxyGETMock).toHaveBeenCalledWith(
32+
hostPath,
33+
`/api/${FEATURE_STORE_API_VERSION}/projects`,
34+
opts,
35+
);
36+
expect(handleFeatureStoreFailuresMock).toHaveBeenCalledTimes(1);
37+
expect(handleFeatureStoreFailuresMock).toHaveBeenCalledWith(mockProxyPromise);
38+
expect(result).toBe(mockProxyPromise);
39+
});
40+
41+
it('should work with empty options', () => {
42+
const hostPath = 'test-host';
43+
const opts = {};
44+
45+
listFeatureStoreProject(hostPath)(opts);
46+
47+
expect(proxyGETMock).toHaveBeenCalledWith(
48+
hostPath,
49+
`/api/${FEATURE_STORE_API_VERSION}/projects`,
50+
opts,
51+
);
52+
});
53+
});
54+
55+
describe('getEntities', () => {
56+
beforeEach(() => {
57+
jest.clearAllMocks();
58+
});
59+
60+
it('should call proxyGET with all entities endpoint when no project is provided', () => {
61+
const hostPath = 'test-host';
62+
const opts = { dryRun: true };
63+
64+
const result = getEntities(hostPath)(opts);
65+
66+
expect(proxyGETMock).toHaveBeenCalledTimes(1);
67+
expect(proxyGETMock).toHaveBeenCalledWith(
68+
hostPath,
69+
`/api/${FEATURE_STORE_API_VERSION}/entities/all`,
70+
opts,
71+
);
72+
expect(handleFeatureStoreFailuresMock).toHaveBeenCalledTimes(1);
73+
expect(handleFeatureStoreFailuresMock).toHaveBeenCalledWith(mockProxyPromise);
74+
expect(result).toBe(mockProxyPromise);
75+
});
76+
77+
it('should call proxyGET with project-specific endpoint when project is provided', () => {
78+
const hostPath = 'test-host';
79+
const opts = { dryRun: true };
80+
const project = 'test-project';
81+
82+
const result = getEntities(hostPath)(opts, project);
83+
84+
expect(proxyGETMock).toHaveBeenCalledTimes(1);
85+
expect(proxyGETMock).toHaveBeenCalledWith(
86+
hostPath,
87+
`/api/${FEATURE_STORE_API_VERSION}/entities?project=${encodeURIComponent(project)}`,
88+
opts,
89+
);
90+
expect(handleFeatureStoreFailuresMock).toHaveBeenCalledTimes(1);
91+
expect(handleFeatureStoreFailuresMock).toHaveBeenCalledWith(mockProxyPromise);
92+
expect(result).toBe(mockProxyPromise);
93+
});
94+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/* eslint-disable camelcase */
2+
import { handleFeatureStoreFailures } from '#~/api/featureStore/errorUtils';
3+
import { FeatureStoreError } from '#~/pages/featureStore/types';
4+
import { NotReadyError } from '#~/utilities/useFetchState';
5+
6+
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
7+
8+
describe('handleFeatureStoreFailures', () => {
9+
beforeEach(() => {
10+
jest.clearAllMocks();
11+
});
12+
13+
afterAll(() => {
14+
mockConsoleError.mockRestore();
15+
});
16+
17+
it('should successfully return data when promise resolves with valid data', async () => {
18+
const mockData = {
19+
projects: [
20+
{
21+
spec: { name: 'test-project' },
22+
meta: {
23+
createdTimestamp: '2023-01-01T00:00:00Z',
24+
lastUpdatedTimestamp: '2023-01-01T00:00:00Z',
25+
},
26+
},
27+
],
28+
pagination: {
29+
page: 1,
30+
limit: 10,
31+
total_count: 1,
32+
total_pages: 1,
33+
has_next: false,
34+
has_previous: false,
35+
},
36+
};
37+
38+
const result = await handleFeatureStoreFailures(Promise.resolve(mockData));
39+
expect(result).toStrictEqual(mockData);
40+
});
41+
42+
it('should throw FeatureStoreError when promise resolves with error object', async () => {
43+
const featureStoreError: FeatureStoreError = {
44+
code: 'FEATURE_STORE_001',
45+
message: 'Feature store not found',
46+
};
47+
48+
await expect(handleFeatureStoreFailures(Promise.resolve(featureStoreError))).rejects.toThrow(
49+
'Feature store not found',
50+
);
51+
});
52+
53+
it('should throw Error with message when promise rejects with FeatureStoreError', async () => {
54+
const featureStoreError: FeatureStoreError = {
55+
code: 'FEATURE_STORE_002',
56+
message: 'Invalid project configuration',
57+
};
58+
59+
await expect(handleFeatureStoreFailures(Promise.reject(featureStoreError))).rejects.toThrow(
60+
new Error('Invalid project configuration'),
61+
);
62+
});
63+
64+
it('should re-throw NotReadyError when promise rejects with NotReadyError', async () => {
65+
const notReadyError = new NotReadyError('API not ready');
66+
67+
await expect(handleFeatureStoreFailures(Promise.reject(notReadyError))).rejects.toThrow(
68+
notReadyError,
69+
);
70+
});
71+
72+
it('should handle other common state errors and re-throw them', async () => {
73+
const abortError = new Error('The operation was aborted');
74+
abortError.name = 'AbortError';
75+
76+
await expect(handleFeatureStoreFailures(Promise.reject(abortError))).rejects.toThrow(
77+
abortError,
78+
);
79+
});
80+
81+
it('should log and throw generic error for unknown errors', async () => {
82+
const unknownError = new Error('Unknown error');
83+
84+
await expect(handleFeatureStoreFailures(Promise.reject(unknownError))).rejects.toThrow(
85+
'Error communicating with feature store server',
86+
);
87+
88+
expect(mockConsoleError).toHaveBeenCalledTimes(1);
89+
expect(mockConsoleError).toHaveBeenCalledWith('Unknown feature store API error', unknownError);
90+
});
91+
92+
it('should handle non-Error objects and throw generic error', async () => {
93+
const invalidError = 'string error';
94+
95+
await expect(handleFeatureStoreFailures(Promise.reject(invalidError))).rejects.toThrow(
96+
'Error communicating with feature store server',
97+
);
98+
99+
expect(mockConsoleError).toHaveBeenCalledTimes(1);
100+
expect(mockConsoleError).toHaveBeenCalledWith('Unknown feature store API error', invalidError);
101+
});
102+
103+
it('should handle null/undefined errors', async () => {
104+
// null causes an error when accessing .name property in isCommonStateError
105+
await expect(handleFeatureStoreFailures(Promise.reject(null))).rejects.toThrow(
106+
"Cannot read properties of null (reading 'name')",
107+
);
108+
109+
// undefined also causes an error when accessing .name property
110+
await expect(handleFeatureStoreFailures(Promise.reject(undefined))).rejects.toThrow(
111+
"Cannot read properties of undefined (reading 'name')",
112+
);
113+
114+
// These errors occur before console.error is called, so no console calls expected
115+
expect(mockConsoleError).toHaveBeenCalledTimes(0);
116+
});
117+
});

0 commit comments

Comments
 (0)