diff --git a/clients/ui/frontend/src/__mocks__/mockRegisteredModel.ts b/clients/ui/frontend/src/__mocks__/mockRegisteredModel.ts new file mode 100644 index 000000000..7c45fbe57 --- /dev/null +++ b/clients/ui/frontend/src/__mocks__/mockRegisteredModel.ts @@ -0,0 +1,30 @@ +import { ModelState, RegisteredModel } from '~/app/types'; +import { createModelRegistryLabelsObject } from './utils'; + +type MockRegisteredModelType = { + id?: string; + name?: string; + owner?: string; + state?: ModelState; + description?: string; + labels?: string[]; +}; + +export const mockRegisteredModel = ({ + name = 'test', + owner = 'Author 1', + state = ModelState.LIVE, + description = '', + labels = [], + id = '1', +}: MockRegisteredModelType): RegisteredModel => ({ + createTimeSinceEpoch: '1710404288975', + description, + externalID: '1234132asdfasdf', + id, + lastUpdateTimeSinceEpoch: '1710404288975', + name, + state, + owner, + customProperties: createModelRegistryLabelsObject(labels), +}); diff --git a/clients/ui/frontend/src/__mocks__/utils.ts b/clients/ui/frontend/src/__mocks__/utils.ts new file mode 100644 index 000000000..054067883 --- /dev/null +++ b/clients/ui/frontend/src/__mocks__/utils.ts @@ -0,0 +1,13 @@ +import { ModelRegistryMetadataType, ModelRegistryStringCustomProperties } from '~/app/types'; + +export const createModelRegistryLabelsObject = ( + labels: string[], +): ModelRegistryStringCustomProperties => + labels.reduce((acc, label) => { + acc[label] = { + metadataType: ModelRegistryMetadataType.STRING, + // eslint-disable-next-line camelcase + string_value: '', + }; + return acc; + }, {} as ModelRegistryStringCustomProperties); diff --git a/clients/ui/frontend/src/app/api/__tests__/errorUtils.spec.ts b/clients/ui/frontend/src/app/api/__tests__/errorUtils.spec.ts new file mode 100644 index 000000000..3c225152a --- /dev/null +++ b/clients/ui/frontend/src/app/api/__tests__/errorUtils.spec.ts @@ -0,0 +1,33 @@ +import { NotReadyError } from '~/utilities/useFetchState'; +import { APIError } from '~/types'; +import { handleRestFailures } from '~/app/api/errorUtils'; +import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; + +describe('handleRestFailures', () => { + it('should successfully return registered models', async () => { + const modelRegistryMock = mockRegisteredModel({}); + const result = await handleRestFailures(Promise.resolve(modelRegistryMock)); + expect(result).toStrictEqual(modelRegistryMock); + }); + + it('should handle and throw model registry errors', async () => { + const statusMock: APIError = { + code: '', + message: 'error', + }; + + await expect(handleRestFailures(Promise.resolve(statusMock))).rejects.toThrow('error'); + }); + + it('should handle common state errors ', async () => { + await expect(handleRestFailures(Promise.reject(new NotReadyError('error')))).rejects.toThrow( + 'error', + ); + }); + + it('should handle other errors', async () => { + await expect(handleRestFailures(Promise.reject(new Error('error')))).rejects.toThrow( + 'Error communicating with server', + ); + }); +}); diff --git a/clients/ui/frontend/src/app/api/__tests__/service.spec.ts b/clients/ui/frontend/src/app/api/__tests__/service.spec.ts new file mode 100644 index 000000000..1e2a36e23 --- /dev/null +++ b/clients/ui/frontend/src/app/api/__tests__/service.spec.ts @@ -0,0 +1,422 @@ +import { restCREATE, restGET, restPATCH } from '~/app/api/apiUtils'; +import { handleRestFailures } from '~/app/api/errorUtils'; +import { ModelState, ModelArtifactState } from '~/app/types'; +import { + createModelArtifact, + createModelVersion, + createRegisteredModel, + getRegisteredModel, + getModelVersion, + getModelArtifact, + getListModelVersions, + getListModelArtifacts, + getModelVersionsByRegisteredModel, + getListRegisteredModels, + patchModelArtifact, + patchModelVersion, + patchRegisteredModel, + getModelArtifactsByModelVersion, + createModelVersionForRegisteredModel, + createModelArtifactForModelVersion, +} from '~/app/api/service'; +import { BFF_API_VERSION } from '~/app/const'; + +const mockProxyPromise = Promise.resolve(); + +jest.mock('~/app/api/apiUtils', () => ({ + restCREATE: jest.fn(() => mockProxyPromise), + restGET: jest.fn(() => mockProxyPromise), + restPATCH: jest.fn(() => mockProxyPromise), +})); + +const mockResultPromise = Promise.resolve(); + +jest.mock('~/app/api/errorUtils', () => ({ + handleRestFailures: jest.fn(() => mockResultPromise), +})); + +const handleRestFailuresMock = jest.mocked(handleRestFailures); +const restCREATEMock = jest.mocked(restCREATE); +const restGETMock = jest.mocked(restGET); +const restPATCHMock = jest.mocked(restPATCH); + +const K8sAPIOptionsMock = {}; + +describe('createRegisteredModel', () => { + it('should call restCREATE and handleRestFailures to create registered model', () => { + expect( + createRegisteredModel('hostPath', 'model-registry-1')(K8sAPIOptionsMock, { + description: 'test', + externalID: '1', + name: 'test new registered model', + state: ModelState.LIVE, + customProperties: {}, + }), + ).toBe(mockResultPromise); + expect(restCREATEMock).toHaveBeenCalledTimes(1); + expect(restCREATEMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/registered_models`, + { + description: 'test', + externalID: '1', + name: 'test new registered model', + state: ModelState.LIVE, + customProperties: {}, + }, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('createModelVersion', () => { + it('should call restCREATE and handleRestFailures to create model version', () => { + expect( + createModelVersion('hostPath', 'model-registry-1')(K8sAPIOptionsMock, { + description: 'test', + externalID: '1', + author: 'test author', + registeredModelId: '1', + name: 'test new model version', + state: ModelState.LIVE, + customProperties: {}, + }), + ).toBe(mockResultPromise); + expect(restCREATEMock).toHaveBeenCalledTimes(1); + expect(restCREATEMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/model_versions`, + { + description: 'test', + externalID: '1', + author: 'test author', + registeredModelId: '1', + name: 'test new model version', + state: ModelState.LIVE, + customProperties: {}, + }, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('createModelVersionForRegisteredModel', () => { + it('should call restCREATE and handleRestFailures to create model version for a model', () => { + expect( + createModelVersionForRegisteredModel('hostPath', 'model-registry-1')(K8sAPIOptionsMock, '1', { + description: 'test', + externalID: '1', + author: 'test author', + registeredModelId: '1', + name: 'test new model version', + state: ModelState.LIVE, + customProperties: {}, + }), + ).toBe(mockResultPromise); + expect(restCREATEMock).toHaveBeenCalledTimes(1); + expect(restCREATEMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/registered_models/1/versions`, + { + description: 'test', + externalID: '1', + author: 'test author', + registeredModelId: '1', + name: 'test new model version', + state: ModelState.LIVE, + customProperties: {}, + }, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('createModelArtifact', () => { + it('should call restCREATE and handleRestFailures to create model artifact', () => { + expect( + createModelArtifact('hostPath', 'model-registry-1')(K8sAPIOptionsMock, { + description: 'test', + externalID: 'test', + uri: 'test-uri', + state: ModelArtifactState.LIVE, + name: 'test-name', + modelFormatName: 'test-modelformatname', + storageKey: 'teststoragekey', + storagePath: 'teststoragePath', + modelFormatVersion: 'testmodelFormatVersion', + serviceAccountName: 'testserviceAccountname', + customProperties: {}, + artifactType: 'model-artifact', + }), + ).toBe(mockResultPromise); + expect(restCREATEMock).toHaveBeenCalledTimes(1); + expect(restCREATEMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/model_artifacts`, + { + description: 'test', + externalID: 'test', + uri: 'test-uri', + state: ModelArtifactState.LIVE, + name: 'test-name', + modelFormatName: 'test-modelformatname', + storageKey: 'teststoragekey', + storagePath: 'teststoragePath', + modelFormatVersion: 'testmodelFormatVersion', + serviceAccountName: 'testserviceAccountname', + customProperties: {}, + artifactType: 'model-artifact', + }, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('createModelArtifactForModelVersion', () => { + it('should call restCREATE and handleRestFailures to create model artifact for version', () => { + expect( + createModelArtifactForModelVersion('hostPath', 'model-registry-1')(K8sAPIOptionsMock, '2', { + description: 'test', + externalID: 'test', + uri: 'test-uri', + state: ModelArtifactState.LIVE, + name: 'test-name', + modelFormatName: 'test-modelformatname', + storageKey: 'teststoragekey', + storagePath: 'teststoragePath', + modelFormatVersion: 'testmodelFormatVersion', + serviceAccountName: 'testserviceAccountname', + customProperties: {}, + artifactType: 'model-artifact', + }), + ).toBe(mockResultPromise); + expect(restCREATEMock).toHaveBeenCalledTimes(1); + expect(restCREATEMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/model_versions/2/artifacts`, + { + description: 'test', + externalID: 'test', + uri: 'test-uri', + state: ModelArtifactState.LIVE, + name: 'test-name', + modelFormatName: 'test-modelformatname', + storageKey: 'teststoragekey', + storagePath: 'teststoragePath', + modelFormatVersion: 'testmodelFormatVersion', + serviceAccountName: 'testserviceAccountname', + customProperties: {}, + artifactType: 'model-artifact', + }, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('getRegisteredModel', () => { + it('should call restGET and handleRestFailures to fetch registered model', () => { + expect(getRegisteredModel('hostPath', 'model-registry-1')(K8sAPIOptionsMock, '1')).toBe( + mockResultPromise, + ); + expect(restGETMock).toHaveBeenCalledTimes(1); + expect(restGETMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/registered_models/1`, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('getModelVersion', () => { + it('should call restGET and handleRestFailures to fetch model version', () => { + expect(getModelVersion('hostPath', 'model-registry-1')(K8sAPIOptionsMock, '1')).toBe( + mockResultPromise, + ); + expect(restGETMock).toHaveBeenCalledTimes(1); + expect(restGETMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/model_versions/1`, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('getModelArtifact', () => { + it('should call restGET and handleRestFailures to fetch model version', () => { + expect(getModelArtifact('hostPath', 'model-registry-1')(K8sAPIOptionsMock, '1')).toBe( + mockResultPromise, + ); + expect(restGETMock).toHaveBeenCalledTimes(1); + expect(restGETMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/model_artifacts/1`, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('getListRegisteredModels', () => { + it('should call restGET and handleRestFailures to list registered models', () => { + expect(getListRegisteredModels('hostPath', 'model-registry-1')({})).toBe(mockResultPromise); + expect(restGETMock).toHaveBeenCalledTimes(1); + expect(restGETMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/registered_models`, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('getListModelArtifacts', () => { + it('should call restGET and handleRestFailures to list models artifacts', () => { + expect(getListModelArtifacts('hostPath', 'model-registry-1')({})).toBe(mockResultPromise); + expect(restGETMock).toHaveBeenCalledTimes(1); + expect(restGETMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/model_artifacts`, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('getListModelVersions', () => { + it('should call restGET and handleRestFailures to list models versions', () => { + expect(getListModelVersions('hostPath', 'model-registry-1')({})).toBe(mockResultPromise); + expect(restGETMock).toHaveBeenCalledTimes(1); + expect(restGETMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/model_versions`, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('getModelVersionsByRegisteredModel', () => { + it('should call restGET and handleRestFailures to list models versions by registered model', () => { + expect(getModelVersionsByRegisteredModel('hostPath', 'model-registry-1')({}, '1')).toBe( + mockResultPromise, + ); + expect(restGETMock).toHaveBeenCalledTimes(1); + expect(restGETMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/registered_models/1/versions`, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('getModelArtifactsByModelVersion', () => { + it('should call restGET and handleRestFailures to list models artifacts by model version', () => { + expect(getModelArtifactsByModelVersion('hostPath', 'model-registry-1')({}, '1')).toBe( + mockResultPromise, + ); + expect(restGETMock).toHaveBeenCalledTimes(1); + expect(restGETMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/model_versions/1/artifacts`, + {}, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('patchRegisteredModel', () => { + it('should call restPATCH and handleRestFailures to update registered model', () => { + expect( + patchRegisteredModel('hostPath', 'model-registry-1')( + K8sAPIOptionsMock, + { description: 'new test' }, + '1', + ), + ).toBe(mockResultPromise); + expect(restPATCHMock).toHaveBeenCalledTimes(1); + expect(restPATCHMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/registered_models/1`, + { description: 'new test' }, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('patchModelVersion', () => { + it('should call restPATCH and handleRestFailures to update model version', () => { + expect( + patchModelVersion('hostPath', 'model-registry-1')( + K8sAPIOptionsMock, + { description: 'new test' }, + '1', + ), + ).toBe(mockResultPromise); + expect(restPATCHMock).toHaveBeenCalledTimes(1); + expect(restPATCHMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/model_versions/1`, + { description: 'new test' }, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); + +describe('patchModelArtifact', () => { + it('should call restPATCH and handleRestFailures to update model artifact', () => { + expect( + patchModelArtifact('hostPath', 'model-registry-1')( + K8sAPIOptionsMock, + { description: 'new test' }, + '1', + ), + ).toBe(mockResultPromise); + expect(restPATCHMock).toHaveBeenCalledTimes(1); + expect(restPATCHMock).toHaveBeenCalledWith( + 'hostPath', + `/api/${BFF_API_VERSION}/model_registry/model-registry-1/model_artifacts/1`, + { description: 'new test' }, + K8sAPIOptionsMock, + ); + expect(handleRestFailuresMock).toHaveBeenCalledTimes(1); + expect(handleRestFailuresMock).toHaveBeenCalledWith(mockProxyPromise); + }); +}); diff --git a/clients/ui/frontend/src/app/api/apiUtils.ts b/clients/ui/frontend/src/app/api/apiUtils.ts new file mode 100644 index 000000000..d4adff6c1 --- /dev/null +++ b/clients/ui/frontend/src/app/api/apiUtils.ts @@ -0,0 +1,163 @@ +import { APIOptions } from '~/types'; +import { EitherOrNone } from '~/typeHelpers'; + +export const mergeRequestInit = ( + opts: APIOptions = {}, + specificOpts: RequestInit = {}, +): RequestInit => ({ + ...specificOpts, + ...(opts.signal && { signal: opts.signal }), +}); + +type CallRestJSONOptions = { + queryParams?: Record; + parseJSON?: boolean; +} & EitherOrNone< + { + fileContents: string; + }, + { + data: Record; + } +>; + +const callRestJSON = ( + host: string, + path: string, + requestInit: RequestInit, + { data, fileContents, queryParams, parseJSON = true }: CallRestJSONOptions, +): Promise => { + const { method, ...otherOptions } = requestInit; + + const sanitizedQueryParams = queryParams + ? Object.entries(queryParams).reduce((acc, [key, value]) => { + if (value) { + return { ...acc, [key]: value }; + } + + return acc; + }, {}) + : null; + + const searchParams = sanitizedQueryParams + ? new URLSearchParams(sanitizedQueryParams).toString() + : null; + + let requestData: string | undefined; + let contentType: string | undefined; + let formData: FormData | undefined; + if (fileContents) { + formData = new FormData(); + formData.append( + 'uploadfile', + new Blob([fileContents], { type: 'application/x-yaml' }), + 'uploadedFile.yml', + ); + } else if (data) { + // It's OK for contentType and requestData to BOTH be undefined for e.g. a GET request or POST with no body. + contentType = 'application/json;charset=UTF-8'; + requestData = JSON.stringify(data); + } + + return fetch(`${host}${path}${searchParams ? `?${searchParams}` : ''}`, { + ...otherOptions, + ...(contentType && { headers: { 'Content-Type': contentType } }), + method, + body: formData ?? requestData, + }).then((response) => + response.text().then((fetchedData) => { + if (parseJSON) { + return JSON.parse(fetchedData); + } + return fetchedData; + }), + ); +}; + +export const restGET = ( + host: string, + path: string, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'GET' }), { + queryParams, + parseJSON: options?.parseJSON, + }); + +/** Standard POST */ +export const restCREATE = ( + host: string, + path: string, + data: Record, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'POST' }), { + data, + queryParams, + parseJSON: options?.parseJSON, + }); + +/** POST -- but with file content instead of body data */ +export const restFILE = ( + host: string, + path: string, + fileContents: string, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'POST' }), { + fileContents, + queryParams, + parseJSON: options?.parseJSON, + }); + +/** POST -- but no body data -- targets simple endpoints */ +export const restENDPOINT = ( + host: string, + path: string, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'POST' }), { + queryParams, + parseJSON: options?.parseJSON, + }); + +export const restUPDATE = ( + host: string, + path: string, + data: Record, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'PUT' }), { + data, + queryParams, + parseJSON: options?.parseJSON, + }); + +export const restPATCH = ( + host: string, + path: string, + data: Record, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'PATCH' }), { + data, + parseJSON: options?.parseJSON, + }); + +export const restDELETE = ( + host: string, + path: string, + data: Record, + queryParams: Record = {}, + options?: APIOptions, +): Promise => + callRestJSON(host, path, mergeRequestInit(options, { method: 'DELETE' }), { + data, + queryParams, + parseJSON: options?.parseJSON, + }); diff --git a/clients/ui/frontend/src/app/api/errorUtils.ts b/clients/ui/frontend/src/app/api/errorUtils.ts new file mode 100644 index 000000000..4cb92823b --- /dev/null +++ b/clients/ui/frontend/src/app/api/errorUtils.ts @@ -0,0 +1,27 @@ +import { APIError } from '~/types'; +import { isCommonStateError } from '~/utilities/useFetchState'; + +const isError = (e: unknown): e is APIError => + typeof e === 'object' && e !== null && ['code', 'message'].every((key) => key in e); + +export const handleRestFailures = (promise: Promise): Promise => + promise + .then((result) => { + if (isError(result)) { + throw result; + } + return result; + }) + .catch((e) => { + if (isError(e)) { + throw new Error(e.message); + } + if (isCommonStateError(e)) { + // Common state errors are handled by useFetchState at storage level, let them deal with it + // TODO: check whether we need this or not + throw e; + } + // eslint-disable-next-line no-console + console.error('Unknown API error', e); + throw new Error('Error communicating with server'); + }); diff --git a/clients/ui/frontend/src/app/api/k8s.ts b/clients/ui/frontend/src/app/api/k8s.ts new file mode 100644 index 000000000..e17e55dbe --- /dev/null +++ b/clients/ui/frontend/src/app/api/k8s.ts @@ -0,0 +1,10 @@ +import { APIOptions } from '~/types'; +import { handleRestFailures } from '~/app/api/errorUtils'; +import { restGET } from '~/app/api/apiUtils'; +import { ModelRegistry } from '~/app/types'; +import { BFF_API_VERSION } from '~/app/const'; + +export const getModelRegistries = + (hostPath: string) => + (opts: APIOptions): Promise => + handleRestFailures(restGET(hostPath, `/api/${BFF_API_VERSION}/model_registry`, {}, opts)); diff --git a/clients/ui/frontend/src/app/api/service.ts b/clients/ui/frontend/src/app/api/service.ts new file mode 100644 index 000000000..42f8dbb56 --- /dev/null +++ b/clients/ui/frontend/src/app/api/service.ts @@ -0,0 +1,227 @@ +import { + CreateModelArtifactData, + CreateModelVersionData, + CreateRegisteredModelData, + ModelArtifact, + ModelArtifactList, + ModelVersionList, + ModelVersion, + RegisteredModelList, + RegisteredModel, +} from '~/app/types'; +import { restCREATE, restGET, restPATCH } from '~/app/api/apiUtils'; +import { APIOptions } from '~/types'; +import { handleRestFailures } from '~/app/api/errorUtils'; +import { BFF_API_VERSION } from '~/app/const'; + +export const createRegisteredModel = + (hostPath: string, mrName: string) => + (opts: APIOptions, data: CreateRegisteredModelData): Promise => + handleRestFailures( + restCREATE( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/registered_models`, + data, + {}, + opts, + ), + ); + +export const createModelVersion = + (hostPath: string, mrName: string) => + (opts: APIOptions, data: CreateModelVersionData): Promise => + handleRestFailures( + restCREATE( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/model_versions`, + data, + {}, + opts, + ), + ); +export const createModelVersionForRegisteredModel = + (hostPath: string, mrName: string) => + ( + opts: APIOptions, + registeredModelId: string, + data: CreateModelVersionData, + ): Promise => + handleRestFailures( + restCREATE( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/registered_models/${registeredModelId}/versions`, + data, + {}, + opts, + ), + ); + +export const createModelArtifact = + (hostPath: string, mrName: string) => + (opts: APIOptions, data: CreateModelArtifactData): Promise => + handleRestFailures( + restCREATE( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/model_artifacts`, + data, + {}, + opts, + ), + ); + +export const createModelArtifactForModelVersion = + (hostPath: string, mrName: string) => + ( + opts: APIOptions, + modelVersionId: string, + data: CreateModelArtifactData, + ): Promise => + handleRestFailures( + restCREATE( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/model_versions/${modelVersionId}/artifacts`, + data, + {}, + opts, + ), + ); + +export const getRegisteredModel = + (hostPath: string, mrName: string) => + (opts: APIOptions, registeredModelId: string): Promise => + handleRestFailures( + restGET( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/registered_models/${registeredModelId}`, + {}, + opts, + ), + ); + +export const getModelVersion = + (hostPath: string, mrName: string) => + (opts: APIOptions, modelversionId: string): Promise => + handleRestFailures( + restGET( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/model_versions/${modelversionId}`, + {}, + opts, + ), + ); + +export const getModelArtifact = + (hostPath: string, mrName: string) => + (opts: APIOptions, modelArtifactId: string): Promise => + handleRestFailures( + restGET( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/model_artifacts/${modelArtifactId}`, + {}, + opts, + ), + ); + +export const getListModelArtifacts = + (hostPath: string, mrName: string) => + (opts: APIOptions): Promise => + handleRestFailures( + restGET( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/model_artifacts`, + {}, + opts, + ), + ); + +export const getListModelVersions = + (hostPath: string, mrName: string) => + (opts: APIOptions): Promise => + handleRestFailures( + restGET( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/model_versions`, + {}, + opts, + ), + ); + +export const getListRegisteredModels = + (hostPath: string, mrName: string) => + (opts: APIOptions): Promise => + handleRestFailures( + restGET( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/registered_models`, + {}, + opts, + ), + ); + +export const getModelVersionsByRegisteredModel = + (hostPath: string, mrName: string) => + (opts: APIOptions, registeredmodelId: string): Promise => + handleRestFailures( + restGET( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/registered_models/${registeredmodelId}/versions`, + {}, + opts, + ), + ); + +export const getModelArtifactsByModelVersion = + (hostPath: string, mrName: string) => + (opts: APIOptions, modelVersionId: string): Promise => + handleRestFailures( + restGET( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/model_versions/${modelVersionId}/artifacts`, + {}, + opts, + ), + ); + +export const patchRegisteredModel = + (hostPath: string, mrName: string) => + ( + opts: APIOptions, + data: Partial, + registeredModelId: string, + ): Promise => + handleRestFailures( + restPATCH( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/registered_models/${registeredModelId}`, + data, + opts, + ), + ); + +export const patchModelVersion = + (hostPath: string, mrName: string) => + (opts: APIOptions, data: Partial, modelversionId: string): Promise => + handleRestFailures( + restPATCH( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/model_versions/${modelversionId}`, + data, + opts, + ), + ); + +export const patchModelArtifact = + (hostPath: string, mrName: string) => + ( + opts: APIOptions, + data: Partial, + modelartifactId: string, + ): Promise => + handleRestFailures( + restPATCH( + hostPath, + `/api/${BFF_API_VERSION}/model_registry/${mrName}/model_artifacts/${modelartifactId}`, + data, + opts, + ), + ); diff --git a/clients/ui/frontend/src/app/const.ts b/clients/ui/frontend/src/app/const.ts new file mode 100644 index 000000000..35826e7d7 --- /dev/null +++ b/clients/ui/frontend/src/app/const.ts @@ -0,0 +1 @@ +export const BFF_API_VERSION = 'v1'; diff --git a/clients/ui/frontend/src/app/types.ts b/clients/ui/frontend/src/app/types.ts new file mode 100644 index 000000000..17fcc5890 --- /dev/null +++ b/clients/ui/frontend/src/app/types.ts @@ -0,0 +1,194 @@ +import { APIOptions } from '~/types'; + +export enum ModelState { + LIVE = 'LIVE', + ARCHIVED = 'ARCHIVED', +} + +export enum ModelArtifactState { + UNKNOWN = 'UNKNOWN', + PENDING = 'PENDING', + LIVE = 'LIVE', + MARKED_FOR_DELETION = 'MARKED_FOR_DELETION', + DELETED = 'DELETED', + ABANDONED = 'ABANDONED', + REFERENCE = 'REFERENCE', +} + +export type ModelRegistry = { + name: string; + displayName: string; + description: string; +}; + +export enum ModelRegistryMetadataType { + INT = 'MetadataIntValue', + DOUBLE = 'MetadataDoubleValue', + STRING = 'MetadataStringValue', + STRUCT = 'MetadataStructValue', + PROTO = 'MetadataProtoValue', + BOOL = 'MetadataBoolValue', +} + +export type ModelRegistryCustomPropertyInt = { + metadataType: ModelRegistryMetadataType.INT; + int_value: string; // int64-formatted string +}; + +export type ModelRegistryCustomPropertyDouble = { + metadataType: ModelRegistryMetadataType.DOUBLE; + double_value: number; +}; + +export type ModelRegistryCustomPropertyString = { + metadataType: ModelRegistryMetadataType.STRING; + string_value: string; +}; + +export type ModelRegistryCustomPropertyStruct = { + metadataType: ModelRegistryMetadataType.STRUCT; + struct_value: string; // Base64 encoded bytes for struct value +}; + +export type ModelRegistryCustomPropertyProto = { + metadataType: ModelRegistryMetadataType.PROTO; + type: string; // url describing proto value + proto_value: string; // Base64 encoded bytes for proto value +}; + +export type ModelRegistryCustomPropertyBool = { + metadataType: ModelRegistryMetadataType.BOOL; + bool_value: boolean; +}; + +export type ModelRegistryCustomProperty = + | ModelRegistryCustomPropertyInt + | ModelRegistryCustomPropertyDouble + | ModelRegistryCustomPropertyString + | ModelRegistryCustomPropertyStruct + | ModelRegistryCustomPropertyProto + | ModelRegistryCustomPropertyBool; + +export type ModelRegistryCustomProperties = Record; +export type ModelRegistryStringCustomProperties = Record; + +export type ModelRegistryBase = { + id: string; + name: string; + externalID?: string; + description?: string; + createTimeSinceEpoch: string; + lastUpdateTimeSinceEpoch: string; + customProperties: ModelRegistryCustomProperties; +}; + +export type ModelArtifact = ModelRegistryBase & { + uri?: string; + state?: ModelArtifactState; + author?: string; + modelFormatName?: string; + storageKey?: string; + storagePath?: string; + modelFormatVersion?: string; + serviceAccountName?: string; + artifactType: string; +}; + +export type ModelVersion = ModelRegistryBase & { + state?: ModelState; + author?: string; + registeredModelId: string; +}; + +export type RegisteredModel = ModelRegistryBase & { + state?: ModelState; + owner?: string; +}; + +export type CreateRegisteredModelData = Omit< + RegisteredModel, + 'lastUpdateTimeSinceEpoch' | 'createTimeSinceEpoch' | 'id' +>; + +export type CreateModelVersionData = Omit< + ModelVersion, + 'lastUpdateTimeSinceEpoch' | 'createTimeSinceEpoch' | 'id' +>; + +export type CreateModelArtifactData = Omit< + ModelArtifact, + 'lastUpdateTimeSinceEpoch' | 'createTimeSinceEpoch' | 'id' +>; + +export type ModelRegistryListParams = { + size: number; + pageSize: number; + nextPageToken: string; +}; + +export type RegisteredModelList = ModelRegistryListParams & { items: RegisteredModel[] }; + +export type ModelVersionList = ModelRegistryListParams & { items: ModelVersion[] }; + +export type ModelArtifactList = ModelRegistryListParams & { items: ModelArtifact[] }; + +export type CreateRegisteredModel = ( + opts: APIOptions, + data: CreateRegisteredModelData, +) => Promise; + +export type CreateModelVersionForRegisteredModel = ( + opts: APIOptions, + registeredModelId: string, + data: CreateModelVersionData, +) => Promise; + +export type CreateModelArtifactForModelVersion = ( + opts: APIOptions, + modelVersionId: string, + data: CreateModelArtifactData, +) => Promise; + +export type GetRegisteredModel = ( + opts: APIOptions, + registeredModelId: string, +) => Promise; + +export type GetModelVersion = (opts: APIOptions, modelversionId: string) => Promise; + +export type GetListRegisteredModels = (opts: APIOptions) => Promise; + +export type GetModelVersionsByRegisteredModel = ( + opts: APIOptions, + registeredmodelId: string, +) => Promise; + +export type GetModelArtifactsByModelVersion = ( + opts: APIOptions, + modelVersionId: string, +) => Promise; + +export type PatchRegisteredModel = ( + opts: APIOptions, + data: Partial, + registeredModelId: string, +) => Promise; + +export type PatchModelVersion = ( + opts: APIOptions, + data: Partial, + modelversionId: string, +) => Promise; + +export type ModelRegistryAPIs = { + createRegisteredModel: CreateRegisteredModel; + createModelVersionForRegisteredModel: CreateModelVersionForRegisteredModel; + createModelArtifactForModelVersion: CreateModelArtifactForModelVersion; + getRegisteredModel: GetRegisteredModel; + getModelVersion: GetModelVersion; + listRegisteredModels: GetListRegisteredModels; + getModelVersionsByRegisteredModel: GetModelVersionsByRegisteredModel; + getModelArtifactsByModelVersion: GetModelArtifactsByModelVersion; + patchRegisteredModel: PatchRegisteredModel; + patchModelVersion: PatchModelVersion; +}; diff --git a/clients/ui/frontend/src/typeHelpers.ts b/clients/ui/frontend/src/typeHelpers.ts new file mode 100644 index 000000000..a590b848b --- /dev/null +++ b/clients/ui/frontend/src/typeHelpers.ts @@ -0,0 +1,160 @@ +/** + * The type `{}` doesn't mean "any empty object", it means "any non-nullish value". + * + * Use the `AnyObject` type for objects whose structure is unknown. + * + * @see https://github.com/typescript-eslint/typescript-eslint/issues/2063#issuecomment-675156492 + */ +export type AnyObject = Record; + +/** + * Takes a type and makes all properties partial within it. + * + * TODO: Implement the SDK & Patch logic -- this should stop being needed as things will be defined as Patches + */ +export type RecursivePartial = T extends object + ? { + [P in keyof T]?: RecursivePartial; + } + : T; + +/** + * Partial only some properties. + * + * eg. PartialSome + */ +export type PartialSome = Pick, Keys> & + Omit; + +/** + * Unions all values of an object togethers -- antithesis to `keyof myObj`. + */ +export type ValueOf = T[keyof T]; + +/** + * Never allow any properties of `Type`. + * + * Utility type, probably never a reason to export. + */ +type Never = { + [K in keyof Type]?: never; +}; + +/** + * Either TypeA properties or TypeB properties -- never both. + * + * @example + * ```ts + * type MyType = EitherNotBoth<{ foo: boolean }, { bar: boolean }>; + * + * // Valid usages: + * const objA: MyType = { + * foo: true, + * }; + * const objB: MyType = { + * bar: true, + * }; + * + * // TS Error -- can't have both properties: + * const objBoth: MyType = { + * foo: true, + * bar: true, + * }; + * + * // TS Error -- must have at least one property: + * const objNeither: MyType = { + * }; + * ``` + */ +export type EitherNotBoth = (TypeA & Never) | (TypeB & Never); + +/** + * Either TypeA properties or TypeB properties or neither of the properties -- never both. + * + * @example + * ```ts + * type MyType = EitherOrBoth<{ foo: boolean }, { bar: boolean }>; + * + * // Valid usages: + * const objA: MyType = { + * foo: true, + * }; + * const objB: MyType = { + * bar: true, + * }; + * const objBoth: MyType = { + * foo: true, + * bar: true, + * }; + * + * // TS Error -- can't omit both properties: + * const objNeither: MyType = { + * }; + * ``` + */ +export type EitherOrBoth = EitherNotBoth | (TypeA & TypeB); + +/** + * Either TypeA properties or TypeB properties or neither of the properties -- never both. + * + * @example + * ```ts + * type MyType = EitherOrNone<{ foo: boolean }, { bar: boolean }>; + * + * // Valid usages: + * const objA: MyType = { + * foo: true, + * }; + * const objB: MyType = { + * bar: true, + * }; + * const objNeither: MyType = { + * }; + * + * // TS Error -- can't have both properties: + * const objBoth: MyType = { + * foo: true, + * bar: true, + * }; + * ``` + */ +export type EitherOrNone = + | EitherNotBoth + | (Never & Never); + +// support types for `ExactlyOne` +type Explode = keyof T extends infer K + ? K extends unknown + ? { [I in keyof T]: I extends K ? T[I] : never } + : never + : never; +type AtMostOne = Explode>; +type AtLeastOne }> = Partial & U[keyof U]; + +/** + * Create a type where exactly one of multiple properties must be supplied. + * + * @example + * ```ts + * type Foo = ExactlyOne<{ a: number, b: string, c: boolean}>; + * + * // Valid usages: + * const objA: Foo = { + * a: 1, + * }; + * const objB: Foo = { + * b: 'hi', + * }; + * const objC: Foo = { + * c: true, + * }; + * + * // TS Error -- can't have more than one property: + * const objAll: Foo = { + * a: 1, + * b: 'hi', + * c: true, + * }; + * ``` + */ +export type ExactlyOne = AtMostOne & AtLeastOne; diff --git a/clients/ui/frontend/src/types.ts b/clients/ui/frontend/src/types.ts index 34f4c36fc..0be5cb1a9 100644 --- a/clients/ui/frontend/src/types.ts +++ b/clients/ui/frontend/src/types.ts @@ -19,3 +19,14 @@ export type CommonConfig = { export type FeatureFlag = { modelRegistry: boolean; }; + +export type APIOptions = { + dryRun?: boolean; + signal?: AbortSignal; + parseJSON?: boolean; +}; + +export type APIError = { + code: string; + message: string; +}; diff --git a/clients/ui/frontend/src/utilities/useFetchState.ts b/clients/ui/frontend/src/utilities/useFetchState.ts new file mode 100644 index 000000000..64b2e3eb3 --- /dev/null +++ b/clients/ui/frontend/src/utilities/useFetchState.ts @@ -0,0 +1,258 @@ +import * as React from 'react'; +import { APIOptions } from '~/types'; + +/** + * Allows "I'm not ready" rejections if you lack a lazy provided prop + * e.g. Promise.reject(new NotReadyError('Do not have namespace')) + */ +export class NotReadyError extends Error { + constructor(reason: string) { + super(`Not ready yet. ${reason}`); + this.name = 'NotReadyError'; + } +} + +/** + * Checks to see if it's a standard error handled by useStateFetch .catch block. + */ +export const isCommonStateError = (e: Error): boolean => { + if (e.name === 'NotReadyError') { + // An escape hatch for callers to reject the call at this fetchCallbackPromise reference + // Re-compute your callback to re-trigger again + return true; + } + if (e.name === 'AbortError') { + // Abort errors are silent + return true; + } + + return false; +}; + +/** Provided as a promise, so you can await a refresh before enabling buttons / closing modals. + * Returns the successful value or nothing if the call was cancelled / didn't complete. */ +export type FetchStateRefreshPromise = () => Promise; + +/** Return state */ +export type FetchState = [ + data: Type, + loaded: boolean, + loadError: Error | undefined, + /** This promise should never throw to the .catch */ + refresh: FetchStateRefreshPromise, +]; + +type SetStateLazy = (lastState: Type) => Type; +export type AdHocUpdate = (updateLater: (updater: SetStateLazy) => void) => void; + +const isAdHocUpdate = (r: Type | AdHocUpdate): r is AdHocUpdate => + typeof r === 'function'; + +/** + * All callbacks will receive a APIOptions, which includes a signal to provide to a RequestInit. + * This will allow the call to be cancelled if the hook needs to unload. It is recommended that you + * upgrade your API handlers to support this. + */ +type FetchStateCallbackPromiseReturn = (opts: APIOptions) => Return; + +/** + * Standard usage. Your callback should return a Promise that resolves to your data. + */ +export type FetchStateCallbackPromise = FetchStateCallbackPromiseReturn>; + +/** + * Advanced usage. If you have a need to include a lazy refresh state to your data, you can use this + * functionality. It works on the lazy React.setState functionality. + * + * Note: When using, you're giving up immediate setState, so you'll want to call the setStateLater + * function immediately to get back that functionality. + * + * Example: + * ``` + * React.useCallback(() => + * new Promise(() => { + * MyAPICall().then((...) => + * resolve((setStateLater) => { // << providing a function instead of the value + * setStateLater({ ...someInitialData }) + * // ...some time later, after more calls / in a callback / etc + * setStateLater((lastState) => ({ ...lastState, data: additionalData })) + * }) + * ) + * }) + * ); + * ``` + */ +export type FetchStateCallbackPromiseAdHoc = FetchStateCallbackPromiseReturn< + Promise> +>; + +export type FetchOptions = { + /** To enable auto refresh */ + refreshRate: number; + /** + * Makes your promise pure from the sense of if it changes you do not want the previous data. When + * you recompute your fetchCallbackPromise, do you want to drop the values stored? This will + * reset everything; result, loaded, & error state. Intended purpose is if your promise is keyed + * off of a value that if it changes you should drop all data as it's fundamentally a different + * thing - sharing old state is misleading. + * + * Note: Doing this could have undesired side effects. Consider your hook's dependents and the + * state of your data. + * Note: This is only read as initial value; changes do nothing. + */ + initialPromisePurity: boolean; +}; + +/** + * A boilerplate helper utility. Given a callback that returns a promise, it will store state and + * handle refreshes on intervals as needed. + * + * Note: Your callback *should* support the opts property so the call can be cancelled. + */ +const useFetchState = ( + /** React.useCallback result. */ + fetchCallbackPromise: FetchStateCallbackPromise>, + /** + * A preferred default states - this is ignored after the first render + * Note: This is only read as initial value; changes do nothing. + */ + initialDefaultState: Type, + /** Configurable features */ + { refreshRate = 0, initialPromisePurity = false }: Partial = {}, +): FetchState => { + const initialDefaultStateRef = React.useRef(initialDefaultState); + const [result, setResult] = React.useState(initialDefaultState); + const [loaded, setLoaded] = React.useState(false); + const [loadError, setLoadError] = React.useState(undefined); + const abortCallbackRef = React.useRef<() => void>(() => undefined); + const changePendingRef = React.useRef(true); + + /** Setup on initial hook a singular reset function. DefaultState & resetDataOnNewPromise are initial render states. */ + const cleanupRef = React.useRef(() => { + if (initialPromisePurity) { + setResult(initialDefaultState); + setLoaded(false); + setLoadError(undefined); + } + }); + + React.useEffect(() => { + cleanupRef.current(); + }, [fetchCallbackPromise]); + + const call = React.useCallback<() => [Promise, () => void]>(() => { + let alreadyAborted = false; + const abortController = new AbortController(); + + /** Note: this promise cannot "catch" beyond this instance -- unless a runtime error. */ + const doRequest = () => + fetchCallbackPromise({ signal: abortController.signal }) + .then((r) => { + changePendingRef.current = false; + if (alreadyAborted) { + return undefined; + } + + if (r === undefined) { + // Undefined is an unacceptable response. If you want "nothing", pass `null` -- this is likely an API issue though. + // eslint-disable-next-line no-console + console.error( + 'useFetchState Error: Got undefined back from a promise. This is likely an error with your call. Preventing setting.', + ); + return undefined; + } + + setLoadError(undefined); + if (isAdHocUpdate(r)) { + r((setState: SetStateLazy) => { + if (alreadyAborted) { + return undefined; + } + + setResult(setState); + setLoaded(true); + return undefined; + }); + return undefined; + } + + setResult(r); + setLoaded(true); + + return r; + }) + .catch((e) => { + changePendingRef.current = false; + if (alreadyAborted) { + return undefined; + } + + if (isCommonStateError(e)) { + return undefined; + } + setLoadError(e); + return undefined; + }); + + const unload = () => { + changePendingRef.current = false; + if (alreadyAborted) { + return; + } + + alreadyAborted = true; + abortController.abort(); + }; + + return [doRequest(), unload]; + }, [fetchCallbackPromise]); + + // Use a memmo to update the `changePendingRef` immediately on change. + React.useMemo(() => { + changePendingRef.current = true; + // React to changes to the `call` reference. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [call]); + + React.useEffect(() => { + let interval: ReturnType; + + const callAndSave = () => { + const [, unload] = call(); + abortCallbackRef.current = unload; + }; + callAndSave(); + + if (refreshRate > 0) { + interval = setInterval(() => { + abortCallbackRef.current(); + callAndSave(); + }, refreshRate); + } + + return () => { + clearInterval(interval); + abortCallbackRef.current(); + }; + }, [call, refreshRate]); + + // Use a reference for `call` to ensure a stable reference to `refresh` is always returned + const callRef = React.useRef(call); + callRef.current = call; + + const refresh = React.useCallback>(() => { + abortCallbackRef.current(); + const [callPromise, unload] = callRef.current(); + abortCallbackRef.current = unload; + return callPromise; + }, []); + + // Return the default reset state if a change is pending and initialPromisePurity is true + if (initialPromisePurity && changePendingRef.current) { + return [initialDefaultStateRef.current, false, undefined, refresh]; + } + + return [result, loaded, loadError, refresh]; +}; + +export default useFetchState;