Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

URO-206 getting started with orcidlink integration #206

Merged
merged 8 commits into from
May 2, 2024
Merged
6 changes: 6 additions & 0 deletions src/app/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
useFilteredParams,
usePageTracking,
} from '../common/hooks';
import ORCIDLinkFeature from '../features/orcidlink';

export const LOGIN_ROUTE = '/legacy/login';
export const ROOT_REDIRECT_ROUTE = '/narratives';
Expand Down Expand Up @@ -77,6 +78,11 @@ const Routes: FC = () => {
<Route path="*" element={<PageNotFound />} />
</Route>

{/* orcidlink */}
<Route path="/orcidlink">
<Route index element={<Authed element={<ORCIDLinkFeature />} />} />
</Route>

{/* IFrame Fallback Routes */}
<Route path="/fallback">
<Route
Expand Down
2 changes: 2 additions & 0 deletions src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import collections from '../features/collections/collectionsSlice';
import icons from '../features/icons/iconSlice';
import layout from '../features/layout/layoutSlice';
import navigator from '../features/navigator/navigatorSlice';
import orcidlink from '../features/orcidlink/orcidlinkSlice';
import params from '../features/params/paramsSlice';
import profile from '../features/profile/profileSlice';

Expand All @@ -16,6 +17,7 @@ const everyReducer = combineReducers({
navigator,
params,
profile,
orcidlink,
[baseApi.reducerPath]: baseApi.reducer,
});

Expand Down
93 changes: 93 additions & 0 deletions src/common/api/orcidlinkAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { baseApi } from '.';
import { jsonRpcService } from './utils/serviceHelpers';

// orcidlink system types

export interface ORCIDAuthPublic {
expires_in: number;
name: string;
orcid: string;
scope: string;
}

export interface LinkRecordPublic {
created_at: number;
expires_at: number;
retires_at: number;
username: string;
orcid_auth: ORCIDAuthPublic;
}

// Method types

export interface StatusResult {
status: string;
current_time: number;
start_time: number;
}

export interface InfoResult {
'service-description': {
name: string;
title: string;
version: string;
};
}

// is-linked

export interface IsLinkedParams {
username: string;
}

export type IsLinkedResult = boolean;

// owner-link
export interface OwnerLinkParams {
username: string;
}

export type OwnerLinkResult = LinkRecordPublic;

// It is mostly a JSONRPC 2.0 service, although the oauth flow is rest-ish.
const orcidlinkService = jsonRpcService({
url: '/services/orcidlink/api/v1',
version: '2.0',
});

/**
* orcidlink service api
*/
export const orcidlinkAPI = baseApi
.enhanceEndpoints({ addTagTypes: ['ORCIDLink'] })
.injectEndpoints({
endpoints: ({ query }) => ({
orcidlinkStatus: query<StatusResult, {}>({
query: () => {
return orcidlinkService({
method: 'status',
});
},
}),
orcidlinkIsLinked: query<IsLinkedResult, IsLinkedParams>({
query: ({ username }) => {
return orcidlinkService({
method: 'is-linked',
params: {
username,
},
});
},
}),
orcidlinkOwnerLink: query<OwnerLinkResult, OwnerLinkParams>({
query: ({ username }) => {
return orcidlinkService({
method: 'owner-link',
params: {
username,
},
});
},
}),
}),
});
49 changes: 41 additions & 8 deletions src/common/api/utils/kbaseBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '@reduxjs/toolkit/query/react';
import { RootState } from '../../../app/store';
import { serviceWizardApi } from '../serviceWizardApi';
import { KBaseBaseQueryError, isJsonRpcError } from './common';
import { isJsonRpcError, KBaseBaseQueryError } from './common';

export interface DynamicService {
name: string;
Expand All @@ -16,16 +16,33 @@ export interface DynamicService {

export interface StaticService {
url: string;
version?: '1.1' | '2.0';
}

export interface JsonRpcQueryArgs {
apiType: 'JsonRpc';
service: StaticService | DynamicService;
method: string;
params: unknown;
params?: unknown;
fetchArgs?: FetchArgs;
}

export interface JSONRPC11Body {
version: string;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: You can hardcode the version string here, or use a template string type if we want to support minor versions

It will make discrimination (type guarding) between this type and the 2.0 type easier downstream

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In terms of versions, as far as I know there are only two relevant JSON-RPC versions for us: 1.1 and 2.0. Also, 1.1 is not technically official (although it is used in many places in KBase). That being said, official documents describing their operation do exist, and I suggest that we hew as close to them as possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, agreed @dakotablair, although our JSON-RPC 1.1 is not the same as the working draft that inspired it. So our reference for that should be our implementation.

@dakotablair Yes, the version could be hardcoded for sure; I did have another set of changes that addressed this more fundamentally, but more loc.

id: string;
method: string;
params?: unknown;
}

export interface JSONRPC20Body {
jsonrpc: string;
id: string;
method: string;
params?: unknown;
}

export type JSONRPCBody = JSONRPC11Body | JSONRPC20Body;

export interface HttpQueryArgs extends FetchArgs {
apiType: 'Http';
service: StaticService;
Expand Down Expand Up @@ -172,19 +189,35 @@ export const kbaseBaseQuery: (
// Generate JsonRpc request id
const reqId = Math.random().toString();

const body = ((): JSONRPCBody => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

switch (kbQueryArgs.service.version || '1.1') {
case '1.1':
return {
version: '1.1',
id: reqId,
method: kbQueryArgs.method,
};
case '2.0':
return {
jsonrpc: '2.0',
id: reqId,
method: kbQueryArgs.method,
};
}
})();

if (kbQueryArgs.params) {
body.params = kbQueryArgs.params;
}

// generate request body
const fetchArgs = {
url: new URL(
kbQueryArgs.service.url,
fetchBaseQueryArgs.baseUrl
).toString(),
method: 'POST',
body: {
version: '1.1', // TODO: conditionally implement JsonRpc 2.0
id: reqId,
method: kbQueryArgs.method,
params: kbQueryArgs.params,
},
body,
...kbQueryArgs.fetchArgs, // Allow overriding JsonRpc defaults
};

Expand Down
14 changes: 7 additions & 7 deletions src/features/layout/LeftNavBar.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
/* LeftNavBar */
import { FontAwesomeIcon as FAIcon } from '@fortawesome/react-fontawesome';
import {
faCompass,
faUsers,
faBook,
faBullhorn,
faCompass,
faIdCard,
faLayerGroup,
faSearch,
faSuitcase,
faIdCard,
faBullhorn,
faUsers,
IconDefinition,
faLayerGroup,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon as FAIcon } from '@fortawesome/react-fontawesome';
import { FC } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { getFeedsUnseenCount } from '../../common/api/feedsService';
import { useAppSelector } from '../../common/hooks';
import { authMe, authToken } from '../auth/authSlice';
import { useAuthMe } from '../auth/hooks';
import classes from './LeftNavBar.module.scss';
import { getFeedsUnseenCount } from '../../common/api/feedsService';

const LeftNavBar: FC = () => {
const token = useAppSelector(authToken);
Expand Down
14 changes: 7 additions & 7 deletions src/features/layout/TopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { FontAwesomeIcon as FAIcon } from '@fortawesome/react-fontawesome';
import {
faBars,
faEnvelope,
Expand All @@ -17,20 +16,21 @@ import {
faUser,
faWrench,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon as FAIcon } from '@fortawesome/react-fontawesome';
import { FC, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';

import { toast } from 'react-hot-toast';
import { Link } from 'react-router-dom';
import { resetStateAction } from '../../app/store';
import { revokeToken } from '../../common/api/authService';
import { getUserProfile } from '../../common/api/userProfileApi';
import logo from '../../common/assets/logo/46_square.png';
import { Dropdown } from '../../common/components';
import { useAppDispatch, useAppSelector } from '../../common/hooks';
import { authUsername, setAuth } from '../auth/authSlice';
import classes from './TopBar.module.scss';
import { Link } from 'react-router-dom';
import { getUserProfile } from '../../common/api/userProfileApi';
import { revokeToken } from '../../common/api/authService';
import { toast } from 'react-hot-toast';
import { noOp } from '../common';
import { resetStateAction } from '../../app/store';
import classes from './TopBar.module.scss';

export default function TopBar() {
const username = useAppSelector(authUsername);
Expand Down
36 changes: 36 additions & 0 deletions src/features/orcidlink/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Alert from '@mui/material/Alert';
import { SerializedError } from '@reduxjs/toolkit';
import { KBaseBaseQueryError } from '../../common/api/utils/common';
import styles from './orcidlink.module.scss';

export interface ErrorMessageProps {
error: KBaseBaseQueryError | SerializedError;
}

export default function ErrorMessage({ error }: ErrorMessageProps) {
const message = (() => {
if ('status' in error) {
switch (error.status) {
case 'JSONRPC_ERROR':
return error.data.error.message;
case 'FETCH_ERROR':
return 'Fetch Error';
case 'CUSTOM_ERROR':
return error.error;
case 'PARSING_ERROR':
return error.error;
case 'TIMEOUT_ERROR':
return error.error;
}
} else {
return error.message || 'Unknown Error';
}
Comment on lines +11 to +27
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check out the parseError utility for this functionality. It may need to be updated to support exactly what you want, but will be reusable

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/common/api/utils/parseError.ts

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We tend to avoid switch statements. My personal preference is that if we are going to use them then we should make use of their special features, in particular, fall-through and default cases. For example:

switch (error.status) {
    case 'JSONRPC_ERROR':
        return error.data.error.message;
    case 'FETCH_ERROR':
        return 'Fetch Error';
    case 'CUSTOM_ERROR':
    case 'PARSING_ERROR':
    case 'TIMEOUT_ERROR':
    default:
        return error.error;
}

I think many other switch users write code as you have to avoid confusion about these features, and it is this situation that has led us to avoid them on this project. There is another open PR with what I think is a good alternative.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can abide by the codebase conventions. Yeah, first pass was not to use fallthrough because I hadn't explored those error conditions to see if there was more specialized information before; e.t. timeout in theory should have information about the timeout like the timeout limit and actual elapsed time. But, like I said, I didn't have time on this batch to create a test to exercise the conditions.

})();
return (
<div className={styles['error-message']}>
<Alert severity="error" title="Error">
{message}
</Alert>
</div>
);
}
73 changes: 73 additions & 0 deletions src/features/orcidlink/Home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Alert, AlertTitle, CircularProgress } from '@mui/material';
import { SerializedError } from '@reduxjs/toolkit';
import { orcidlinkAPI } from '../../common/api/orcidlinkAPI';
import { KBaseBaseQueryError } from '../../common/api/utils/common';
import { useAppSelector } from '../../common/hooks';
import { authUsername } from '../auth/authSlice';
import { usePageTitle } from '../layout/layoutSlice';
import ErrorMessage from './ErrorMessage';
import Linked from './Linked';
import styles from './orcidlink.module.scss';
import Unlinked from './Unlinked';

export default function Home() {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const username = useAppSelector(authUsername)!;

function renderLoading(title: string, description: string) {
return (
<div className={styles.loading}>
<Alert icon={<CircularProgress size="1rem" />}>
<AlertTitle>
<span className={styles['loading-title']}>{title}</span>
</AlertTitle>
<p>{description}</p>
</Alert>
</div>
);
}

function renderError(error: KBaseBaseQueryError | SerializedError) {
return <ErrorMessage error={error} />;
}

usePageTitle('KBase ORCID Link');

const {
data: isLInked,
error,
isLoading,
isError,
isFetching,
isSuccess,
isUninitialized,
} = orcidlinkAPI.useOrcidlinkIsLinkedQuery({ username });

if (isUninitialized) {
return renderLoading('Uninitialized...', 'Loading the ORCID Link App...');
} else if (isLoading) {
return renderLoading('Loading...', 'Loading the ORCID Link App...');
} else if (isFetching) {
return renderLoading('Fetching...', 'Loading the ORCID Link App...');
} else if (isError) {
return renderError(error);
} else if (isSuccess) {
if (isLInked) {
return (
<div className={styles.box}>
<Linked />
</div>
);
}
return (
<div className={styles.box}>
<Unlinked />
</div>
);
} else {
return renderError({
status: 'CUSTOM_ERROR',
error: 'Unknown State',
});
}
}
Loading
Loading