From 271cb651504d1d12a23d084fb5c774f86ada157e Mon Sep 17 00:00:00 2001 From: Nastya <55718143+anrusina@users.noreply.github.com> Date: Wed, 25 May 2022 10:57:03 -0700 Subject: [PATCH] fix: separate api auth to plugin folder (#495) * fix: separate api auth to plugin folder * chore: for local admin URL is undefined * chore: update README.md * test: fix tests + add new ones * v0.0.2 - release for flyte-api plugin Signed-off-by: Nastya <55718143+anrusina@users.noreply.github.com> --- packages/composites/ui-atoms/package.json | 6 +- packages/plugins/components/package.json | 6 +- packages/plugins/flyte-api/README.md | 40 +++++++- packages/plugins/flyte-api/package.json | 16 ++-- .../src/ApiProvider/apiProvider.test.tsx | 47 ++++++++++ .../flyte-api/src/ApiProvider/index.tsx | 56 +++++++++++ .../flyte-api/src/ApiProvider/login.ts | 22 +++++ .../plugins/flyte-api/src/Sample/index.tsx | 41 -------- .../flyte-api/src/Sample/sample.stories.tsx | 34 ------- .../flyte-api/src/Sample/sample.test.tsx | 11 --- packages/plugins/flyte-api/src/index.ts | 5 +- .../plugins/flyte-api/src/utils/constants.ts | 10 ++ .../plugins/flyte-api/src/utils/errors.ts | 36 +++++++ packages/plugins/flyte-api/src/utils/index.ts | 74 +++++++++++++++ .../plugins/flyte-api/src/utils/nodeChecks.ts | 4 + .../plugins/flyte-api/src/utils/utils.test.ts | 65 +++++++++++++ packages/zapp/console/package.json | 6 +- .../zapp/console/src/components/App/App.tsx | 31 +++--- .../filters/useOnlyMyExecutionsFilterState.ts | 2 +- .../components/Navigation/UserInformation.tsx | 20 ++-- .../Navigation/test/UserInformation.test.tsx | 35 ++++--- .../Project/test/ProjectDashboard.test.tsx | 40 ++++---- .../Project/test/ProjectTask.test.tsx | 21 +++-- .../Project/test/ProjectWorkflows.test.tsx | 20 ++-- .../data/QueryAuthorizationObserver.tsx | 6 +- .../console/src/components/data/apiContext.ts | 29 +----- .../src/components/hooks/useFetchableData.ts | 6 +- .../src/components/hooks/useUserProfile.ts | 8 +- .../src/components/hooks/useVersion.ts | 8 +- .../AdminEntity/transformRequestError.ts | 2 +- .../console/src/models/AdminEntity/utils.ts | 21 ----- .../zapp/console/src/models/Common/api.ts | 51 ++-------- .../console/src/models/Common/constants.ts | 22 ----- yarn.lock | 94 ++++++++++++------- 34 files changed, 550 insertions(+), 345 deletions(-) create mode 100644 packages/plugins/flyte-api/src/ApiProvider/apiProvider.test.tsx create mode 100644 packages/plugins/flyte-api/src/ApiProvider/index.tsx create mode 100644 packages/plugins/flyte-api/src/ApiProvider/login.ts delete mode 100644 packages/plugins/flyte-api/src/Sample/index.tsx delete mode 100644 packages/plugins/flyte-api/src/Sample/sample.stories.tsx delete mode 100644 packages/plugins/flyte-api/src/Sample/sample.test.tsx create mode 100644 packages/plugins/flyte-api/src/utils/constants.ts create mode 100644 packages/plugins/flyte-api/src/utils/errors.ts create mode 100644 packages/plugins/flyte-api/src/utils/index.ts create mode 100644 packages/plugins/flyte-api/src/utils/nodeChecks.ts create mode 100644 packages/plugins/flyte-api/src/utils/utils.test.ts diff --git a/packages/composites/ui-atoms/package.json b/packages/composites/ui-atoms/package.json index 3d53063d4..0ef6e457f 100644 --- a/packages/composites/ui-atoms/package.json +++ b/packages/composites/ui-atoms/package.json @@ -3,8 +3,8 @@ "version": "0.0.1-rc.2", "description": "Flyteconsole UI atoms, which didn't plan to be published and would be consumed as is internally", "main": "./dist/index.js", - "module": "./lib/esm/index.js", - "types": "./lib/esm/index.d.ts", + "module": "./lib/index.js", + "types": "./lib/index.d.ts", "license": "Apache-2.0", "private": false, "publishConfig": { @@ -13,7 +13,7 @@ }, "scripts": { "build": "yarn build:esm && yarn build:cjs", - "build:esm": "tsc --module esnext --outDir lib/esm", + "build:esm": "tsc --module esnext --outDir lib", "build:cjs": "tsc", "test": "NODE_ENV=test jest" }, diff --git a/packages/plugins/components/package.json b/packages/plugins/components/package.json index 0e88f35b4..50905e212 100644 --- a/packages/plugins/components/package.json +++ b/packages/plugins/components/package.json @@ -3,8 +3,8 @@ "version": "0.0.1-rc.2", "description": "Flyteconsole Components module, which is published as npm package and can be consumed by 3rd parties", "main": "./dist/index.js", - "module": "./lib/esm/index.js", - "types": "./lib/esm/index.d.ts", + "module": "./lib/index.js", + "types": "./lib/index.d.ts", "license": "Apache-2.0", "private": false, "publishConfig": { @@ -13,7 +13,7 @@ }, "scripts": { "build": "yarn build:esm && yarn build:cjs", - "build:esm": "tsc --module esnext --outDir lib/esm --project ./tsconfig.build.json", + "build:esm": "tsc --module esnext --outDir lib --project ./tsconfig.build.json", "build:cjs": "tsc --project ./tsconfig.build.json", "test": "NODE_ENV=test jest" }, diff --git a/packages/plugins/flyte-api/README.md b/packages/plugins/flyte-api/README.md index b8c09ddd5..bf4f72fa3 100644 --- a/packages/plugins/flyte-api/README.md +++ b/packages/plugins/flyte-api/README.md @@ -1 +1,39 @@ -This is a flyte-API package for flyteconsole plugin system +## @flyteconsole/flyte-api + +This package provides ability to do FlyteAdmin API calls from JS/TS code. + +At this point it allows to get though authentication steps, request user profile and FlyteAdmin version. +In future releases we will add ability to do all types of FlyteAdmin API calls. + +### Installation + +To install the package please run: +```bash +yarn add @flyteconsole/flyte-api +``` + +### Usage + +To use in your application + +- Wrap parent component with + +`ADMIN_API_URL` is a flyte admin domain URL to which `/api/v1/_endpoint` part would be added, to perform REST API call. + ` +Then from any child component + +```js +import useAxios from 'axios-hooks'; +import { useFlyteApi, defaultAxiosConfig } from '@flyteconsole/flyte-api'; + +... +/** Get profile information */ +const apiContext = useFlyteApi(); + +const profilePath = apiContext.getProfileUrl(); +const [{ data: profile, loading }] = useAxios({ + url: profilePath, + method: 'GET', + ...defaultAxiosConfig, +}); +``` diff --git a/packages/plugins/flyte-api/package.json b/packages/plugins/flyte-api/package.json index 10d551800..b5bcda356 100644 --- a/packages/plugins/flyte-api/package.json +++ b/packages/plugins/flyte-api/package.json @@ -1,10 +1,10 @@ { "name": "@flyteconsole/flyte-api", - "version": "0.0.1-rc.1", + "version": "0.0.2", "description": "FlyteConsole plugin to allow access FlyteAPI", "main": "./dist/index.js", - "module": "./lib/esm/index.js", - "types": "./lib/esm/index.d.ts", + "module": "./lib/index.js", + "types": "./lib/index.d.ts", "license": "Apache-2.0", "private": false, "publishConfig": { @@ -12,15 +12,17 @@ "registry": "https://registry.npmjs.org/" }, "scripts": { + "clean": "rm -rf dist && rm -rf lib", "build": "yarn build:esm && yarn build:cjs", - "build:esm": "tsc --module esnext --outDir lib/esm --project ./tsconfig.build.json", + "build:esm": "tsc --module esnext --outDir lib --project ./tsconfig.build.json", "build:cjs": "tsc --project ./tsconfig.build.json", + "push:update": "yarn clean && yarn build && yarn publish", "test": "NODE_ENV=test jest" }, "dependencies": { - "@material-ui/core": "^4.0.0", - "@material-ui/icons": "^4.0.0", - "classnames": "^2.3.1" + "axios": "^0.27.2", + "camelcase-keys": "^7.0.2", + "snakecase-keys": "^5.4.2" }, "peerDependencies": { "react": "^16.13.1", diff --git a/packages/plugins/flyte-api/src/ApiProvider/apiProvider.test.tsx b/packages/plugins/flyte-api/src/ApiProvider/apiProvider.test.tsx new file mode 100644 index 000000000..63b84cbb3 --- /dev/null +++ b/packages/plugins/flyte-api/src/ApiProvider/apiProvider.test.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { FlyteApiProvider, useFlyteApi } from '.'; +import { AdminEndpoint } from '../utils/constants'; +import { getLoginUrl } from './login'; + +const MockCoponent = () => { + const context = useFlyteApi(); + + return ( + <> +
{context.getProfileUrl()}
+
{context.getAdminApiUrl('/magic')}
+
{context.getLoginUrl()}
+ + ); +}; + +describe('fltyte-api/ApiProvider', () => { + it('getLoginUrl properly adds redirect url', () => { + const result = getLoginUrl(AdminEndpoint.Version, `http://some.nonsense`); + expect(result).toEqual('/version/login?redirect_url=http://some.nonsense'); + }); + + it('If FlyteApiContext is not defined, returned URL uses default value', () => { + const { getAllByText } = render(); + expect(getAllByText('#').length).toBe(3); + }); + + it('If FlyteApiContext is defined, but flyteApiDomain is not point to localhost', () => { + const { getByText } = render( + + + , + ); + expect(getByText('http://localhost/me')).toBeInTheDocument(); + }); + + it('If FlyteApiContext provides flyteApiDomain value', () => { + const { getByText } = render( + + + , + ); + expect(getByText('https://some.domain.here/me')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugins/flyte-api/src/ApiProvider/index.tsx b/packages/plugins/flyte-api/src/ApiProvider/index.tsx new file mode 100644 index 000000000..137caf2b9 --- /dev/null +++ b/packages/plugins/flyte-api/src/ApiProvider/index.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { createContext, useContext } from 'react'; +import { getAdminApiUrl, getEndpointUrl } from '../utils'; +import { AdminEndpoint, RawEndpoint } from '../utils/constants'; +import { defaultLoginStatus, getLoginUrl, LoginStatus } from './login'; + +export interface FlyteApiContextState { + loginStatus: LoginStatus; + getLoginUrl: (redirect?: string) => string; + getProfileUrl: () => string; + getAdminApiUrl: (endpoint: AdminEndpoint | string) => string; +} + +const FlyteApiContext = createContext({ + // default values - used when Provider wrapper is not found + loginStatus: defaultLoginStatus, + getLoginUrl: () => '#', + getProfileUrl: () => '#', + getAdminApiUrl: () => '#', +}); + +interface FlyteApiProviderProps { + flyteApiDomain?: string; + children?: React.ReactNode; +} + +export const useFlyteApi = () => useContext(FlyteApiContext); + +export const FlyteApiProvider = (props: FlyteApiProviderProps) => { + const { flyteApiDomain } = props; + + const [loginExpired, setLoginExpired] = React.useState(false); + + // Whenever we detect expired credentials, trigger a login redirect automatically + React.useEffect(() => { + if (loginExpired) { + window.location.href = getLoginUrl(flyteApiDomain); + } + }, [loginExpired]); + + return ( + getLoginUrl(flyteApiDomain, redirect), + getProfileUrl: () => getEndpointUrl(RawEndpoint.Profile, flyteApiDomain), + getAdminApiUrl: (endpoint) => getAdminApiUrl(endpoint, flyteApiDomain), + }} + > + {props.children} + + ); +}; diff --git a/packages/plugins/flyte-api/src/ApiProvider/login.ts b/packages/plugins/flyte-api/src/ApiProvider/login.ts new file mode 100644 index 000000000..f0c70b943 --- /dev/null +++ b/packages/plugins/flyte-api/src/ApiProvider/login.ts @@ -0,0 +1,22 @@ +import { getEndpointUrl } from '../utils'; +import { RawEndpoint } from '../utils/constants'; + +export interface LoginStatus { + expired: boolean; + setExpired(expired: boolean): void; +} + +export const defaultLoginStatus: LoginStatus = { + expired: true, + setExpired: () => { + /** Do nothing */ + }, +}; + +/** Constructs a url for redirecting to the Admin login endpoint and returning + * to the current location after completing the flow. + */ +export function getLoginUrl(adminUrl?: string, redirectUrl: string = window.location.href) { + const baseUrl = getEndpointUrl(RawEndpoint.Login, adminUrl); + return `${baseUrl}?redirect_url=${redirectUrl}`; +} diff --git a/packages/plugins/flyte-api/src/Sample/index.tsx b/packages/plugins/flyte-api/src/Sample/index.tsx deleted file mode 100644 index 7be98984d..000000000 --- a/packages/plugins/flyte-api/src/Sample/index.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as React from 'react'; -import { AppBar, Toolbar, IconButton, makeStyles, Theme } from '@material-ui/core'; -import MenuIcon from '@material-ui/icons/Menu'; - -const useStyles = makeStyles((theme: Theme) => ({ - spacer: { - flexGrow: 1, - }, - menuButton: { - marginRight: theme.spacing(2), - }, -})); - -export interface SampleComponentProps { - useCustomContent?: boolean; // rename to show that it is a backNavigation - className?: string; -} - -/** Contains all content in the top navbar of the application. */ -export const SampleComponent = (props: SampleComponentProps) => { - const styles = useStyles(); - - return ( - - -
- {' Sample Text '} -
- - - - - - ); -}; diff --git a/packages/plugins/flyte-api/src/Sample/sample.stories.tsx b/packages/plugins/flyte-api/src/Sample/sample.stories.tsx deleted file mode 100644 index 12b58a5e6..000000000 --- a/packages/plugins/flyte-api/src/Sample/sample.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; -import { ComponentStory, ComponentMeta } from '@storybook/react'; -import { makeStyles, Theme } from '@material-ui/core/styles'; - -import { SampleComponent } from '.'; - -export default { - title: 'Flyte-API/Sample', - component: SampleComponent, -} as ComponentMeta; - -const useStyles = makeStyles((_theme: Theme) => ({ - updatedOne: { - backgroundColor: 'lightblue', - color: 'black', - }, - updatedTwo: { - backgroundColor: 'black', - color: 'yellow', - }, -})); - -const Template: ComponentStory = () => ; -export const Primary = Template.bind({}); - -export const Secondary: ComponentStory = () => { - const styles = useStyles(); - return ; -}; - -export const Tertiary: ComponentStory = () => { - const styles = useStyles(); - return ; -}; diff --git a/packages/plugins/flyte-api/src/Sample/sample.test.tsx b/packages/plugins/flyte-api/src/Sample/sample.test.tsx deleted file mode 100644 index e6728d223..000000000 --- a/packages/plugins/flyte-api/src/Sample/sample.test.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import * as React from 'react'; -import { render, screen } from '@testing-library/react'; -import { SampleComponent } from './index'; - -describe('add function', () => { - it('SampleComponent is rendered contains correct text', () => { - render(); - const text = screen.getByText('Sample Text'); - expect(text).toBeInTheDocument(); - }); -}); diff --git a/packages/plugins/flyte-api/src/index.ts b/packages/plugins/flyte-api/src/index.ts index 21c680758..ef7f4f4f2 100644 --- a/packages/plugins/flyte-api/src/index.ts +++ b/packages/plugins/flyte-api/src/index.ts @@ -1 +1,4 @@ -export { SampleComponent } from './Sample'; +export { FlyteApiProvider, useFlyteApi, type FlyteApiContextState } from './ApiProvider'; + +export { AdminEndpoint, RawEndpoint } from './utils/constants'; +export { getAxiosApiCall, defaultAxiosConfig } from './utils'; diff --git a/packages/plugins/flyte-api/src/utils/constants.ts b/packages/plugins/flyte-api/src/utils/constants.ts new file mode 100644 index 000000000..49cf31413 --- /dev/null +++ b/packages/plugins/flyte-api/src/utils/constants.ts @@ -0,0 +1,10 @@ +export enum RawEndpoint { + Login = '/login', + Profile = '/me', +} + +export const adminApiPrefix = '/api/v1'; + +export enum AdminEndpoint { + Version = '/version', +} diff --git a/packages/plugins/flyte-api/src/utils/errors.ts b/packages/plugins/flyte-api/src/utils/errors.ts new file mode 100644 index 000000000..81c66bd2c --- /dev/null +++ b/packages/plugins/flyte-api/src/utils/errors.ts @@ -0,0 +1,36 @@ +/* eslint-disable max-classes-per-file */ +import { AxiosError } from 'axios'; + +export class NotFoundError extends Error { + constructor(public override name: string, msg = 'The requested item could not be found') { + super(msg); + } +} + +/** Indicates failure to fetch a resource because the user is not authorized (401) */ +export class NotAuthorizedError extends Error { + constructor(msg = 'User is not authorized to view this resource') { + super(msg); + } +} + +/** Detects special cases for errors returned from Axios and lets others pass through. */ +export function transformRequestError(err: unknown, path: string) { + const error = err as AxiosError; + + if (!error.response) { + return error; + } + + // For some status codes, we'll throw a special error to allow + // client code and components to handle separately + if (error.response.status === 404) { + return new NotFoundError(path); + } + if (error.response.status === 401) { + return new NotAuthorizedError(); + } + + // this error is not decoded. + return error; +} diff --git a/packages/plugins/flyte-api/src/utils/index.ts b/packages/plugins/flyte-api/src/utils/index.ts new file mode 100644 index 000000000..2658a6a22 --- /dev/null +++ b/packages/plugins/flyte-api/src/utils/index.ts @@ -0,0 +1,74 @@ +import axios, { + AxiosRequestConfig, + AxiosRequestTransformer, + AxiosResponseTransformer, +} from 'axios'; +import * as snakecaseKeys from 'snakecase-keys'; +import * as camelcaseKeys from 'camelcase-keys'; +import { AdminEndpoint, adminApiPrefix, RawEndpoint } from './constants'; +import { isObject } from './nodeChecks'; +import { transformRequestError } from './errors'; + +/** Ensures that a string is slash-prefixed */ +function ensureSlashPrefixed(path: string) { + return path.startsWith('/') ? path : `/${path}`; +} + +/** Creates a URL to the same host with a given path */ +function createLocalURL(path: string) { + return `${window.location.origin}${ensureSlashPrefixed(path)}`; +} + +/** Updates Enpoint url depending on admin domain */ +export function getEndpointUrl(endpoint: RawEndpoint | string, adminUrl?: string) { + if (adminUrl) { + return `${adminUrl}${endpoint}`; + } + + return createLocalURL(endpoint); +} + +/** Adds admin api prefix to domain Url */ +export function getAdminApiUrl(endpoint: AdminEndpoint | string, adminUrl?: string) { + const finalUrl = `${adminApiPrefix}${ensureSlashPrefixed(endpoint)}`; + + if (adminUrl) { + return `${adminUrl}${finalUrl}`; + } + + return createLocalURL(finalUrl); +} + +/** Config object that can be used for requests that are not sent to + * the Admin entity API (`/api/v1/...`), such as the `/me` endpoint. This config + * ensures that requests/responses are correctly converted and that cookies are + * included. + */ +export const defaultAxiosConfig: AxiosRequestConfig = { + transformRequest: [ + (data: any) => (isObject(data) ? snakecaseKeys(data) : data), + ...(axios.defaults.transformRequest as AxiosRequestTransformer[]), + ], + transformResponse: [ + ...(axios.defaults.transformResponse as AxiosResponseTransformer[] as any), + camelcaseKeys, + ], + withCredentials: true, +}; + +/** + * @deprecated Please use `axios-hooks` instead, it will allow you to get full call status. + * example usage https://www.npmjs.com/package/axios-hooks: + * const [{ data: profile, loading, errot }] = useAxios({url: path, method: 'GET', ...defaultAxiosConfig}); + */ +export const getAxiosApiCall = async (path: string): Promise => { + try { + const { data } = await axios.get(path, defaultAxiosConfig); + return data; + } catch (e) { + const { message } = transformRequestError(e, path); + // eslint-disable-next-line no-console + console.error(`Failed to fetch data: ${message}`); + return null; + } +}; diff --git a/packages/plugins/flyte-api/src/utils/nodeChecks.ts b/packages/plugins/flyte-api/src/utils/nodeChecks.ts new file mode 100644 index 000000000..13dc100b7 --- /dev/null +++ b/packages/plugins/flyte-api/src/utils/nodeChecks.ts @@ -0,0 +1,4 @@ +// recommended util.d.ts implementation +export const isObject = (value: unknown): boolean => { + return value !== null && typeof value === 'object'; +}; diff --git a/packages/plugins/flyte-api/src/utils/utils.test.ts b/packages/plugins/flyte-api/src/utils/utils.test.ts new file mode 100644 index 000000000..fb2d2b6a1 --- /dev/null +++ b/packages/plugins/flyte-api/src/utils/utils.test.ts @@ -0,0 +1,65 @@ +import { getAdminApiUrl, getEndpointUrl } from '.'; +import { AdminEndpoint, RawEndpoint } from './constants'; +import { transformRequestError } from './errors'; +import { isObject } from './nodeChecks'; + +describe('flyte-api/utils', () => { + it('getEndpointUrl properly uses local or admin domain', () => { + // when admin domain is not provided - uses localhost + let result = getEndpointUrl(RawEndpoint.Profile); + expect(result).toEqual('http://localhost/me'); + + // when admin domain is empty - uses localhost + result = getEndpointUrl(RawEndpoint.Profile, ''); + expect(result).toEqual('http://localhost/me'); + + // when admin domain is empty - uses localhost + result = getEndpointUrl(RawEndpoint.Profile, ''); + expect(result).toEqual('http://localhost/me'); + + // when admin domain provided - uses it + result = getEndpointUrl(RawEndpoint.Profile, 'https://admin.domain.io'); + expect(result).toEqual('https://admin.domain.io/me'); + }); + + it('getAdminApiUrl properly adds api/v1 prefix', () => { + // when admin domain is not provided - uses localhost + let result = getAdminApiUrl(AdminEndpoint.Version); + expect(result).toEqual('http://localhost/api/v1/version'); + + result = getAdminApiUrl('execution?=filter', 'https://admin.domain.io'); + expect(result).toEqual('https://admin.domain.io/api/v1/execution?=filter'); + }); + + it('isObject properly identifies objects', () => { + // Not an objects + expect(isObject(null)).toBeFalsy(); + expect(isObject(undefined)).toBeFalsy(); + expect(isObject(3)).toBeFalsy(); + expect(isObject('abc')).toBeFalsy(); + expect(isObject(true)).toBeFalsy(); + + // Objects + expect(isObject({ hi: 'there' })).toBeTruthy(); + expect(isObject([])).toBeTruthy(); + expect(isObject({})).toBeTruthy(); + }); + + it('transformRequestError', () => { + // no status - return item as is + let result = transformRequestError({ message: 'default' }, ''); + expect(result.message).toEqual('default'); + + // 401 - Unauthorised + result = transformRequestError({ response: { status: 401 }, message: 'default' }, ''); + expect(result.message).toEqual('User is not authorized to view this resource'); + + // 404 - Not Found + result = transformRequestError({ response: { status: 404 }, message: 'default' }, ''); + expect(result.message).toEqual('The requested item could not be found'); + + // unnown status - return item as is + result = transformRequestError({ response: { status: 502 }, message: 'default' }, ''); + expect(result.message).toEqual('default'); + }); +}); diff --git a/packages/zapp/console/package.json b/packages/zapp/console/package.json index 678cb3552..5348f4968 100644 --- a/packages/zapp/console/package.json +++ b/packages/zapp/console/package.json @@ -95,11 +95,11 @@ "@typescript-eslint/parser": "^5.15.0", "@xstate/react": "^1.0.0", "autoprefixer": "^8.3.0", - "axios": "^0.21.2", + "axios": "^0.27.2", "axios-mock-adapter": "^1.16.0", "babel-loader": "^8.2.5", "babel-polyfill": "^6.26.0", - "camelcase-keys": "^6.1.1", + "camelcase-keys": "^7.0.2", "classnames": "^2.3.1", "compression-webpack-plugin": "^9.2.0", "contrast": "^1.0.1", @@ -147,7 +147,7 @@ "resolve-url-loader": "^5.0.0", "semantic-release": "^17.2.3", "shallowequal": "^1.1.0", - "snakecase-keys": "^3.1.0", + "snakecase-keys": "^5.4.2", "source-map-loader": "^3.0.1", "ts-loader": "^9.2.6", "ts-node": "^8.0.2", diff --git a/packages/zapp/console/src/components/App/App.tsx b/packages/zapp/console/src/components/App/App.tsx index ef417ab10..9ef9d323d 100644 --- a/packages/zapp/console/src/components/App/App.tsx +++ b/packages/zapp/console/src/components/App/App.tsx @@ -1,5 +1,7 @@ +import * as React from 'react'; import { CssBaseline, Collapse } from '@material-ui/core'; import { ThemeProvider } from '@material-ui/styles'; +import { FlyteApiProvider } from '@flyteconsole/flyte-api'; import { SnackbarProvider } from 'notistack'; import { FeatureFlagsProvider } from 'basics/FeatureFlags'; import { env } from 'common/env'; @@ -11,7 +13,6 @@ import { createQueryClient } from 'components/data/queryCache'; import { SystemStatusBanner } from 'components/Notifications/SystemStatusBanner'; import { skeletonColor, skeletonHighlightColor } from 'components/Theme/constants'; import { muiTheme } from 'components/Theme/muiTheme'; -import * as React from 'react'; import { hot } from 'react-hot-loader'; import { SkeletonTheme } from 'react-loading-skeleton'; import { QueryClientProvider } from 'react-query'; @@ -41,19 +42,21 @@ export const AppComponent: React.FC = () => { TransitionComponent={Collapse} > - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/packages/zapp/console/src/components/Executions/filters/useOnlyMyExecutionsFilterState.ts b/packages/zapp/console/src/components/Executions/filters/useOnlyMyExecutionsFilterState.ts index 2e39dd12b..f1d071a68 100644 --- a/packages/zapp/console/src/components/Executions/filters/useOnlyMyExecutionsFilterState.ts +++ b/packages/zapp/console/src/components/Executions/filters/useOnlyMyExecutionsFilterState.ts @@ -25,7 +25,7 @@ export function useOnlyMyExecutionsFilterState({ initialValue, }: OnlyMyExecutionsFilterStateProps): OnlyMyExecutionsFilterState { const profile = useUserProfile(); - const userId = profile.value?.subject ? profile.value.subject : ''; + const userId = profile.value?.subject ?? ''; const isFlagEnabled = useFeatureFlag(FeatureFlag.OnlyMine); const onlyMineExecutionsSelectedValue = useOnlyMineSelectedValue(OnlyMyFilter.OnlyMyExecutions); const [onlyMyExecutionsValue, setOnlyMyExecutionsValue] = useState( diff --git a/packages/zapp/console/src/components/Navigation/UserInformation.tsx b/packages/zapp/console/src/components/Navigation/UserInformation.tsx index 31527df77..acac76357 100644 --- a/packages/zapp/console/src/components/Navigation/UserInformation.tsx +++ b/packages/zapp/console/src/components/Navigation/UserInformation.tsx @@ -1,9 +1,8 @@ import * as React from 'react'; -import Link from '@material-ui/core/Link'; -import { makeStyles, Theme } from '@material-ui/core/styles'; +import { useFlyteApi } from '@flyteconsole/flyte-api'; +import { Link, makeStyles, Theme } from '@material-ui/core'; import { WaitForData } from 'components/common/WaitForData'; import { useUserProfile } from 'components/hooks/useUserProfile'; -import { getLoginUrl } from 'models/AdminEntity/utils'; import t from './strings'; const useStyles = makeStyles((theme: Theme) => ({ @@ -12,22 +11,25 @@ const useStyles = makeStyles((theme: Theme) => ({ }, })); -const LoginLink: React.FC = () => ( - - {t('login')} - -); +const LoginLink = (props: { loginUrl: string }) => { + return ( + + {t('login')} + + ); +}; /** Displays user info if logged in, or a login link otherwise. */ export const UserInformation: React.FC<{}> = () => { const style = useStyles(); const profile = useUserProfile(); + const apiContext = useFlyteApi(); return (
{!profile.value ? ( - + ) : !profile.value.preferredUsername || profile.value.preferredUsername === '' ? ( profile.value.name ) : ( diff --git a/packages/zapp/console/src/components/Navigation/test/UserInformation.test.tsx b/packages/zapp/console/src/components/Navigation/test/UserInformation.test.tsx index 09bbf23bc..4e0eb83a0 100644 --- a/packages/zapp/console/src/components/Navigation/test/UserInformation.test.tsx +++ b/packages/zapp/console/src/components/Navigation/test/UserInformation.test.tsx @@ -1,42 +1,39 @@ import { render, waitFor } from '@testing-library/react'; -import { APIContext } from 'components/data/apiContext'; -import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; -import { getUserProfile } from 'models/Common/api'; +import { FetchableData } from 'components/hooks/types'; +import { useUserProfile } from 'components/hooks/useUserProfile'; +import { loadedFetchable } from 'components/hooks/__mocks__/fetchableData'; import { UserProfile } from 'models/Common/types'; import * as React from 'react'; + import { UserInformation } from '../UserInformation'; +jest.mock('components/hooks/useUserProfile'); + describe('UserInformation', () => { const sampleUserProfile: UserProfile = { preferredUsername: 'testUser@example.com', } as UserProfile; - let mockGetUserProfile: jest.Mock>; - - const UserInformationWithContext = () => ( - - - - ); - - beforeEach(() => { - mockGetUserProfile = jest.fn().mockResolvedValue(null); - }); + const mockUseUserProfile = useUserProfile as jest.Mock>; it('Shows login link if no user profile exists', async () => { - const { getByText } = render(); + mockUseUserProfile.mockReturnValue(loadedFetchable(null, jest.fn())); + const { getByText } = render(); + await waitFor(() => getByText('Login')); - expect(mockGetUserProfile).toHaveBeenCalled(); + expect(mockUseUserProfile).toHaveBeenCalled(); + const element = getByText('Login'); expect(element).toBeInTheDocument(); expect(element.tagName).toBe('A'); }); it('Shows user preferredName if profile exists', async () => { - mockGetUserProfile.mockResolvedValue(sampleUserProfile); - const { getByText } = render(); + mockUseUserProfile.mockReturnValue(loadedFetchable(sampleUserProfile, jest.fn())); + const { getByText } = render(); + await waitFor(() => getByText(sampleUserProfile.preferredUsername)); - expect(mockGetUserProfile).toHaveBeenCalled(); + expect(mockUseUserProfile).toHaveBeenCalled(); expect(getByText(sampleUserProfile.preferredUsername)).toBeInTheDocument(); }); }); diff --git a/packages/zapp/console/src/components/Project/test/ProjectDashboard.test.tsx b/packages/zapp/console/src/components/Project/test/ProjectDashboard.test.tsx index fe4af21af..1008c4152 100644 --- a/packages/zapp/console/src/components/Project/test/ProjectDashboard.test.tsx +++ b/packages/zapp/console/src/components/Project/test/ProjectDashboard.test.tsx @@ -13,16 +13,16 @@ import * as React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router'; import { createTestQueryClient, disableQueryLogger, enableQueryLogger } from 'test/utils'; - -import { APIContext } from 'components/data/apiContext'; -import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; -import { getUserProfile } from 'models/Common/api'; +import { useUserProfile } from 'components/hooks/useUserProfile'; +import { FetchableData } from 'components/hooks/types'; +import { loadedFetchable } from 'components/hooks/__mocks__/fetchableData'; import { getProjectDomainAttributes } from 'models/Project/api'; import { Admin } from 'flyteidl'; import * as LocalCache from 'basics/LocalCache'; import { ProjectDashboard } from '../ProjectDashboard'; import { failedToLoadExecutionsString } from '../constants'; +jest.mock('components/hooks/useUserProfile'); jest.mock('components/Executions/Tables/WorkflowExecutionsTable'); jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }), @@ -48,13 +48,14 @@ jest.mock('models/Project/api', () => ({ })); describe('ProjectDashboard', () => { + const mockUseUserProfile = useUserProfile as jest.Mock>; + let basicPythonFixture: ReturnType; let failedTaskFixture: ReturnType; let executions1: Execution[]; let executions2: Execution[]; let scope: DomainIdentifierScope; let queryClient: QueryClient; - let mockGetUserProfile: jest.Mock>; const sampleUserProfile: UserProfile = { subject: 'subject', @@ -74,12 +75,13 @@ describe('ProjectDashboard', () => { jest.spyOn(LocalCache, 'useLocalCache'); beforeEach(() => { - mockGetUserProfile = jest.fn().mockResolvedValue(null); + mockUseUserProfile.mockReturnValue(loadedFetchable(null, jest.fn())); queryClient = createTestQueryClient(); basicPythonFixture = basicPythonWorkflow.generate(); failedTaskFixture = oneFailedTaskWorkflow.generate(); insertFixture(mockServer, basicPythonFixture); insertFixture(mockServer, failedTaskFixture); + executions1 = [ basicPythonFixture.workflowExecutions.top.data, failedTaskFixture.workflowExecutions.top.data, @@ -94,13 +96,7 @@ describe('ProjectDashboard', () => { const renderView = () => render( - - - + , { wrapper: MemoryRouter }, ); @@ -114,14 +110,14 @@ describe('ProjectDashboard', () => { }); it('should show loading spinner', async () => { - mockGetUserProfile.mockResolvedValue(sampleUserProfile); + mockUseUserProfile.mockReturnValue(loadedFetchable(sampleUserProfile, jest.fn())); const { queryByTestId } = renderView(); await waitFor(() => {}); expect(queryByTestId(/loading-spinner/i)).toBeDefined(); }); it('should display WorkflowExecutionsTable and BarChart ', async () => { - mockGetUserProfile.mockResolvedValue(sampleUserProfile); + mockUseUserProfile.mockReturnValue(loadedFetchable(sampleUserProfile, jest.fn())); const { queryByTestId } = renderView(); await waitFor(() => {}); expect(queryByTestId('workflow-table')).toBeDefined(); @@ -130,15 +126,17 @@ describe('ProjectDashboard', () => { it('should not display checkbox if user does not login', async () => { const { queryByTestId } = renderView(); await waitFor(() => {}); - expect(mockGetUserProfile).toHaveBeenCalled(); + expect(mockUseUserProfile).toHaveBeenCalled(); expect(queryByTestId(/checkbox/i)).toBeNull(); }); it('should display checkboxes if user login', async () => { - mockGetUserProfile.mockResolvedValue(sampleUserProfile); + mockUseUserProfile.mockReturnValue(loadedFetchable(sampleUserProfile, jest.fn())); const { getAllByRole } = renderView(); + await waitFor(() => {}); - expect(mockGetUserProfile).toHaveBeenCalled(); + expect(mockUseUserProfile).toHaveBeenCalled(); + // There are 2 checkboxes on a page: 1 - onlyMyExecutions, 2 - show archived, both unchecked by default const checkboxes = getAllByRole(/checkbox/i) as HTMLInputElement[]; expect(checkboxes).toHaveLength(2); @@ -148,16 +146,18 @@ describe('ProjectDashboard', () => { /** user doesn't have its own workflow */ it('should not display workflow if the user does not have one when filtered onlyMyExecutions', async () => { - mockGetUserProfile.mockResolvedValue(sampleUserProfile); + mockUseUserProfile.mockReturnValue(loadedFetchable(sampleUserProfile, jest.fn())); const { getByText, queryByText, getAllByRole } = renderView(); await waitFor(() => {}); - expect(mockGetUserProfile).toHaveBeenCalled(); + expect(mockUseUserProfile).toHaveBeenCalled(); + // There are 2 checkboxes on a page: 1 - onlyMyExecutions, 2 - show archived, both unchecked by default const checkboxes = getAllByRole(/checkbox/i) as HTMLInputElement[]; expect(checkboxes[0]).toBeTruthy(); expect(checkboxes[0]?.checked).toEqual(false); await waitFor(() => expect(getByText(executions1[0].closure.workflowId.name))); fireEvent.click(checkboxes[0]); + // when user selects checkbox, table should have no executions to display await waitFor(() => expect(queryByText(executions1[0].closure.workflowId.name)).toBeNull()); }); diff --git a/packages/zapp/console/src/components/Project/test/ProjectTask.test.tsx b/packages/zapp/console/src/components/Project/test/ProjectTask.test.tsx index 9642f01e8..b8afe58bc 100644 --- a/packages/zapp/console/src/components/Project/test/ProjectTask.test.tsx +++ b/packages/zapp/console/src/components/Project/test/ProjectTask.test.tsx @@ -1,8 +1,11 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import { APIContext } from 'components/data/apiContext'; +import { useUserProfile } from 'components/hooks/useUserProfile'; import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; +import { FetchableData } from 'components/hooks/types'; +import { loadedFetchable } from 'components/hooks/__mocks__/fetchableData'; import { FilterOperationName } from 'models/AdminEntity/types'; -import { getUserProfile, listNamedEntities } from 'models/Common/api'; +import { listNamedEntities } from 'models/Common/api'; import { NamedEntity, NamedEntityIdentifier, @@ -27,6 +30,7 @@ const sampleUserProfile: UserProfile = { subject: 'subject', } as UserProfile; +jest.mock('components/hooks/useUserProfile'); jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }), })); @@ -41,10 +45,10 @@ describe('ProjectTasks', () => { let tasks: NamedEntity[]; let queryClient: QueryClient; let mockListNamedEntities: jest.Mock>; - let mockGetUserProfile: jest.Mock>; + const mockUseUserProfile = useUserProfile as jest.Mock>; beforeEach(() => { - mockGetUserProfile = jest.fn().mockResolvedValue(null); + mockUseUserProfile.mockReturnValue(loadedFetchable(null, jest.fn())); queryClient = createTestQueryClient(); tasks = ['MyTask', 'MyOtherTask'].map((name) => createTask({ domain, name, project })); mockListNamedEntities = jest.fn().mockResolvedValue({ entities: tasks }); @@ -60,10 +64,7 @@ describe('ProjectTasks', () => { render( @@ -91,7 +92,7 @@ describe('ProjectTasks', () => { }); it('should display checkbox if user login', async () => { - mockGetUserProfile.mockResolvedValue(sampleUserProfile); + mockUseUserProfile.mockReturnValue(loadedFetchable(sampleUserProfile, jest.fn())); const { getAllByRole } = renderComponent(); await waitFor(() => {}); const checkboxes = getAllByRole(/checkbox/i) as HTMLInputElement[]; @@ -101,7 +102,7 @@ describe('ProjectTasks', () => { }); it('should display archive button', async () => { - mockGetUserProfile.mockResolvedValue(sampleUserProfile); + mockUseUserProfile.mockReturnValue(loadedFetchable(sampleUserProfile, jest.fn())); const { getByText, getAllByTitle, findAllByText } = renderComponent(); await waitFor(() => {}); @@ -130,7 +131,7 @@ describe('ProjectTasks', () => { }); it('clicking show archived should hide active tasks', async () => { - mockGetUserProfile.mockResolvedValue(sampleUserProfile); + mockUseUserProfile.mockReturnValue(loadedFetchable(sampleUserProfile, jest.fn())); const { getByText, queryByText, getAllByRole } = renderComponent(); await waitFor(() => {}); diff --git a/packages/zapp/console/src/components/Project/test/ProjectWorkflows.test.tsx b/packages/zapp/console/src/components/Project/test/ProjectWorkflows.test.tsx index 49a43be48..da1bd7f5b 100644 --- a/packages/zapp/console/src/components/Project/test/ProjectWorkflows.test.tsx +++ b/packages/zapp/console/src/components/Project/test/ProjectWorkflows.test.tsx @@ -1,8 +1,11 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import { APIContext } from 'components/data/apiContext'; import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; +import { FetchableData } from 'components/hooks/types'; +import { useUserProfile } from 'components/hooks/useUserProfile'; +import { loadedFetchable } from 'components/hooks/__mocks__/fetchableData'; import { FilterOperationName } from 'models/AdminEntity/types'; -import { getUserProfile, listNamedEntities } from 'models/Common/api'; +import { listNamedEntities } from 'models/Common/api'; import { NamedEntity, UserProfile } from 'models/Common/types'; import { NamedEntityState } from 'models/enums'; import * as React from 'react'; @@ -16,6 +19,7 @@ const sampleUserProfile: UserProfile = { subject: 'subject', } as UserProfile; +jest.mock('components/hooks/useUserProfile'); jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }), })); @@ -26,10 +30,11 @@ describe('ProjectWorkflows', () => { let workflowNames: NamedEntity[]; let queryClient: QueryClient; let mockListNamedEntities: jest.Mock>; - let mockGetUserProfile: jest.Mock>; + + const mockUseUserProfile = useUserProfile as jest.Mock>; beforeEach(() => { - mockGetUserProfile = jest.fn().mockResolvedValue(null); + mockUseUserProfile.mockReturnValue(loadedFetchable(null, jest.fn())); queryClient = createTestQueryClient(); workflowNames = ['MyWorkflow', 'MyOtherWorkflow'].map((name) => createWorkflowName({ domain, name, project }), @@ -41,10 +46,7 @@ describe('ProjectWorkflows', () => { render( @@ -71,7 +73,7 @@ describe('ProjectWorkflows', () => { }); it('should display checkbox if user login', async () => { - mockGetUserProfile.mockResolvedValue(sampleUserProfile); + mockUseUserProfile.mockReturnValue(loadedFetchable(sampleUserProfile, jest.fn())); const { getAllByRole } = renderComponent(); await waitFor(() => {}); const checkboxes = getAllByRole(/checkbox/i) as HTMLInputElement[]; @@ -82,7 +84,7 @@ describe('ProjectWorkflows', () => { /** user doesn't have its own workflow */ it('clicking show archived should hide active workflows', async () => { - mockGetUserProfile.mockResolvedValue(sampleUserProfile); + mockUseUserProfile.mockReturnValue(loadedFetchable(sampleUserProfile, jest.fn())); const { getByText, queryByText, getAllByRole } = renderComponent(); await waitFor(() => {}); const checkboxes = getAllByRole(/checkbox/i) as HTMLInputElement[]; diff --git a/packages/zapp/console/src/components/data/QueryAuthorizationObserver.tsx b/packages/zapp/console/src/components/data/QueryAuthorizationObserver.tsx index 915112fdd..7a99851f7 100644 --- a/packages/zapp/console/src/components/data/QueryAuthorizationObserver.tsx +++ b/packages/zapp/console/src/components/data/QueryAuthorizationObserver.tsx @@ -1,15 +1,17 @@ import { NotAuthorizedError } from 'errors/fetchErrors'; import * as React from 'react'; import { onlineManager, Query, useQueryClient } from 'react-query'; -import { useAPIContext } from './apiContext'; +import { useFlyteApi } from '@flyteconsole/flyte-api'; /** Watches all queries to detect a NotAuthorized error, disabling future queries * and triggering the login refresh flow. * Note: Should be placed just below the QueryClient and ApiContext providers. */ + +// TODO: narusina - move this one to flyte-api too export const QueryAuthorizationObserver: React.FC = () => { const queryCache = useQueryClient().getQueryCache(); - const apiContext = useAPIContext(); + const apiContext = useFlyteApi(); React.useEffect(() => { const unsubscribe = queryCache.subscribe((query?: Query | undefined) => { if (!query || !query.state.error) { diff --git a/packages/zapp/console/src/components/data/apiContext.ts b/packages/zapp/console/src/components/data/apiContext.ts index 8378354ba..3dca31cd1 100644 --- a/packages/zapp/console/src/components/data/apiContext.ts +++ b/packages/zapp/console/src/components/data/apiContext.ts @@ -1,4 +1,3 @@ -import { getLoginUrl } from 'models/AdminEntity/utils'; import * as CommonAPI from 'models/Common/api'; import * as ExecutionAPI from 'models/Execution/api'; import * as LaunchAPI from 'models/Launch/api'; @@ -14,12 +13,8 @@ type APIFunctions = typeof CommonAPI & typeof TaskAPI & typeof WorkflowAPI; -export interface LoginStatus { - expired: boolean; - setExpired(expired: boolean): void; -} export interface APIContextValue extends APIFunctions { - loginStatus: LoginStatus; + // use API functions only, for now } export const defaultAPIContextValue = { @@ -29,12 +24,6 @@ export const defaultAPIContextValue = { ...ProjectAPI, ...TaskAPI, ...WorkflowAPI, - loginStatus: { - expired: false, - setExpired: () => { - // do nothing - }, - }, }; /** Exposes all of the model layer api functions for use by data fetching @@ -43,26 +32,10 @@ export const defaultAPIContextValue = { */ export const APIContext = React.createContext(defaultAPIContextValue); -function useLoginStatus(): LoginStatus { - const [expired, setExpired] = React.useState(false); - - // Whenever we detect expired credentials, trigger a login redirect automatically - React.useEffect(() => { - if (expired) { - window.location.href = getLoginUrl(); - } - }, [expired]); - return { - expired, - setExpired, - }; -} - /** Creates a state object that can be used as the value for APIContext.Provider */ export function useAPIState(): APIContextValue { return { ...defaultAPIContextValue, - loginStatus: useLoginStatus(), }; } diff --git a/packages/zapp/console/src/components/hooks/useFetchableData.ts b/packages/zapp/console/src/components/hooks/useFetchableData.ts index d4a397783..d6f1d91d2 100644 --- a/packages/zapp/console/src/components/hooks/useFetchableData.ts +++ b/packages/zapp/console/src/components/hooks/useFetchableData.ts @@ -1,10 +1,10 @@ import { useMachine } from '@xstate/react'; +import { FlyteApiContextState, useFlyteApi } from '@flyteconsole/flyte-api'; import { createDebugLogger } from 'common/log'; import { CacheContext } from 'components/Cache/CacheContext'; import { ValueCache } from 'components/Cache/createCache'; import { getCacheKey } from 'components/Cache/utils'; import { defaultStateMachineConfig } from 'components/common/constants'; -import { APIContextValue, useAPIContext } from 'components/data/apiContext'; import { NotAuthorizedError } from 'errors/fetchErrors'; import { useContext, useEffect, useMemo, useRef } from 'react'; import { fetchMachine } from './fetchMachine'; @@ -33,7 +33,7 @@ function isHashableInput(value: any): value is object | string { } interface CreateFetchFnConfig { - apiContext: APIContextValue; + apiContext: FlyteApiContextState; cache: ValueCache; cacheKey?: string; debugName?: string; @@ -110,7 +110,7 @@ export function useFetchableData( const cacheKey = isHashableInput(data) ? getCacheKey(data) : undefined; const contextCacheKey = useRef(); const cache = useContext(CacheContext); - const apiContext = useAPIContext(); + const apiContext = useFlyteApi(); const fetchFn = useMemo( () => createFetchFn({ diff --git a/packages/zapp/console/src/components/hooks/useUserProfile.ts b/packages/zapp/console/src/components/hooks/useUserProfile.ts index 1259bdffe..7f68aea4f 100644 --- a/packages/zapp/console/src/components/hooks/useUserProfile.ts +++ b/packages/zapp/console/src/components/hooks/useUserProfile.ts @@ -1,14 +1,16 @@ -import { useAPIContext } from 'components/data/apiContext'; import { UserProfile } from 'models/Common/types'; +import { useFlyteApi, getAxiosApiCall } from '@flyteconsole/flyte-api'; import { useFetchableData } from './useFetchableData'; /** State hook that returns the user information if logged in, null otherwise */ export function useUserProfile() { - const { getUserProfile } = useAPIContext(); + const { getProfileUrl } = useFlyteApi(); + const profilePath = getProfileUrl(); + return useFetchableData({ debugName: 'UserProfile', defaultValue: null, - doFetch: getUserProfile, + doFetch: () => getAxiosApiCall(profilePath), useCache: true, }); } diff --git a/packages/zapp/console/src/components/hooks/useVersion.ts b/packages/zapp/console/src/components/hooks/useVersion.ts index 59aca5c06..c4531f233 100644 --- a/packages/zapp/console/src/components/hooks/useVersion.ts +++ b/packages/zapp/console/src/components/hooks/useVersion.ts @@ -1,14 +1,16 @@ -import { useAPIContext } from 'components/data/apiContext'; +import { useFlyteApi, AdminEndpoint, getAxiosApiCall } from '@flyteconsole/flyte-api'; import { GetVersionResponse } from 'models/Common/types'; import { useFetchableData } from './useFetchableData'; /** State hook that returns the version information */ function useVersion() { - const { getVersion } = useAPIContext(); + const { getAdminApiUrl } = useFlyteApi(); + const versionPath = getAdminApiUrl(AdminEndpoint.Version); + return useFetchableData({ debugName: 'Version', defaultValue: null, - doFetch: getVersion, + doFetch: () => getAxiosApiCall(versionPath), useCache: true, }); } diff --git a/packages/zapp/console/src/models/AdminEntity/transformRequestError.ts b/packages/zapp/console/src/models/AdminEntity/transformRequestError.ts index 9df4275f3..a5d6b36ae 100644 --- a/packages/zapp/console/src/models/AdminEntity/transformRequestError.ts +++ b/packages/zapp/console/src/models/AdminEntity/transformRequestError.ts @@ -8,7 +8,7 @@ function decodeErrorResponseMessage(error: AxiosError) { try { // probablly using a wrong decode type.. is there a decode type for the error message? const decodedErrorResponseMessage = decodeProtoResponse( - error.response?.data, + error.response?.data as any, Admin.RawOutputDataConfig, ); if (decodedErrorResponseMessage && decodedErrorResponseMessage.outputLocationPrefix) { diff --git a/packages/zapp/console/src/models/AdminEntity/utils.ts b/packages/zapp/console/src/models/AdminEntity/utils.ts index 780f12b2a..8a067e3ac 100644 --- a/packages/zapp/console/src/models/AdminEntity/utils.ts +++ b/packages/zapp/console/src/models/AdminEntity/utils.ts @@ -10,9 +10,6 @@ import { } from './types'; const debug = createDebugLogger('adminEntity'); -const loginEndpoint = '/login'; -const profileEndpoint = '/me'; -const redirectParam = 'redirect_url'; /** Converts a path into a full Admin API url */ export function adminApiUrl(url: string) { @@ -23,24 +20,6 @@ export function adminApiUrl(url: string) { return createLocalURL(`${apiPrefix}${finalUrl}`); } -/** Constructs a url for redirecting to the Admin login endpoint and returning - * to the current location after completing the flow. - */ -export function getLoginUrl(redirectUrl: string = window.location.href) { - const baseUrl = env.ADMIN_API_URL - ? `${env.ADMIN_API_URL}${loginEndpoint}` - : createLocalURL(loginEndpoint); - return `${baseUrl}?${redirectParam}=${redirectUrl}`; -} - -/** Constructs a URL for fetching the current user profile. */ -export function getProfileUrl() { - if (env.ADMIN_API_URL) { - return `${env.ADMIN_API_URL}${profileEndpoint}`; - } - return createLocalURL(profileEndpoint); -} - // Helper to log out the contents of a protobuf response, since the Network tab // shows binary values :-). export function logProtoResponse(url: string, data: T): T { diff --git a/packages/zapp/console/src/models/Common/api.ts b/packages/zapp/console/src/models/Common/api.ts index 1ac7ef97d..bfaefc8ee 100644 --- a/packages/zapp/console/src/models/Common/api.ts +++ b/packages/zapp/console/src/models/Common/api.ts @@ -1,21 +1,16 @@ -import axios from 'axios'; import { env } from 'common/env'; -import { log } from 'common/log'; import { Admin, Core } from 'flyteidl'; +import { getAxiosApiCall } from '@flyteconsole/flyte-api'; import { getAdminEntity } from 'models/AdminEntity/AdminEntity'; import { defaultPaginationConfig } from 'models/AdminEntity/constants'; -import { transformRequestError } from 'models/AdminEntity/transformRequestError'; import { PaginatedEntityResponse, RequestConfig } from 'models/AdminEntity/types'; -import { adminApiUrl, getProfileUrl } from 'models/AdminEntity/utils'; -import { defaultAxiosConfig, defaultSystemStatus, identifierPrefixes } from './constants'; +import { defaultSystemStatus, identifierPrefixes } from './constants'; import { - GetVersionResponse, IdentifierScope, NamedEntity, NamedEntityIdentifier, ResourceType, SystemStatus, - UserProfile, } from './types'; import { makeIdentifierPath, makeNamedEntityPath } from './utils'; @@ -97,37 +92,6 @@ export const listNamedEntities = (input: ListNamedEntitiesInput, requestConfig?: ); }; -/** Fetches the current user profile. NOTE: This will *not* fail in cases - * where the user is not logged in or the session is expired. Admin does not - * distinguish between these cases, so the profile will be `null` in both cases. - * A value of `null` indicates that a redirect to the login endpoint is needed. - */ -export const getUserProfile = async () => { - const path = getProfileUrl(); - try { - const { data } = await axios.get(path, defaultAxiosConfig); - return data; - } catch (e) { - const { message } = transformRequestError(e, path); - log.error(`Failed to fetch user profile: ${message}`); - return null; - } -}; - -/** Fetches the current admin version. - */ -export const getVersion = async () => { - const path = adminApiUrl('/version'); - try { - const { data } = await axios.get(path, defaultAxiosConfig); - return data; - } catch (e) { - const { message } = transformRequestError(e, path); - log.error(`Failed to fetch version: ${message}`); - return null; - } -}; - /** If env.STATUS_URL is set, will issue a fetch to retrieve the current system * status. If not, will resolve immediately with a default value indicating * normal system status. @@ -138,11 +102,10 @@ export const getSystemStatus = async () => { } const path = env.STATUS_URL; - try { - const { data } = await axios.get(path, defaultAxiosConfig); - return data; - } catch (e) { - const { message } = transformRequestError(e, path); - throw new Error(`Failed to fetch system status: ${message}`); + const result = await getAxiosApiCall(path); + if (!result) { + throw new Error('Failed to fetch system status'); } + + return result; }; diff --git a/packages/zapp/console/src/models/Common/constants.ts b/packages/zapp/console/src/models/Common/constants.ts index 6962189fe..f011b0727 100644 --- a/packages/zapp/console/src/models/Common/constants.ts +++ b/packages/zapp/console/src/models/Common/constants.ts @@ -1,13 +1,5 @@ -import axios, { AxiosRequestConfig, AxiosTransformer } from 'axios'; -import * as camelcaseKeys from 'camelcase-keys'; -import * as snakecaseKeys from 'snakecase-keys'; import { LiteralMapBlob, ResourceType, SystemStatus } from './types'; -// recommended util.d.ts implementation -const isObject = (value: unknown): boolean => { - return value !== null && typeof value === 'object'; -}; - export const endpointPrefixes = { execution: '/executions', launchPlan: '/launch_plans', @@ -36,18 +28,4 @@ export const emptyLiteralMapBlob: LiteralMapBlob = { values: { literals: {} }, }; -/** Config object that can be used for requests that are not sent to - * the Admin entity API (`/api/v1/...`), such as the `/me` endpoint. This config - * ensures that requests/responses are correctly converted and that cookies are - * included. - */ -export const defaultAxiosConfig: AxiosRequestConfig = { - transformRequest: [ - (data: any) => (isObject(data) ? snakecaseKeys(data) : data), - ...(axios.defaults.transformRequest as AxiosTransformer[]), - ], - transformResponse: [...(axios.defaults.transformResponse as AxiosTransformer[]), camelcaseKeys], - withCredentials: true, -}; - export const defaultSystemStatus: SystemStatus = { status: 'normal' }; diff --git a/yarn.lock b/yarn.lock index 238e49d19..d37c27cdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5409,12 +5409,13 @@ axios-mock-adapter@^1.16.0: fast-deep-equal "^3.1.3" is-buffer "^2.0.3" -axios@^0.21.2: - version "0.21.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.2.tgz#21297d5084b2aeeb422f5d38e7be4fbb82239017" - integrity sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg== +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== dependencies: - follow-redirects "^1.14.0" + follow-redirects "^1.14.9" + form-data "^4.0.0" axobject-query@^2.2.0: version "2.2.0" @@ -6256,7 +6257,7 @@ camelcase-keys@^4.0.0: map-obj "^2.0.0" quick-lru "^1.0.0" -camelcase-keys@^6.1.1, camelcase-keys@^6.2.2: +camelcase-keys@^6.2.2: version "6.2.2" resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0" integrity sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg== @@ -6265,6 +6266,16 @@ camelcase-keys@^6.1.1, camelcase-keys@^6.2.2: map-obj "^4.0.0" quick-lru "^4.0.1" +camelcase-keys@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-7.0.2.tgz#d048d8c69448745bb0de6fc4c1c52a30dfbe7252" + integrity sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg== + dependencies: + camelcase "^6.3.0" + map-obj "^4.1.0" + quick-lru "^5.1.1" + type-fest "^1.2.1" + camelcase@^4.0.0, camelcase@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" @@ -6280,7 +6291,7 @@ camelcase@^6.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== -camelcase@^6.2.0: +camelcase@^6.2.0, camelcase@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== @@ -9607,11 +9618,16 @@ flux@^4.0.1: fbemitter "^3.0.0" fbjs "^3.0.1" -follow-redirects@^1.0.0, follow-redirects@^1.14.0: +follow-redirects@^1.0.0: version "1.14.8" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc" integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA== +follow-redirects@^1.14.9: + version "1.15.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.0.tgz#06441868281c86d0dda4ad8bdaead2d02dca89d4" + integrity sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -9688,6 +9704,15 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -15744,6 +15769,11 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + qw@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/qw/-/qw-1.0.1.tgz#efbfdc740f9ad054304426acb183412cc8b996d4" @@ -17355,13 +17385,22 @@ smart-buffer@^4.1.0: resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.1.0.tgz#91605c25d91652f4661ea69ccf45f1b331ca21ba" integrity sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw== -snakecase-keys@^3.1.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/snakecase-keys/-/snakecase-keys-3.2.1.tgz#ce5d1a2de8a93c939d7992f76f2743aa59f3d5ad" - integrity sha512-CjU5pyRfwOtaOITYv5C8DzpZ8XA/ieRsDpr93HI2r6e3YInC6moZpSQbmUtg8cTk58tq2x3jcG2gv+p1IZGmMA== +snake-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" + integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" + +snakecase-keys@^5.4.2: + version "5.4.2" + resolved "https://registry.yarnpkg.com/snakecase-keys/-/snakecase-keys-5.4.2.tgz#b886f77c9a5fc347b04bd0ad1a05213190e60b9e" + integrity sha512-6mzBNP9t1ghArxcaCxZNGIJewE9qyH+eUQIGba9I7MJcZoV4WX4fM2Y+/K9+BGHCFB94ZASfoC3F44UxLXce+A== dependencies: map-obj "^4.1.0" - to-snake-case "^1.0.0" + snake-case "^3.0.4" + type-fest "^2.5.2" snapdragon-node@^2.0.1: version "2.1.1" @@ -18347,11 +18386,6 @@ to-ico@^1.1.5: parse-png "^1.0.0" resize-img "^1.1.0" -to-no-case@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a" - integrity sha1-xyKQcWTvaxeBMsjmmTAhLRtKoWo= - to-object-path@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" @@ -18384,20 +18418,6 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" -to-snake-case@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/to-snake-case/-/to-snake-case-1.0.0.tgz#ce746913897946019a87e62edfaeaea4c608ab8c" - integrity sha1-znRpE4l5RgGah+Yu366upMYIq4w= - dependencies: - to-space-case "^1.0.0" - -to-space-case@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/to-space-case/-/to-space-case-1.0.0.tgz#b052daafb1b2b29dc770cea0163e5ec0ebc9fc17" - integrity sha1-sFLar7Gysp3HcM6gFj5ewOvJ/Bc= - dependencies: - to-no-case "^1.0.0" - toggle-selection@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32" @@ -18618,6 +18638,16 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +type-fest@^1.2.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" + integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== + +type-fest@^2.5.2: + version "2.12.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.12.2.tgz#80a53614e6b9b475eb9077472fb7498dc7aa51d0" + integrity sha512-qt6ylCGpLjZ7AaODxbpyBZSs9fCI9SkL3Z9q2oxMBQhs/uyY+VD8jHA8ULCGmWQJlBgqvO3EJeAngOHD8zQCrQ== + type-is@~1.6.17, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"