diff --git a/src/features/orcidlink/common/Loading.test.tsx b/src/features/orcidlink/common/Loading.test.tsx
new file mode 100644
index 00000000..c2b1d7a3
--- /dev/null
+++ b/src/features/orcidlink/common/Loading.test.tsx
@@ -0,0 +1,31 @@
+import { render } from '@testing-library/react';
+import Loading from './Loading';
+
+const TEST_TITLE = 'Test Loading Title';
+const TEST_MESSAGE = 'Test Loading Message';
+
+describe('The Loading Component', () => {
+ it('renders minimal correctly', () => {
+ const { container } = render(
);
+
+ expect(container).toHaveTextContent(TEST_TITLE);
+ });
+
+ it('renders with all props correctly', () => {
+ const { container } = render(
+
+ );
+
+ expect(container).toHaveTextContent(TEST_TITLE);
+ expect(container).toHaveTextContent(TEST_MESSAGE);
+ });
+
+ it('renders with children rather than message prop correctly', () => {
+ const { container } = render(
+
{TEST_MESSAGE}
+ );
+
+ expect(container).toHaveTextContent(TEST_TITLE);
+ expect(container).toHaveTextContent(TEST_MESSAGE);
+ });
+});
diff --git a/src/features/orcidlink/common/Loading.tsx b/src/features/orcidlink/common/Loading.tsx
new file mode 100644
index 00000000..f208b61b
--- /dev/null
+++ b/src/features/orcidlink/common/Loading.tsx
@@ -0,0 +1,33 @@
+/**
+ * A simple component to express a "loading" state.
+ *
+ * Although dubbed "loading", there is nothing specific to "loading" in this
+ * component, other than the name. It is more of a generalized async process
+ * feedback component, based on the MUI Alert component.
+ */
+import { Alert, AlertTitle, CircularProgress } from '@mui/material';
+import { PropsWithChildren } from 'react';
+
+export interface LoadingProps extends PropsWithChildren {
+ title: string;
+ message?: string;
+}
+
+export default function Loading({ title, message, children }: LoadingProps) {
+ if (message || children) {
+ return (
+
}>
+
+ {title}
+
+ {message ?
{message}
: children}
+
+ );
+ }
+
+ return (
+
}>
+
{title}
+
+ );
+}
diff --git a/src/features/orcidlink/common/ORCIDId.module.scss b/src/features/orcidlink/common/ORCIDId.module.scss
new file mode 100644
index 00000000..60657033
--- /dev/null
+++ b/src/features/orcidlink/common/ORCIDId.module.scss
@@ -0,0 +1,10 @@
+.main {
+ align-items: baseline;
+ display: inline-flex;
+ flex-direction: row;
+}
+
+.icon {
+ height: 1rem;
+ margin-right: 0.25em;
+}
diff --git a/src/features/orcidlink/common/ORCIDId.test.tsx b/src/features/orcidlink/common/ORCIDId.test.tsx
new file mode 100644
index 00000000..499242b5
--- /dev/null
+++ b/src/features/orcidlink/common/ORCIDId.test.tsx
@@ -0,0 +1,11 @@
+import { render } from '@testing-library/react';
+import ORCIDId from './ORCIDId';
+
+describe('The ORCIDId Component', () => {
+ it('renders minimal correctly', () => {
+ const ID = 'foo';
+ const { container } = render(
);
+
+ expect(container).toHaveTextContent(ID);
+ });
+});
diff --git a/src/features/orcidlink/common/ORCIDId.tsx b/src/features/orcidlink/common/ORCIDId.tsx
new file mode 100644
index 00000000..4c0fc595
--- /dev/null
+++ b/src/features/orcidlink/common/ORCIDId.tsx
@@ -0,0 +1,19 @@
+/**
+ * A simple component to display an anchor link to an ORCID profile in the form recommended by ORCID.
+ *
+ */
+import { ORCID_ICON_URL } from '../constants';
+import styles from './ORCIDId.module.scss';
+
+export interface ORCIDIdProps {
+ orcidId: string;
+}
+
+export default function ORCIDId({ orcidId }: ORCIDIdProps) {
+ return (
+
+
+ {orcidId}
+
+ );
+}
diff --git a/src/features/orcidlink/common/Scopes.tsx b/src/features/orcidlink/common/Scopes.tsx
index 2116b275..321a5b75 100644
--- a/src/features/orcidlink/common/Scopes.tsx
+++ b/src/features/orcidlink/common/Scopes.tsx
@@ -16,7 +16,6 @@ import {
Typography,
} from '@mui/material';
import { ORCIDScope, ScopeHelp, SCOPE_HELP } from '../constants';
-import styles from '../orcidlink.module.scss';
export interface ScopesProps {
scopes: string;
@@ -62,13 +61,13 @@ export default function Scopes({ scopes }: ScopesProps) {
{orcid.label}
- ORCID® Policy
+ ORCID® Policy
{orcid.tooltip}
- How KBase Uses It
+ How KBase Uses It
{help.map((item, index) => {
return {item}
;
})}
- See Also
+ See Also
{seeAlso.map(({ url, label }, index) => {
return (
diff --git a/src/features/orcidlink/common/api/JSONRPC20.test.ts b/src/features/orcidlink/common/api/JSONRPC20.test.ts
new file mode 100644
index 00000000..91503686
--- /dev/null
+++ b/src/features/orcidlink/common/api/JSONRPC20.test.ts
@@ -0,0 +1,682 @@
+import fetchMock, { MockResponseInit } from 'jest-fetch-mock';
+import { FetchMock } from 'jest-fetch-mock/types';
+import {
+ APIOverrides,
+ jsonrpc20_response,
+ makeBatchResponseObject,
+} from '../../test/jsonrpc20ServiceMock';
+
+import {
+ assertJSONRPC20Response,
+ assertPlainObject,
+ batchResultOrThrow,
+ JSONRPC20Client,
+ JSONRPC20ResponseObject,
+ notIn,
+ resultOrThrow,
+} from './JSONRPC20';
+
+// Simulates latency in the request response; helps with detecting state driven
+// by api calls which may not be detectable with tests if the mock rpc handling
+// is too fast.
+const RPC_DELAY = 300;
+
+// Used in at least one test which simulates a request timeout.
+const RPC_DELAY_TIMEOUT = 2000;
+
+// The timeout used for most RPC calls (other than those to test timeout behavior)
+const RPC_CALL_TIMEOUT = 1000;
+
+describe('The JSONRPC20 assertPlainObject function', () => {
+ it('correctly identifies a plain object', () => {
+ const testCases = [
+ {},
+ { foo: 'bar' },
+ { bar: 123 },
+ { foo: { bar: { baz: 'buzz' } } },
+ ];
+
+ for (const testCase of testCases) {
+ expect(() => {
+ assertPlainObject(testCase);
+ }).not.toThrow();
+ }
+ });
+ it('correctly identifies a non-plain object', () => {
+ const testCases = [new Date(), new Set(), null];
+
+ for (const testCase of testCases) {
+ expect(() => {
+ assertPlainObject(testCase);
+ }).toThrow();
+ }
+ });
+});
+
+describe('The JSONRPC20 diff function', () => {
+ it('correctly identifies extra keys', () => {
+ const testCases: Array<{
+ params: [Array, Array];
+ expected: Array;
+ }> = [
+ {
+ params: [
+ [1, 2, 3],
+ [1, 2, 3],
+ ],
+ expected: [],
+ },
+
+ {
+ params: [
+ [1, 2, 3, 4, 5, 6],
+ [1, 2, 3],
+ ],
+ expected: [4, 5, 6],
+ },
+ ];
+
+ for (const { params, expected } of testCases) {
+ expect(notIn(...params)).toEqual(expected);
+ }
+ });
+});
+
+describe('The JSONRPC20 assertJSONRPC20Response function', () => {
+ it('correctly identifies a valid JSON-RPC 2.0 response', () => {
+ const testCases: Array = [
+ {
+ jsonrpc: '2.0',
+ id: '123',
+ result: null,
+ },
+
+ {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'foo',
+ },
+ {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 123,
+ message: 'an error',
+ },
+ },
+ {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 123,
+ message: 'an error',
+ data: { some: 'details' },
+ },
+ },
+ ];
+
+ for (const testCase of testCases) {
+ expect(() => {
+ assertJSONRPC20Response(testCase);
+ }).not.toThrow();
+ }
+ });
+
+ it('correctly identifies an invalid JSON-RPC 2.0 response', () => {
+ const testCases: Array<{ param: unknown; expected: string }> = [
+ { param: 'x', expected: 'JSON-RPC 2.0 response must be an object' },
+ {
+ param: null,
+ expected: 'JSON-RPC 2.0 response must be a non-null object',
+ },
+ {
+ param: new Date(),
+ expected: 'JSON-RPC 2.0 response must be a plain object',
+ },
+ {
+ param: {
+ jsonrpc: '2.0',
+ id: '123',
+ },
+ expected:
+ 'JSON-RPC 2.0 response must include either "result" or "error"',
+ },
+ {
+ param: {
+ jsonrpc: '2.0',
+ result: null,
+ },
+ expected: 'JSON-RPC 2.0 response must have the "id" property',
+ },
+ {
+ param: {
+ id: '123',
+ result: null,
+ },
+ expected: 'JSON-RPC 2.0 response must have the "jsonrpc" property',
+ },
+ {
+ param: {
+ jsonrpc: 'X',
+ id: '123',
+ result: null,
+ },
+ expected:
+ 'JSON-RPC 2.0 response "jsonrpc" property must be the string "2.0"',
+ },
+ {
+ param: {
+ jsonrpc: '2.0',
+ id: ['x'],
+ result: null,
+ },
+ expected:
+ 'JSON-RPC 2.0 response "id" property must be a string, number or null',
+ },
+ {
+ param: {
+ jsonrpc: '2.0',
+ id: '123',
+ error: 'foo',
+ },
+ expected:
+ 'JSON-RPC 2.0 response "error" property must be a plain object',
+ },
+ {
+ param: {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 123,
+ },
+ },
+ expected:
+ 'JSON-RPC 2.0 response "error" property must have a "message" property',
+ },
+ {
+ param: {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ message: 'an error',
+ },
+ },
+ expected:
+ 'JSON-RPC 2.0 response "error" property must have a "code" property',
+ },
+ {
+ param: {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ foo: 123,
+ bar: 'baz',
+ },
+ },
+ expected:
+ 'JSON-RPC 2.0 response "error" property has extra keys: foo, bar',
+ },
+ {
+ param: {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 'foo',
+ message: 'bar',
+ },
+ },
+ expected:
+ 'JSON-RPC 2.0 response "error.code" property must be an integer',
+ },
+ {
+ param: {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 123,
+ message: 456,
+ },
+ },
+ expected:
+ 'JSON-RPC 2.0 response "error.message" property must be an string',
+ },
+ ];
+
+ for (const { param, expected } of testCases) {
+ expect(() => {
+ assertJSONRPC20Response(param);
+ }).toThrowError(expected);
+ }
+ });
+});
+
+describe('The JSONRPC20 resultOrThrow function works as expected', () => {
+ it('simply returns the result if found', () => {
+ const testCase: JSONRPC20ResponseObject = {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'fuzz',
+ };
+ expect(resultOrThrow(testCase)).toEqual(testCase.result);
+ });
+
+ it('throws if an error is returned', () => {
+ const testCase: JSONRPC20ResponseObject = {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 123,
+ message: 'An Error',
+ },
+ };
+ expect(() => {
+ resultOrThrow(testCase);
+ }).toThrow('An Error');
+ });
+});
+
+describe('The JSONRPC20 batchResultOrThrow function works as expected', () => {
+ it('simply returns the result if found', () => {
+ const responseResult: JSONRPC20ResponseObject = {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'fuzz',
+ };
+ const testCase: Array = [responseResult];
+ expect(batchResultOrThrow(testCase)).toEqual([responseResult.result]);
+ });
+
+ it('throws if an error is returned', () => {
+ const testCase: Array = [
+ {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 123,
+ message: 'An Error',
+ },
+ },
+ ];
+ expect(() => {
+ batchResultOrThrow(testCase);
+ }).toThrow('An Error');
+ });
+});
+
+async function pause(duration: number) {
+ await new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(null);
+ }, duration);
+ });
+}
+
+/**
+ *
+ * @param request The mock request
+ * @param method The rpc method
+ * @param params The rpc params
+ * @returns A JSON-RPC 2.0 response object
+ */
+async function jsonrpc20MethodResponse(
+ request: Request,
+ method: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ _params: any
+): Promise {
+ switch (method) {
+ case 'foo':
+ return {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'fuzz',
+ };
+ case 'bar': {
+ return {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'buzz',
+ };
+ }
+ case 'baz': {
+ if (request.headers.get('authorization') === 'my_token') {
+ return {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'is authorized',
+ };
+ } else {
+ return {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 123,
+ message: 'Not Authorized',
+ data: {
+ foo: 'bar',
+ },
+ },
+ };
+ }
+ }
+ case 'error': {
+ return {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 123,
+ message: 'An Error',
+ data: {
+ foo: 'bar',
+ },
+ },
+ };
+ }
+ case 'timeout': {
+ await pause(RPC_DELAY_TIMEOUT);
+ return {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'fuzz',
+ };
+ }
+
+ default:
+ throw new Error('case not handled');
+ }
+}
+
+async function jsonrpc20Response(
+ request: Request,
+ method: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ params: any
+) {
+ switch (method) {
+ case 'not_json':
+ return {
+ body: 'foo',
+ status: 200,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ };
+ default: {
+ const response = await jsonrpc20MethodResponse(request, method, params);
+ return jsonrpc20_response(response);
+ }
+ }
+}
+
+export function makeJSONRPC20Server(overrides: APIOverrides = {}) {
+ return fetchMock.mockResponse(
+ async (request): Promise => {
+ const { pathname } = new URL(request.url);
+ // put a little delay in here so that we have a better
+ // chance of catching temporary conditions, like loading.
+ await pause(RPC_DELAY);
+ switch (pathname) {
+ // Mocks for the orcidlink api
+ case '/services/foo': {
+ if (request.method !== 'POST') {
+ return '';
+ }
+ const body = await request.json();
+
+ if (body instanceof Array) {
+ // batch case; normal request wrapped in an array; response array
+ // mirrors request.
+ const responses = await Promise.all(
+ body.map((rpc) => {
+ const method = rpc['method'];
+ const params = rpc['params'];
+ return jsonrpc20MethodResponse(request, method, params);
+ })
+ );
+ return makeBatchResponseObject(responses);
+ } else {
+ // single request
+ const method = body['method'];
+ const params = body['params'];
+ return jsonrpc20Response(request, method, params);
+ }
+ }
+ case '/services/bad_batch': {
+ return jsonrpc20Response(request, 'foo', {});
+ }
+ default:
+ throw new Error('case not handled');
+ }
+ }
+ );
+}
+
+describe('The JSONRPC20 client', () => {
+ let mockService: FetchMock;
+
+ beforeEach(() => {
+ fetchMock.enableMocks();
+ fetchMock.doMock();
+ mockService = makeJSONRPC20Server();
+ });
+ afterEach(() => {
+ mockService.mockClear();
+ fetchMock.disableMocks();
+ });
+
+ it('correctly invokes simple POST endpoint', async () => {
+ const rpc = {
+ jsonrpc: '2.0',
+ id: '123',
+ method: 'foo',
+ params: {
+ bar: 'baz',
+ },
+ };
+ const expected = {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'fuzz',
+ };
+ const headers = new Headers();
+ headers.set('content-type', 'application/json');
+ headers.set('accept', 'application/json');
+
+ // use a timeout detection duration tht is 1/2 of the testing delay used to
+ // force timeout.
+ const timeout = RPC_DELAY_TIMEOUT / 2;
+ const controller = new AbortController();
+ const timeoutTimer = window.setTimeout(() => {
+ controller.abort('Timeout');
+ }, timeout);
+ // const expected = { baz: 'buzzer' }
+ const response = await fetch('http://example.com/services/foo', {
+ method: 'POST',
+ body: JSON.stringify(rpc),
+ headers,
+ mode: 'cors',
+ signal: controller.signal,
+ });
+ clearTimeout(timeoutTimer);
+ const result = await response.json();
+ expect(result).toEqual(expected);
+ });
+
+ it('correctly invokes fictitious service', async () => {
+ const client = new JSONRPC20Client({
+ url: 'http://example.com/services/foo',
+ timeout: RPC_DELAY_TIMEOUT,
+ });
+
+ const expected = {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'fuzz',
+ };
+
+ const result = await client.callMethod('foo', { baz: 'buzz' });
+ expect(result).toEqual(expected);
+ });
+
+ it('correctly invokes fictitious service with a batch request', async () => {
+ const client = new JSONRPC20Client({
+ url: 'http://example.com/services/foo',
+ timeout: RPC_CALL_TIMEOUT,
+ });
+
+ const expected = [
+ {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'fuzz',
+ },
+ {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'buzz',
+ },
+ ];
+
+ const result = await client.callBatch([
+ { method: 'foo', params: { baz: 'buzz' } },
+ { method: 'bar', params: { baz: 'buzz' } },
+ ]);
+ expect(result).toEqual(expected);
+ });
+
+ it('correctly invokes fictitious service 2', async () => {
+ const client = new JSONRPC20Client({
+ url: 'http://example.com/services/foo',
+ timeout: RPC_CALL_TIMEOUT,
+ });
+
+ const expected = {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'buzz',
+ };
+
+ const result = await client.callMethod('bar', { baz: 'buzz' });
+ expect(result).toEqual(expected);
+ });
+
+ it('correctly invokes method with authorization', async () => {
+ const client = new JSONRPC20Client({
+ url: 'http://example.com/services/foo',
+ timeout: RPC_CALL_TIMEOUT,
+ token: 'my_token',
+ });
+
+ const expected = {
+ jsonrpc: '2.0',
+ id: '123',
+ result: 'is authorized',
+ };
+
+ const result = await client.callMethod('baz', { baz: 'buzz' });
+ expect(result).toEqual(expected);
+ });
+
+ it('correctly invokes method which returns an error', async () => {
+ const client = new JSONRPC20Client({
+ url: 'http://example.com/services/foo',
+ timeout: RPC_CALL_TIMEOUT,
+ });
+
+ const expected = {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 123,
+ message: 'An Error',
+ data: {
+ foo: 'bar',
+ },
+ },
+ };
+
+ const result = await client.callMethod('error', { baz: 'buzz' });
+ expect(result).toEqual(expected);
+ });
+
+ it('correctly invokes fictitious service with a batch request which returns an error', async () => {
+ const client = new JSONRPC20Client({
+ url: 'http://example.com/services/foo',
+ timeout: RPC_CALL_TIMEOUT,
+ });
+
+ const expected = [
+ {
+ jsonrpc: '2.0',
+ id: '123',
+ error: {
+ code: 123,
+ message: 'An Error',
+ data: {
+ foo: 'bar',
+ },
+ },
+ },
+ ];
+
+ const result = await client.callBatch([
+ { method: 'error', params: { baz: 'buzz' } },
+ ]);
+ expect(result).toEqual(expected);
+ });
+
+ it('correctly invokes service with a batch request which is not an array', async () => {
+ const client = new JSONRPC20Client({
+ url: 'http://example.com/services/bad_batch',
+ timeout: RPC_CALL_TIMEOUT,
+ });
+
+ expect(async () => {
+ await client.callBatch([
+ { method: 'foo', params: { baz: 'buzz' } },
+ { method: 'bar', params: { baz: 'buzz' } },
+ ]);
+ }).rejects.toThrow('JSON-RPC 2.0 batch response must be an array');
+ });
+
+ it('times out as expected', async () => {
+ const client = new JSONRPC20Client({
+ url: 'http://example.com/services/foo',
+ timeout: 100,
+ });
+
+ fetchMock.mockAbort();
+
+ // cannot use the appended abort error message, as it is different in
+ // jest-fetch-mock than browsers, and there is no way to supply the message.
+ await expect(client.callMethod('timeout', { baz: 'buzz' })).rejects.toThrow(
+ /Connection error AbortError:/
+ );
+ });
+
+ it('throws if no endpoint detected', async () => {
+ const client = new JSONRPC20Client({
+ url: 'http://example.com/services/foo',
+ timeout: RPC_CALL_TIMEOUT,
+ });
+
+ fetchMock.mockReject(new Error('Request error: Network request failed'));
+
+ await expect(
+ client.callMethod('network_fail', { baz: 'buzz' })
+ ).rejects.toThrow('Request error: Network request failed');
+ });
+
+ it('throws if non-json returned', async () => {
+ const client = new JSONRPC20Client({
+ url: 'http://example.com/services/foo',
+ timeout: RPC_CALL_TIMEOUT,
+ });
+
+ // server.passthrough();
+
+ await expect(
+ client.callMethod('not_json', { baz: 'buzz' })
+ ).rejects.toThrow('The response from the service could not be parsed');
+ });
+});
diff --git a/src/features/orcidlink/common/api/JSONRPC20.ts b/src/features/orcidlink/common/api/JSONRPC20.ts
new file mode 100644
index 00000000..6bca652a
--- /dev/null
+++ b/src/features/orcidlink/common/api/JSONRPC20.ts
@@ -0,0 +1,417 @@
+/**
+ * A JSON-RPC 2.0 client
+ *
+ * It is intended to be fully compliant; where not, it is an oversight.
+ * Code copied from kbase-ui and modified.
+ * There is some accomodation for KBase (e.g. token may be used in authorization
+ * header), but unlike the JSON-RPC 1.1 usage at KBase is more compliant with
+ * the specs.
+ */
+import * as uuid from 'uuid';
+
+export function assertPlainObject(
+ value: unknown
+): asserts value is JSONRPC20ResponseObject {
+ if (typeof value !== 'object') {
+ throw new Error('must be an object');
+ }
+ if (value === null) {
+ throw new Error('must be a non-null object');
+ }
+ if (value.constructor !== Object) {
+ throw new Error('must be a plain object');
+ }
+}
+
+/**
+ * Returns the values in a1 that are not in a2.
+ *
+ * @param a1
+ * @param a2
+ */
+export function notIn(a1: Array, a2: Array) {
+ return a1.filter((v1) => {
+ return !a2.includes(v1);
+ });
+}
+
+/**
+ * Ensures that the given value is a JSON-RPC 2.0 compliant response
+ *
+ * Note that I'd rather use JSON Schema, but I can't get AJV and TS to play nicely.
+ *
+ * We make the assumption that the value is the result of JSON.parse(), so all
+ * values are by definition JSON-compatible - no undefined, no non-plain
+ * objects, no functions, etc.
+ *
+ * @param value
+ */
+export function assertJSONRPC20Response(
+ value: unknown
+): asserts value is JSONRPC20ResultResponseObject {
+ if (typeof value !== 'object') {
+ throw new Error('JSON-RPC 2.0 response must be an object');
+ }
+ if (value === null) {
+ throw new Error('JSON-RPC 2.0 response must be a non-null object');
+ }
+ if (value.constructor !== Object) {
+ throw new Error('JSON-RPC 2.0 response must be a plain object');
+ }
+
+ if (!('jsonrpc' in value)) {
+ throw new Error('JSON-RPC 2.0 response must have the "jsonrpc" property');
+ }
+
+ if (value.jsonrpc !== '2.0') {
+ throw new Error(
+ 'JSON-RPC 2.0 response "jsonrpc" property must be the string "2.0"'
+ );
+ }
+
+ if ('id' in value) {
+ if (
+ !(['string', 'number'].includes(typeof value.id) && value.id !== null)
+ ) {
+ throw new Error(
+ 'JSON-RPC 2.0 response "id" property must be a string, number or null'
+ );
+ }
+ } else {
+ throw new Error('JSON-RPC 2.0 response must have the "id" property');
+ }
+
+ if ('result' in value) {
+ // nothing to assert here? The result can be any valid JSON value.
+ } else if ('error' in value) {
+ try {
+ assertPlainObject(value.error);
+ } catch (ex) {
+ throw new Error(
+ 'JSON-RPC 2.0 response "error" property must be a plain object'
+ );
+ }
+
+ const extraKeys = notIn(Object.keys(value.error), [
+ 'code',
+ 'message',
+ 'data',
+ ]);
+ if (extraKeys.length > 0) {
+ throw new Error(
+ `JSON-RPC 2.0 response "error" property has extra keys: ${extraKeys.join(
+ ', '
+ )}`
+ );
+ }
+
+ if (!('code' in value.error)) {
+ throw new Error(
+ 'JSON-RPC 2.0 response "error" property must have a "code" property'
+ );
+ }
+ if (!Number.isInteger(value.error.code)) {
+ throw new Error(
+ 'JSON-RPC 2.0 response "error.code" property must be an integer'
+ );
+ }
+ if (!('message' in value.error)) {
+ throw new Error(
+ 'JSON-RPC 2.0 response "error" property must have a "message" property'
+ );
+ }
+ if (typeof value.error.message !== 'string') {
+ throw new Error(
+ 'JSON-RPC 2.0 response "error.message" property must be an string'
+ );
+ }
+ } else {
+ throw new Error(
+ 'JSON-RPC 2.0 response must include either "result" or "error"'
+ );
+ }
+}
+
+export function assertJSONRPC20BatchResponse(
+ values: unknown
+): asserts values is Array {
+ if (!(values instanceof Array)) {
+ throw new Error('JSON-RPC 2.0 batch response must be an array');
+ }
+
+ for (const value of values) {
+ assertJSONRPC20Response(value);
+ }
+}
+
+export interface JSONRPC20ObjectParams {
+ [key: string]: unknown;
+}
+
+export type JSONRPC20Params = JSONRPC20ObjectParams | Array;
+
+export type JSONRPC20Id = string | number | null;
+
+// The entire JSON RPC request object
+export interface JSONRPC20Request {
+ jsonrpc: '2.0';
+ method: string;
+ id?: JSONRPC20Id;
+ params?: JSONRPC20Params;
+}
+
+export type JSONRPC20Result =
+ | string
+ | number
+ | null
+ | Object
+ | Array;
+
+export interface JSONRPC20ResultResponseObject {
+ jsonrpc: '2.0';
+ id?: JSONRPC20Id;
+ result: JSONRPC20Result;
+}
+
+export interface JSONRPC20ErrorResponseObject {
+ jsonrpc: '2.0';
+ id?: JSONRPC20Id;
+ error: JSONRPC20Error;
+}
+
+export interface JSONRPC20Error {
+ code: number;
+ message: string;
+ data?: unknown;
+}
+
+export class JSONRPC20Exception extends Error {
+ error: JSONRPC20Error;
+ constructor(error: JSONRPC20Error) {
+ super(error.message);
+ this.error = error;
+ }
+}
+
+export function batchResultOrThrow(
+ responses: JSONRPC20BatchResponse
+): Array {
+ return responses.map((response) => {
+ if ('result' in response) {
+ return response.result;
+ }
+ throw new JSONRPC20Exception(response.error);
+ });
+}
+
+export function resultOrThrow(
+ response: JSONRPC20ResponseObject
+): JSONRPC20Result {
+ if ('result' in response) {
+ return response.result;
+ }
+ throw new JSONRPC20Exception(response.error);
+}
+
+export type JSONRPC20ResponseObject =
+ | JSONRPC20ResultResponseObject
+ | JSONRPC20ErrorResponseObject;
+
+export type JSONRPC20BatchResponse = Array;
+
+export class ConnectionError extends Error {}
+
+export class RequestError extends Error {}
+
+/**
+ * Constructor parameters
+ */
+export interface JSONRPC20ClientParams {
+ url: string;
+ timeout: number;
+ token?: string;
+}
+
+export interface JSONRPC20CallParams {
+ method: string;
+ params?: JSONRPC20Params;
+}
+
+/**
+ * A JSON-RPC 2.0 client, with some accomodation for KBase usage (e.g. token)
+ */
+export class JSONRPC20Client {
+ url: string;
+ timeout: number;
+ token?: string;
+
+ constructor({ url, timeout, token }: JSONRPC20ClientParams) {
+ this.url = url;
+ this.timeout = timeout;
+ this.token = token;
+ }
+
+ /**
+ * Given a method name and parameters, call the known endpoint, process the response,
+ * and return the result.
+ *
+ * Exceptions included
+ *
+ * @param method JSON-RPC 2.0 method name
+ * @param params JSON-RPC 2.0 parameters; must be an object or array
+ * @param options An object containing optional parameters
+ * @returns A
+ */
+ async callMethod(
+ method: string,
+ params?: JSONRPC20Params,
+ { timeout }: { timeout?: number } = {}
+ ): Promise {
+ // The innocuously named "payload" is the entire request object.
+ const payload = {
+ jsonrpc: '2.0',
+ method,
+ id: uuid.v4(),
+ params,
+ };
+
+ const headers = new Headers();
+ headers.set('content-type', 'application/json');
+ headers.set('accept', 'application/json');
+ if (this.token) {
+ headers.set('authorization', this.token);
+ }
+
+ // The abort controller allows us to abort the request after a specific amount
+ // of time passes.
+ const controller = new AbortController();
+ const timeoutTimer = window.setTimeout(() => {
+ controller.abort('Timeout');
+ }, timeout || this.timeout);
+
+ let response;
+ try {
+ response = await fetch(this.url, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ headers,
+ mode: 'cors',
+ signal: controller.signal,
+ });
+ } catch (ex) {
+ if (ex instanceof DOMException) {
+ throw new ConnectionError(`Connection error ${ex.name}: ${ex.message}`);
+ } else if (ex instanceof TypeError) {
+ throw new RequestError(`Request error: ${ex.message}`);
+ } else {
+ // Should never occur.
+ throw ex;
+ }
+ }
+ clearTimeout(timeoutTimer);
+
+ const responseText = await response.text();
+ const responseStatus = response.status;
+ let result;
+ try {
+ result = JSON.parse(responseText);
+ } catch (ex) {
+ throw new JSONRPC20Exception({
+ code: 100,
+ message: 'The response from the service could not be parsed',
+ data: {
+ originalMessage: ex instanceof Error ? ex.message : 'Unknown error',
+ responseText,
+ responseStatus,
+ },
+ });
+ }
+
+ assertJSONRPC20Response(result);
+
+ return result;
+ }
+
+ /**
+ * Given a method name and parameters, call the known endpoint, process the response,
+ * and return the result.
+ *
+ * Exceptions included
+ *
+ * @param method JSON-RPC 2.0 method name
+ * @param params JSON-RPC 2.0 parameters; must be an object or array
+ * @param options An object containing optional parameters
+ * @returns A
+ */
+ async callBatch(
+ calls: Array,
+ { timeout }: { timeout?: number } = {}
+ ): Promise {
+ // The innocuously named "payload" is the entire request object.
+
+ const payload = calls.map(({ method, params }) => {
+ return {
+ jsonrpc: '2.0',
+ method,
+ id: uuid.v4(),
+ params,
+ };
+ });
+
+ const headers = new Headers();
+ headers.set('content-type', 'application/json');
+ headers.set('accept', 'application/json');
+ if (this.token) {
+ headers.set('authorization', this.token);
+ }
+
+ // The abort controller allows us to abort the request after a specific amount
+ // of time passes.
+ const controller = new AbortController();
+ const timeoutTimer = window.setTimeout(() => {
+ controller.abort('Timeout');
+ }, timeout || this.timeout);
+
+ let response;
+ try {
+ response = await fetch(this.url, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ headers,
+ mode: 'cors',
+ signal: controller.signal,
+ });
+ } catch (ex) {
+ if (ex instanceof DOMException) {
+ throw new ConnectionError(`Connection error ${ex.name}: ${ex.message}`);
+ } else if (ex instanceof TypeError) {
+ throw new RequestError(`Request error: ${ex.message}`);
+ } else {
+ // Should never occur.
+ throw ex;
+ }
+ }
+ clearTimeout(timeoutTimer);
+
+ const responseText = await response.text();
+ const responseStatus = response.status;
+ let result;
+ try {
+ result = JSON.parse(responseText);
+ } catch (ex) {
+ throw new JSONRPC20Exception({
+ code: 100,
+ message: 'The response from the service could not be parsed',
+ data: {
+ originalMessage: ex instanceof Error ? ex.message : 'Unknown error',
+ responseText,
+ responseStatus,
+ },
+ });
+ }
+
+ assertJSONRPC20BatchResponse(result);
+
+ return result;
+ }
+}
diff --git a/src/features/orcidlink/common/api/ORCIDLInkAPI.ts b/src/features/orcidlink/common/api/ORCIDLInkAPI.ts
new file mode 100644
index 00000000..f283a2e0
--- /dev/null
+++ b/src/features/orcidlink/common/api/ORCIDLInkAPI.ts
@@ -0,0 +1,305 @@
+/**
+ * A direct (not RTK) client for the orcidlink service.
+ *
+ * Using the JSON-RPC 2.0 and service client implementations in this same
+ * directory, implements enough of the orcidlink api to allow for functionality
+ * currently implemented in the ui.
+ *
+ * This separate client exists because it is only used for the ephemeral states
+ * for creating and perhaps removing an orcidlink.
+ *
+ * Future developers may move this into the RTK orcidlink client, but given the
+ * short amount of time to port this, I didn't want to go down that rabbit hole.
+ *
+ * This code was ported from kbase-ui and modified to fit.
+ */
+
+import { JSONRPC20ObjectParams } from './JSONRPC20';
+import {
+ LinkRecordPublic,
+ LinkRecordPublicNonOwner,
+ ORCIDAuthPublic,
+ ORCIDProfile,
+} from './orcidlinkAPICommon';
+import { ServiceClient } from './ServiceClient';
+
+export interface StatusResult {
+ status: 'ok';
+ current_time: number;
+ start_time: number;
+}
+
+export interface ServiceDescription {
+ name: string;
+ title: string;
+ version: string;
+ language: string;
+ description: string;
+ repoURL: string;
+}
+
+export interface ServiceConfig {
+ url: string;
+}
+
+export interface Auth2Config extends ServiceConfig {
+ tokenCacheLifetime: number;
+ tokenCacheMaxSize: number;
+}
+
+export interface GitInfo {
+ commit_hash: string;
+ commit_hash_abbreviated: string;
+ author_name: string;
+ committer_name: string;
+ committer_date: number;
+ url: string;
+ branch: string;
+ tag: string | null;
+}
+
+export interface RuntimeInfo {
+ current_time: number;
+ orcid_api_url: string;
+ orcid_oauth_url: string;
+ orcid_site_url: string;
+}
+
+export interface InfoResult {
+ 'service-description': ServiceDescription;
+ 'git-info': GitInfo;
+ runtime_info: RuntimeInfo;
+}
+
+export interface ErrorInfo {
+ code: number;
+ title: string;
+ description: string;
+ status_code: number;
+}
+
+export interface ErrorInfoResult {
+ error_info: ErrorInfo;
+}
+
+export interface LinkingSessionPublicComplete {
+ session_id: string;
+ username: string;
+ created_at: number;
+ expires_at: number;
+ orcid_auth: ORCIDAuthPublic;
+ return_link: string | null;
+ skip_prompt: boolean;
+ ui_options: string;
+}
+
+export interface LinkParams extends JSONRPC20ObjectParams {
+ username: string;
+}
+
+export interface LinkForOtherParams extends JSONRPC20ObjectParams {
+ username: string;
+}
+
+export interface DeleteLinkParams extends JSONRPC20ObjectParams {
+ username: string;
+}
+
+export interface CreateLinkingSessionParams extends JSONRPC20ObjectParams {
+ username: string;
+}
+
+export interface CreateLinkingSessionResult {
+ session_id: string;
+}
+
+export interface DeleteLinkingSessionParams extends JSONRPC20ObjectParams {
+ session_id: string;
+}
+
+export interface FinishLinkingSessionParams extends JSONRPC20ObjectParams {
+ session_id: string;
+}
+
+export interface GetLinkingSessionParams extends JSONRPC20ObjectParams {
+ session_id: string;
+}
+
+export interface IsLinkedParams extends JSONRPC20ObjectParams {
+ username: string;
+}
+
+export interface GetProfileParams extends JSONRPC20ObjectParams {
+ username: string;
+}
+
+// Works
+
+export interface ExternalId {
+ type: string;
+ value: string;
+ url: string;
+ relationship: string;
+}
+
+export interface Citation {
+ type: string;
+ value: string;
+}
+
+export interface ContributorORCIDInfo {
+ uri: string;
+ path: string;
+}
+
+export interface ContributorRole {
+ role: string;
+}
+
+export interface Contributor {
+ orcidId: string | null;
+ name: string;
+ roles: Array;
+}
+
+export interface SelfContributor {
+ orcidId: string;
+ name: string;
+ roles: Array;
+}
+export interface WorkBase {
+ title: string;
+ journal: string;
+ date: string;
+ workType: string;
+ url: string;
+ doi: string;
+ externalIds: Array;
+ citation: Citation | null;
+ shortDescription: string;
+ selfContributor: SelfContributor;
+ otherContributors: Array | null;
+}
+
+export type NewWork = WorkBase;
+
+export interface PersistedWork extends WorkBase {
+ putCode: string;
+}
+
+export type WorkUpdate = PersistedWork;
+
+export interface Work extends PersistedWork {
+ createdAt: number;
+ updatedAt: number;
+ source: string;
+}
+
+export type GetWorksResult = Array<{
+ externalIds: Array;
+ updatedAt: number;
+ works: Array;
+}>;
+
+export interface GetWorksParams extends JSONRPC20ObjectParams {
+ username: string;
+}
+
+export interface GetWorkParams extends JSONRPC20ObjectParams {
+ username: string;
+ put_code: string;
+}
+
+export interface GetWorkResult extends JSONRPC20ObjectParams {
+ work: Work;
+}
+
+export interface SaveWorkParams extends JSONRPC20ObjectParams {
+ username: string;
+ work_update: WorkUpdate;
+}
+
+export interface SaveWorkResult {
+ work: Work;
+}
+
+export interface DeleteWorkParams extends JSONRPC20ObjectParams {
+ username: string;
+ put_code: string;
+}
+
+export default class ORCIDLinkAPI extends ServiceClient {
+ module = 'ORCIDLink';
+ prefix = false;
+
+ async status(): Promise {
+ const result = await this.callFunc('status');
+ return result as unknown as StatusResult;
+ }
+
+ async info(): Promise {
+ const result = await this.callFunc('info');
+ return result as unknown as InfoResult;
+ }
+
+ async errorInfo(errorCode: number): Promise {
+ const result = await this.callFunc('error-info', {
+ error_code: errorCode,
+ });
+ return result as unknown as ErrorInfoResult;
+ }
+
+ async isLinked(params: IsLinkedParams): Promise {
+ const result = await this.callFunc('is-linked', params);
+ return result as unknown as boolean;
+ }
+
+ async getOwnerLink(params: LinkParams): Promise {
+ const result = await this.callFunc('owner-link', params);
+ return result as unknown as LinkRecordPublic;
+ }
+
+ async getOtherLink(
+ params: LinkForOtherParams
+ ): Promise {
+ const result = await this.callFunc('other-link', params);
+ return result as unknown as LinkRecordPublicNonOwner;
+ }
+
+ async deleteOwnLink(params: DeleteLinkParams): Promise {
+ await this.callFunc('delete-own-link', params);
+ }
+
+ async createLinkingSession(
+ params: CreateLinkingSessionParams
+ ): Promise {
+ const result = await this.callFunc('create-linking-session', params);
+ return result as unknown as CreateLinkingSessionResult;
+ }
+
+ async getLinkingSession(
+ params: GetLinkingSessionParams
+ ): Promise {
+ const result = await this.callFunc('get-linking-session', params);
+ return result as unknown as LinkingSessionPublicComplete;
+ }
+
+ async deleteLinkingSession(
+ params: DeleteLinkingSessionParams
+ ): Promise {
+ await this.callFunc('delete-linking-session', params);
+ }
+
+ async finishLinkingSession(
+ params: FinishLinkingSessionParams
+ ): Promise {
+ await this.callFunc('finish-linking-session', params);
+ }
+
+ // ORCID profile
+
+ async getProfile(params: GetProfileParams): Promise {
+ const result = await this.callFunc('get-orcid-profile', params);
+ return result as unknown as ORCIDProfile;
+ }
+}
diff --git a/src/features/orcidlink/common/api/ORCIDLinkAPI.test.ts b/src/features/orcidlink/common/api/ORCIDLinkAPI.test.ts
new file mode 100644
index 00000000..32255162
--- /dev/null
+++ b/src/features/orcidlink/common/api/ORCIDLinkAPI.test.ts
@@ -0,0 +1,251 @@
+import fetchMock from 'jest-fetch-mock';
+import { FetchMock } from 'jest-fetch-mock/types';
+import { ORCIDProfile } from '../../../../common/api/orcidLinkCommon';
+import {
+ ERROR_INFO_1,
+ LINKING_SESSION_1,
+ LINK_RECORD_1,
+ LINK_RECORD_OTHER_1,
+ PROFILE_1,
+} from '../../test/data';
+import { makeORCIDLinkAPI } from '../../test/mocks';
+import { makeOrcidlinkServiceMock } from '../../test/orcidlinkServiceMock';
+import {
+ CreateLinkingSessionParams,
+ CreateLinkingSessionResult,
+ DeleteLinkingSessionParams,
+ DeleteLinkParams,
+ FinishLinkingSessionParams,
+ GetLinkingSessionParams,
+ GetProfileParams,
+ LinkingSessionPublicComplete,
+} from './ORCIDLInkAPI';
+
+describe('The ORCIDLink API', () => {
+ let mockService: FetchMock;
+
+ beforeEach(() => {
+ fetchMock.enableMocks();
+ fetchMock.doMock();
+ mockService = makeOrcidlinkServiceMock();
+ });
+
+ afterEach(() => {
+ mockService.mockClear();
+ fetchMock.disableMocks();
+ });
+
+ it('correctly calls the "status" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const result = await api.status();
+
+ expect(result.status).toBe('ok');
+ });
+
+ it('correctly calls the "info" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const result = await api.info();
+
+ expect(result['git-info'].author_name).toBe('foo');
+ });
+
+ it('correctly calls the "error-info" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const testCases = [
+ {
+ params: {
+ errorCode: 123,
+ },
+ expected: ERROR_INFO_1,
+ },
+ ];
+
+ for (const { params, expected } of testCases) {
+ const result = await api.errorInfo(params.errorCode);
+ expect(result).toMatchObject(expected);
+ }
+ });
+
+ it('correctly calls the "is-linked" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const testCases = [
+ {
+ params: {
+ username: 'foo',
+ },
+ expected: false,
+ },
+ {
+ params: {
+ username: 'bar',
+ },
+ expected: true,
+ },
+ ];
+
+ for (const { params, expected } of testCases) {
+ const result = await api.isLinked(params);
+ expect(result).toBe(expected);
+ }
+ });
+
+ it('correctly calls the "owner-link" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const testCases = [
+ {
+ params: {
+ username: 'foo',
+ },
+ expected: LINK_RECORD_1,
+ },
+ ];
+
+ for (const { params, expected } of testCases) {
+ const result = await api.getOwnerLink(params);
+ expect(result).toMatchObject(expected);
+ }
+ });
+
+ it('correctly calls the "other-link" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const testCases = [
+ {
+ params: {
+ username: 'bar',
+ },
+ expected: LINK_RECORD_OTHER_1,
+ },
+ ];
+
+ for (const { params, expected } of testCases) {
+ const result = await api.getOtherLink(params);
+ expect(result).toMatchObject(expected);
+ }
+ });
+
+ it('correctly calls the "delete-own-link" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const testCases: Array<{ params: DeleteLinkParams }> = [
+ {
+ params: {
+ username: 'bar',
+ },
+ },
+ ];
+
+ for (const { params } of testCases) {
+ const result = await api.deleteOwnLink(params);
+ expect(result).toBeUndefined();
+ }
+ });
+
+ it('correctly calls the "create-linking-session" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const testCases: Array<{
+ params: CreateLinkingSessionParams;
+ expected: CreateLinkingSessionResult;
+ }> = [
+ {
+ params: {
+ username: 'foo',
+ },
+ expected: {
+ session_id: 'foo_session_id',
+ },
+ },
+ ];
+
+ for (const { params, expected } of testCases) {
+ const result = await api.createLinkingSession(params);
+ expect(result).toMatchObject(expected);
+ }
+ });
+
+ it('correctly calls the "get-linking-session" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const testCases: Array<{
+ params: GetLinkingSessionParams;
+ expected: LinkingSessionPublicComplete;
+ }> = [
+ {
+ params: {
+ session_id: 'foo_session2',
+ },
+ expected: LINKING_SESSION_1,
+ },
+ ];
+
+ for (const { params, expected } of testCases) {
+ const result = await api.getLinkingSession(params);
+ expect(result).toMatchObject(expected);
+ }
+ });
+
+ it('correctly calls the "delete-linking-session" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const testCases: Array<{
+ params: DeleteLinkingSessionParams;
+ }> = [
+ {
+ params: {
+ session_id: 'foo_session',
+ },
+ },
+ ];
+
+ for (const { params } of testCases) {
+ const result = await api.deleteLinkingSession(params);
+ expect(result).toBeUndefined();
+ }
+ });
+
+ it('correctly calls the "finish-linking-session" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const testCases: Array<{
+ params: FinishLinkingSessionParams;
+ }> = [
+ {
+ params: {
+ session_id: 'foo_session',
+ },
+ },
+ ];
+
+ for (const { params } of testCases) {
+ const result = await api.finishLinkingSession(params);
+ expect(result).toBeUndefined();
+ }
+ });
+
+ it('correctly calls the "get-orcid-profile" method', async () => {
+ const api = makeORCIDLinkAPI();
+
+ const testCases: Array<{
+ params: GetProfileParams;
+ expected: ORCIDProfile;
+ }> = [
+ {
+ params: {
+ username: 'foo',
+ },
+ expected: PROFILE_1,
+ },
+ ];
+
+ for (const { params, expected } of testCases) {
+ const result = await api.getProfile(params);
+ expect(result).toMatchObject(expected);
+ }
+ });
+});
diff --git a/src/features/orcidlink/common/api/ServiceClient.test.ts b/src/features/orcidlink/common/api/ServiceClient.test.ts
new file mode 100644
index 00000000..f7165daf
--- /dev/null
+++ b/src/features/orcidlink/common/api/ServiceClient.test.ts
@@ -0,0 +1,205 @@
+import fetchMock from 'jest-fetch-mock';
+import { FetchMock, MockResponseInit } from 'jest-fetch-mock/types';
+import { API_CALL_TIMEOUT } from '../../test/data';
+import {
+ jsonrpc20_response,
+ makeResultObject,
+} from '../../test/jsonrpc20ServiceMock';
+import { JSONRPC20ResponseObject } from './JSONRPC20';
+import { ServiceClient } from './ServiceClient';
+
+export function makeMyServiceMock() {
+ function handleRPC(
+ id: string,
+ method: string,
+ params: unknown
+ ): JSONRPC20ResponseObject {
+ switch (method) {
+ case 'foo':
+ return makeResultObject(id, 'bar');
+ case 'FooModule.foo':
+ return makeResultObject(id, 'RESULT FOR METHOD WITH MODULE PREFIX');
+ case 'batch1':
+ return makeResultObject(id, 'batch_response_1');
+ case 'batch2':
+ return makeResultObject(id, 'batch_response_2');
+ case 'FooModule.batch1':
+ return makeResultObject(id, 'batch_response_1');
+ case 'FooModule.batch2':
+ return makeResultObject(id, 'batch_response_2');
+ default:
+ // eslint-disable-next-line no-console
+ console.debug('METHOD NOT HANDLED', method, params);
+ throw new Error('method not handled');
+ }
+ }
+
+ return fetchMock.mockResponse(
+ async (request): Promise => {
+ const { pathname } = new URL(request.url);
+ // put a little delay in here so that we have a better
+ // chance of catching temporary conditions, like loading.
+ await new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(null);
+ }, 300);
+ });
+ switch (pathname) {
+ // Mocks for the orcidlink api
+ case '/myservice': {
+ if (request.method !== 'POST') {
+ return '';
+ }
+ const body = await request.json();
+
+ const response:
+ | JSONRPC20ResponseObject
+ | Array = (() => {
+ if (body instanceof Array) {
+ // batch
+ return body.map((rpc) => {
+ const id = rpc['id'];
+ const method = rpc['method'];
+ const params = rpc['params'];
+ return handleRPC(id, method, params);
+ });
+ } else {
+ // single reqquest
+ const id = body['id'];
+ const method = body['method'];
+ const params = body['params'];
+ return handleRPC(id, method, params);
+ }
+ })();
+
+ return jsonrpc20_response(response);
+ }
+ default:
+ // eslint-disable-next-line no-console
+ console.debug('PATH NOT HANDLED', pathname);
+ throw new Error('pathname not handled');
+ }
+ }
+ );
+}
+
+describe('The ServiceClient abstract base class', () => {
+ let mockService: FetchMock;
+
+ beforeEach(() => {
+ fetchMock.enableMocks();
+ // fetchMock.doMock();
+ mockService = makeMyServiceMock();
+ });
+
+ afterEach(() => {
+ mockService.mockClear();
+ fetchMock.disableMocks();
+ });
+
+ it('can be used to create a basic JSON-RPC 2.0 client without method prefix', async () => {
+ class MyServiceClient extends ServiceClient {
+ module = 'FooModule';
+ prefix = false;
+
+ async foo(): Promise {
+ const result = await this.callFunc('foo');
+ return result as unknown as string;
+ }
+ }
+
+ const client = new MyServiceClient({
+ timeout: API_CALL_TIMEOUT,
+ url: 'http://localhost/myservice',
+ });
+
+ expect(client).not.toBeNull();
+
+ const result = await client.foo();
+
+ expect(result).toBe('bar');
+ });
+
+ it('can be used to create a basic JSON-RPC 2.0 client with method prefix', async () => {
+ class MyServiceClient extends ServiceClient {
+ module = 'FooModule';
+ prefix = true;
+
+ async foo(): Promise {
+ const result = await this.callFunc('foo');
+ return result as unknown as string;
+ }
+ }
+
+ const client = new MyServiceClient({
+ timeout: API_CALL_TIMEOUT,
+ url: 'http://localhost/myservice',
+ });
+
+ expect(client).not.toBeNull();
+
+ const result = await client.foo();
+
+ expect(result).toBe('RESULT FOR METHOD WITH MODULE PREFIX');
+ });
+
+ it('can be used to create a basic JSON-RPC 2.0 client with batch support', async () => {
+ class MyServiceClient extends ServiceClient {
+ module = 'FooModule';
+ prefix = false;
+
+ async batch(): Promise {
+ const result = await this.callBatch([
+ {
+ funcName: 'batch1',
+ },
+ {
+ funcName: 'batch2',
+ },
+ ]);
+ return result as unknown as string;
+ }
+ }
+
+ const client = new MyServiceClient({
+ timeout: API_CALL_TIMEOUT,
+ url: 'http://localhost/myservice',
+ });
+
+ expect(client).not.toBeNull();
+
+ const result = await client.batch();
+
+ expect(result).toBeInstanceOf(Array);
+ });
+
+ it('can be used to create a basic JSON-RPC 2.0 client with batch support and module name prefix', async () => {
+ class MyServiceClient extends ServiceClient {
+ module = 'FooModule';
+ prefix = true;
+
+ async batch(): Promise {
+ const result = await this.callBatch([
+ {
+ funcName: 'batch1',
+ },
+ {
+ funcName: 'batch2',
+ },
+ ]);
+ return result as unknown as string;
+ }
+ }
+
+ const client = new MyServiceClient({
+ timeout: API_CALL_TIMEOUT,
+ url: 'http://localhost/myservice',
+ });
+
+ expect(client).not.toBeNull();
+
+ const result = await client.batch();
+
+ expect(result).toBeInstanceOf(Array);
+ });
+});
diff --git a/src/features/orcidlink/common/api/ServiceClient.ts b/src/features/orcidlink/common/api/ServiceClient.ts
new file mode 100644
index 00000000..09261dfe
--- /dev/null
+++ b/src/features/orcidlink/common/api/ServiceClient.ts
@@ -0,0 +1,106 @@
+/**
+ * A base client for KBase services based on JSON-RPC 2.0
+ *
+ * Basically just a wrapper around JSONRPCE20.ts, but captures KBase usage
+ * patterns. For example, KBase services typically have a "module name"
+ * (essentially a service name or identifier), and construct the method name
+ * from a concatenation of the module name and a method name. E.g. if the
+ * service is "Foo" and the method "bar", the method name for the RPC call is
+ * "Foo.bar". This is the pattern enforced by kb-sdk. But since kb-sdk only
+ * supports JSON-RPC 1.1, we are free to use plain method names; in the example
+ * above, simply "bar".
+ *
+ */
+
+import {
+ batchResultOrThrow,
+ JSONRPC20Client,
+ JSONRPC20Params,
+ JSONRPC20Result,
+ resultOrThrow,
+} from './JSONRPC20';
+
+export interface ServiceClientParams {
+ url: string;
+ timeout: number;
+ token?: string;
+}
+
+export interface BatchParams {
+ funcName: string;
+ params?: JSONRPC20Params;
+}
+
+/**
+ * The base class for all KBase JSON-RPC 1.1 services
+ */
+export abstract class ServiceClient {
+ abstract module: string;
+ abstract prefix: boolean;
+ url: string;
+ timeout: number;
+ token?: string;
+
+ constructor({ url, timeout, token }: ServiceClientParams) {
+ this.url = url;
+ this.timeout = timeout;
+ this.token = token;
+ }
+
+ /**
+ * The single point of entry for RPC calls, just to help dry out the class.
+ *
+ * @param funcName
+ * @param params
+ * @returns
+ */
+
+ public async callFunc(
+ funcName: string,
+ params?: JSONRPC20Params
+ ): Promise {
+ const client = new JSONRPC20Client({
+ url: this.url,
+ timeout: this.timeout,
+ token: this.token,
+ });
+ const method = (() => {
+ if (this.prefix) {
+ return `${this.module}.${funcName}`;
+ } else {
+ return funcName;
+ }
+ })();
+ const result = await client.callMethod(method, params, {
+ timeout: this.timeout,
+ });
+ return resultOrThrow(result);
+ }
+
+ public async callBatch(
+ batch: Array
+ ): Promise> {
+ const client = new JSONRPC20Client({
+ url: this.url,
+ timeout: this.timeout,
+ token: this.token,
+ });
+
+ const batchParams = batch.map(({ funcName, params }) => {
+ const method = (() => {
+ if (this.prefix) {
+ return `${this.module}.${funcName}`;
+ } else {
+ return funcName;
+ }
+ })();
+
+ return { method, params };
+ });
+
+ const result = await client.callBatch(batchParams, {
+ timeout: this.timeout,
+ });
+ return batchResultOrThrow(result);
+ }
+}
diff --git a/src/features/orcidlink/common/api/orcidlinkAPICommon.ts b/src/features/orcidlink/common/api/orcidlinkAPICommon.ts
new file mode 100644
index 00000000..ed490442
--- /dev/null
+++ b/src/features/orcidlink/common/api/orcidlinkAPICommon.ts
@@ -0,0 +1,72 @@
+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;
+}
+
+export interface ORCIDAuthPublicNonOwner {
+ orcid: string;
+ name: string;
+}
+
+export interface LinkRecordPublicNonOwner {
+ username: string;
+ orcid_auth: ORCIDAuthPublicNonOwner;
+}
+
+// ORCID User Profile
+
+export interface Affiliation {
+ name: string;
+ role: string;
+ startYear: string;
+ endYear: string | null;
+}
+
+export interface ORCIDFieldGroupBase {
+ private: boolean;
+}
+
+export interface ORCIDFieldGroupPrivate extends ORCIDFieldGroupBase {
+ private: true;
+}
+
+export interface ORCIDFieldGroupAccessible extends ORCIDFieldGroupBase {
+ private: false;
+ fields: T;
+}
+
+export type ORCIDFieldGroup =
+ | ORCIDFieldGroupPrivate
+ | ORCIDFieldGroupAccessible;
+
+export interface ORCIDNameFieldGroup {
+ firstName: string;
+ lastName: string | null;
+ creditName: string | null;
+}
+
+export interface ORCIDBiographyFieldGroup {
+ bio: string;
+}
+
+export interface ORCIDEmailFieldGroup {
+ emailAddresses: Array;
+}
+
+export interface ORCIDProfile {
+ orcidId: string;
+ nameGroup: ORCIDFieldGroup;
+ biographyGroup: ORCIDFieldGroup;
+ emailGroup: ORCIDFieldGroup;
+ employments: Array;
+}
diff --git a/src/features/orcidlink/common/styles.module.scss b/src/features/orcidlink/common/styles.module.scss
new file mode 100644
index 00000000..733c353d
--- /dev/null
+++ b/src/features/orcidlink/common/styles.module.scss
@@ -0,0 +1,11 @@
+@import "../../../common/colors";
+
+/**
+* A background color for orcidlink pages.
+*
+* Used to enable content contained in "Card" components to be distict, as cards
+* are rather subtle. Similar approach, and same color, as Navigator.
+*/
+.paper {
+ background-color: use-color("base-lightest");
+}
diff --git a/src/features/orcidlink/constants.ts b/src/features/orcidlink/constants.ts
index 4724bf42..8f4ea3c0 100644
--- a/src/features/orcidlink/constants.ts
+++ b/src/features/orcidlink/constants.ts
@@ -6,6 +6,8 @@
* If a constat value is hardcoded, it should be moved here.
*/
+import { baseUrl } from '../../common/api';
+
export const MANAGER_ROLE = 'ORCIDLINK_MANAGER';
export type ORCIDScope = '/read-limited' | '/activities/update';
@@ -74,6 +76,32 @@ function image_url(filename: string): string {
export const ORCID_ICON_URL = image_url('ORCID-iD_icon-vector.svg');
export const ORCID_SIGN_IN_SCREENSHOT_URL = image_url('ORCID-sign-in.png');
+/**
+ * Conforms to ORCID branding
+ */
export const ORCID_LABEL = 'ORCID®';
+/**
+ * Conforms to ORCID branding and helps us make a consistent label for this service.
+ */
export const ORCID_LINK_LABEL = `KBase ${ORCID_LABEL} Link`;
+
+/**
+ * Service paths.
+ *
+ * Ideally these would be in a central config for services, but there does not
+ * appear to be such a thing at present. Rather the service paths are embedded
+ * in the RTK api definitions.
+ */
+export const ORCIDLINK_SERVICE_API_ENDPOINT = `${baseUrl}services/orcidlink/api/v1`;
+export const ORCIDLINK_SERVICE_OAUTH_ENDPOINT = `${baseUrl}/services/orcidlink`;
+
+/**
+ * An API call will be abandoned after this duration of time, 1 minute.
+ *
+ * Typically, the network stack may have other timeouts involved. Generally, any
+ * request used by orcidlink is expected to be short-duration. A long timeout
+ * that may be appropriate for a rare case like uploading a large file is simply
+ * not expected, so a short timeout limit should be appropriate.
+ */
+export const API_CALL_TIMEOUT = 60000;
diff --git a/src/features/orcidlink/index.test.tsx b/src/features/orcidlink/index.test.tsx
deleted file mode 100644
index 72b496a7..00000000
--- a/src/features/orcidlink/index.test.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import { render, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import fetchMock from 'jest-fetch-mock';
-import { Provider } from 'react-redux';
-import { MemoryRouter, Route, Routes } from 'react-router-dom';
-import { createTestStore } from '../../app/store';
-import MainView from './index';
-import { INITIAL_STORE_STATE } from './test/data';
-import { setupMockRegularUser } from './test/mocks';
-
-describe('The Main Component', () => {
- let debugLogSpy: jest.SpyInstance;
- beforeEach(() => {
- jest.resetAllMocks();
- });
- beforeEach(() => {
- fetchMock.resetMocks();
- fetchMock.enableMocks();
- debugLogSpy = jest.spyOn(console, 'debug');
- });
-
- it('renders with minimal props', async () => {
- setupMockRegularUser();
-
- render(
-
-
-
- } />
-
- {/* */}
-
-
- );
-
- const creditName = 'Foo B. Bar';
- const realName = 'Foo Bar';
-
- // Part of the profile should be available
- expect(await screen.findByText(creditName)).toBeVisible();
- expect(await screen.findByText(realName)).toBeVisible();
- });
-
- it('can switch to the "manage your link" tab', async () => {
- const user = userEvent.setup();
- setupMockRegularUser();
-
- render(
-
-
-
- } />
-
-
-
- );
-
- // Matches test data (see the setup function above)
- const creditName = 'Foo B. Bar';
- // Matches what would be synthesized from the test data
- const realName = 'Foo Bar';
-
- // Part of the profile should be available
- expect(await screen.findByText(creditName)).toBeVisible();
- expect(await screen.findByText(realName)).toBeVisible();
-
- const tab = await screen.findByText('Manage Your Link');
- expect(tab).not.toBeNull();
-
- await user.click(tab);
-
- await waitFor(() => {
- expect(
- screen.queryByText('Remove your KBase ORCID® Link')
- ).not.toBeNull();
- expect(screen.queryByText('Settings')).not.toBeNull();
- });
- });
-
- it('the "Show in User Profile?" switch calls the prop function we pass', async () => {
- const user = userEvent.setup();
- setupMockRegularUser();
-
- render(
-
-
-
- } />
-
-
-
- );
-
- const tab = await screen.findByText('Manage Your Link');
- expect(tab).not.toBeNull();
- await user.click(tab);
-
- await waitFor(() => {
- expect(screen.queryByText('Settings')).not.toBeNull();
- });
-
- const toggleControl = await screen.findByText('Yes');
-
- await user.click(toggleControl);
-
- await waitFor(() => {
- expect(debugLogSpy).toHaveBeenCalledWith('TOGGLE SHOW IN PROFILE');
- });
- });
-});
diff --git a/src/features/orcidlink/index.tsx b/src/features/orcidlink/index.tsx
deleted file mode 100644
index b57131e2..00000000
--- a/src/features/orcidlink/index.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Box } from '@mui/material';
-import Home from './Home';
-import styles from './orcidlink.module.scss';
-
-const ORCIDLinkFeature = () => {
- return (
-
-
-
- );
-};
-
-export default ORCIDLinkFeature;
diff --git a/src/features/orcidlink/orcidlink.module.scss b/src/features/orcidlink/orcidlink.module.scss
deleted file mode 100644
index 6aa137f0..00000000
--- a/src/features/orcidlink/orcidlink.module.scss
+++ /dev/null
@@ -1,37 +0,0 @@
-@import "../../common/colors";
-
-.box {
- border: 4px solid use-color("primary");
- border-radius: 1rem;
- margin: 1rem;
- padding: 1rem;
-}
-
-.prop-table {
- display: flex;
- flex-direction: column;
-}
-
-.prop-table > div {
- display: flex;
- flex-direction: row;
- margin-bottom: 0.5rem;
-}
-
-.prop-table > div > div:nth-child(1) {
- flex: 0 0 12rem;
- font-weight: bold;
-}
-
-.prop-table > div > div:nth-child(2) {
- flex: 1 1 0;
-}
-
-.paper {
- background-color: use-color("base-lightest");
- height: 100%;
-}
-
-.section-title {
- font-weight: bold;
-}
diff --git a/src/features/orcidlink/test/data.ts b/src/features/orcidlink/test/data.ts
index 0716be41..8f839511 100644
--- a/src/features/orcidlink/test/data.ts
+++ b/src/features/orcidlink/test/data.ts
@@ -1,16 +1,52 @@
+/**
+ * Contains test data used in orcidlink tests.
+ *
+ * Most test data should reside here.
+ */
+
import { InfoResult } from '../../../common/api/orcidlinkAPI';
import {
LinkRecordPublic,
+ LinkRecordPublicNonOwner,
ORCIDProfile,
} from '../../../common/api/orcidLinkCommon';
import { JSONRPC20Error } from '../../../common/api/utils/kbaseBaseQuery';
-import orcidlinkIsLinkedAuthorizationRequired from './data/orcidlink-is-linked-1010.json';
+import {
+ ErrorInfoResult,
+ LinkingSessionPublicComplete,
+ StatusResult,
+} from '../common/api/ORCIDLInkAPI';
+
+// We can have a short default timeout, as tests should be running against a
+// local server with very low latency.
+//
+// Of course if you are testing timeout errors, you should ignore this and use
+// whatever values are required to trigger whatever conditions are needed.
+export const API_CALL_TIMEOUT = 1000;
+
+export const STATUS_1: StatusResult = {
+ status: 'ok',
+ current_time: 123,
+ start_time: 456,
+};
-export const ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED: JSONRPC20Error =
- orcidlinkIsLinkedAuthorizationRequired;
+export const ERROR_INFO_1: ErrorInfoResult = {
+ error_info: {
+ code: 123,
+ title: 'Foo Error',
+ description: 'This is the foo error',
+ status_code: 400,
+ },
+};
+
+export const ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED: JSONRPC20Error = {
+ code: 1010,
+ message: 'Authorization Required',
+ data: 'Authorization Required',
+};
export const PROFILE_1: ORCIDProfile = {
- orcidId: '0009-0006-1955-0944',
+ orcidId: 'foo_orcid_id',
nameGroup: {
private: false,
fields: {
@@ -39,6 +75,22 @@ export const PROFILE_1: ORCIDProfile = {
],
};
+export const LINKING_SESSION_1: LinkingSessionPublicComplete = {
+ session_id: 'f85a9b43-5dd4-4e1d-ad66-9f923fde5de2',
+ username: 'kbaseuitest',
+ created_at: 1718044771667,
+ expires_at: 1718045371667,
+ return_link: null,
+ skip_prompt: false,
+ ui_options: '',
+ orcid_auth: {
+ name: 'Erik T. Pearson',
+ scope: '/read-limited /activities/update',
+ expires_in: 631138518,
+ orcid: '0009-0008-7728-946X',
+ },
+};
+
export const LINK_RECORD_1: LinkRecordPublic = {
username: 'foo',
created_at: 1714546800000,
@@ -52,6 +104,14 @@ export const LINK_RECORD_1: LinkRecordPublic = {
},
};
+export const LINK_RECORD_OTHER_1: LinkRecordPublicNonOwner = {
+ username: 'bar',
+ orcid_auth: {
+ name: 'Bar',
+ orcid: 'bar_orcid_id',
+ },
+};
+
export const SERVICE_INFO_1: InfoResult = {
'git-info': {
author_name: 'foo',
@@ -96,6 +156,32 @@ export const INITIAL_STORE_STATE = {
},
};
+export const INITIAL_STORE_STATE_UNAUTHENTICATED = {
+ auth: {
+ token: undefined,
+ username: undefined,
+ tokenInfo: undefined,
+ initialized: false,
+ },
+};
+
+export const INITIAL_STORE_STATE_BAR = {
+ auth: {
+ token: 'bar_token',
+ username: 'bar',
+ tokenInfo: {
+ created: 123,
+ expires: 456,
+ id: 'abc123',
+ name: 'Bar Baz',
+ type: 'Login',
+ user: 'bar',
+ cachefor: 890,
+ },
+ initialized: true,
+ },
+};
+
export const INITIAL_UNAUTHENTICATED_STORE_STATE = {
auth: {
initialized: true,
diff --git a/src/features/orcidlink/test/data/orcidlink-is-linked-1010.json b/src/features/orcidlink/test/data/orcidlink-is-linked-1010.json
deleted file mode 100644
index 522486a9..00000000
--- a/src/features/orcidlink/test/data/orcidlink-is-linked-1010.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "code": 1010,
- "message": "Authorization Required",
- "data": "Authorization Required"
-}
diff --git a/src/features/orcidlink/test/jsonrpc20ServiceMock.ts b/src/features/orcidlink/test/jsonrpc20ServiceMock.ts
new file mode 100644
index 00000000..feef1d8c
--- /dev/null
+++ b/src/features/orcidlink/test/jsonrpc20ServiceMock.ts
@@ -0,0 +1,212 @@
+/**
+ * Support for creating mocks of JSON-RPC 2.0 services.
+ *
+ * Contains utility functions to reduce the repetetiveness of ressponses, and a
+ * general-purpose mechanism for providing method implementations for testing
+ * using jest-fetch-mock.
+ *
+ */
+import { MockResponseInit } from 'jest-fetch-mock/types';
+import {
+ JSONRPC20Error,
+ JSONRPC20Id,
+ JSONRPC20Request,
+ JSONRPC20ResponseObject,
+ JSONRPC20Result,
+} from '../common/api/JSONRPC20';
+
+/**
+ * Constructs a JSON-RPC 2.0 repsonse object with a result.
+ */
+export function makeResultObject(
+ id: JSONRPC20Id,
+ result: JSONRPC20Result
+): JSONRPC20ResponseObject {
+ return {
+ jsonrpc: '2.0',
+ id,
+ result,
+ };
+}
+
+/**
+ * Construct a JSON-RPC 2.0 response with an error.
+ */
+export function makeErrorObject(
+ id: JSONRPC20Id,
+ error: JSONRPC20Error
+): JSONRPC20ResponseObject {
+ return {
+ jsonrpc: '2.0',
+ id,
+ error,
+ };
+}
+
+/**
+ * Convenience function to create a JSON-RPC 2.0 batch response, either result or error, within a
+ * jest-fetch-mock response
+ *
+ * JSON-RPC 2.0 has a batch mode, which can make multiple concurrent requests
+ * more efficient and faster. We don't currently use batch mode, at least in
+ * orcidlink, simply because it would take additional work to redesign the RTK
+ * query support.
+ *
+ * Batch mode, btw, is supported by the orcidlink service.
+ */
+export function makeBatchResponseObject(
+ result: Array
+): MockResponseInit {
+ return {
+ body: JSON.stringify(result),
+ status: 200,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ };
+}
+
+/**
+ * Convenience funciton to create a JSON-RPC 2.0 result response within a
+ * jest-fetch-mock response
+ */
+export function jsonrpc20_resultResponse(
+ id: JSONRPC20Id,
+ result: JSONRPC20Result
+): MockResponseInit {
+ return {
+ body: JSON.stringify(makeResultObject(id, result)),
+ status: 200,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ };
+}
+
+/**
+ * Convenience funciton to create a JSON-RPC 2.0 error response, either result or error, within a
+ * jest-fetch-mock response
+ */
+export function jsonrpc20_errorResponse(
+ id: JSONRPC20Id,
+ error: JSONRPC20Error
+): MockResponseInit {
+ return {
+ body: JSON.stringify(makeErrorObject(id, error)),
+ status: 200,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ };
+}
+
+/**
+ * Convenience funciton to create a JSON-RPC 2.0 response, either result or error, within a
+ * jest-fetch-mock response.
+ */
+export function jsonrpc20_response(
+ rpc: JSONRPC20ResponseObject | Array
+): MockResponseInit {
+ return {
+ body: JSON.stringify(rpc),
+ status: 200,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ };
+}
+
+/**
+ * The method spec is a mock implementation for one method in a service.
+ */
+export interface RPCMethodResultSpec {
+ path: string;
+ method: string;
+ result: (request: JSONRPC20Request) => JSONRPC20Result;
+}
+
+export interface RPCMethodErrorSpec {
+ path: string;
+ method: string;
+ error: (request: JSONRPC20Request) => JSONRPC20Error;
+}
+
+export type RPCMethodSpec = RPCMethodResultSpec | RPCMethodErrorSpec;
+
+// Determines a little pause in the handling of a request.
+const REQUEST_LATENCY = 300;
+
+/**
+ * Creates a general-purpose JSON-RPC 2.0 server to be used in tests via the
+ * "jest-fetch-mock" library. The "specs" parameter provides any methods to be implemented.
+ *
+ * Note that the "jest-fetch-mock" provides the "fetchMock" variable globally
+ * when "enableMocks()" is called in a test.
+ * See https://www.npmjs.com/package/jest-fetch-mock
+ */
+export function makeJSONRPC20Server(specs: Array) {
+ fetchMock.mockResponse(
+ async (request): Promise => {
+ const { pathname } = new URL(request.url);
+ // put a little delay in here so that we have a better
+ // chance of catching passing conditions, like loading.
+ await new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(null);
+ }, REQUEST_LATENCY);
+ });
+
+ if (request.method !== 'POST') {
+ return '';
+ }
+
+ const rpc = (await request.json()) as JSONRPC20Request;
+
+ const id = rpc.id;
+ if (!id) {
+ throw new Error('Id must be provided (we do not use notifications)');
+ }
+
+ for (const spec of specs) {
+ const { path, method } = spec;
+ if (!(path === pathname && method === rpc['method'])) {
+ continue;
+ }
+ if ('result' in spec) {
+ return jsonrpc20_resultResponse(id, spec.result(rpc));
+ } else {
+ return jsonrpc20_errorResponse(id, spec.error(rpc));
+ }
+ }
+
+ // If a service method is called, but is not mocked in the "specs", then
+ // this error should be displayed somewhere in the test failure. This is
+ // never an expected condition, and indicates that the mock server
+ // implementation is not complete, or a api call is incorrect during testing.
+ throw new Error(`NOT HANDLED: ${pathname}, ${rpc.method}`);
+ }
+ );
+}
+
+export type APIOverrides = Record<
+ string,
+ Record JSONRPC20ResponseObject>
+>;
+
+export function getOverride(
+ method: string,
+ param: string,
+ overrides: APIOverrides
+) {
+ if (!(method in overrides)) {
+ return;
+ }
+
+ const overrideMethod = overrides[method];
+
+ if (!(param in overrideMethod)) {
+ return;
+ }
+
+ return overrideMethod[param];
+}
diff --git a/src/features/orcidlink/test/mocks.ts b/src/features/orcidlink/test/mocks.ts
index d8ab8956..ca82490d 100644
--- a/src/features/orcidlink/test/mocks.ts
+++ b/src/features/orcidlink/test/mocks.ts
@@ -1,162 +1,15 @@
-import { MockResponseInit } from 'jest-fetch-mock/types';
-import { JSONRPC20Error } from '../../../common/api/utils/kbaseBaseQuery';
-import {
- LINK_RECORD_1,
- ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED,
- PROFILE_1,
- SERVICE_INFO_1,
-} from './data';
-
-export function jsonrpc20_resultResponse(id: string, result: unknown) {
- return {
- body: JSON.stringify({
- jsonrpc: '2.0',
- id,
- result,
- }),
- status: 200,
- headers: {
- 'content-type': 'application/json',
- },
- };
-}
-
-export function jsonrpc20_errorResponse(id: string, error: JSONRPC20Error) {
- return {
- body: JSON.stringify({
- jsonrpc: '2.0',
- id,
- error,
- }),
- status: 200,
- headers: {
- 'content-type': 'application/json',
- },
- };
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export function restResponse(result: any, status = 200) {
- return {
- body: JSON.stringify(result),
- status,
- headers: {
- 'content-type': 'application/json',
- },
- };
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export function mockIsLinkedResponse(body: any) {
- const username = body['params']['username'];
-
- const result = (() => {
- switch (username) {
- case 'foo':
- return true;
- case 'bar':
- return false;
- default:
- throw new Error('Invalid test value for username');
- }
- })();
- return jsonrpc20_resultResponse(body['id'], result);
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export function mockIsLinkedNotResponse(body: any) {
- return jsonrpc20_resultResponse(body['id'], false);
-}
-
-export function setupMockRegularUser() {
- fetchMock.mockResponse(
- async (request): Promise => {
- const { pathname } = new URL(request.url);
- // put a little delay in here so that we have a better
- // chance of catching temporary conditions, like loading.
- await new Promise((resolve) => {
- setTimeout(() => {
- resolve(null);
- }, 300);
- });
- switch (pathname) {
- // Mocks for the orcidlink api
- case '/services/orcidlink/api/v1': {
- if (request.method !== 'POST') {
- return '';
- }
- const body = await request.json();
- const id = body['id'];
- switch (body['method']) {
- case 'is-linked':
- // In this mock, user "foo" is linked, user "bar" is not.
- return jsonrpc20_resultResponse(id, mockIsLinkedResponse(body));
- case 'get-orcid-profile':
- // simulate fetching an orcid profile
- return jsonrpc20_resultResponse(id, PROFILE_1);
- case 'owner-link':
- // simulate fetching the link record for a user
- return jsonrpc20_resultResponse(id, LINK_RECORD_1);
- case 'info':
- // simulate getting service info.
- return jsonrpc20_resultResponse(id, SERVICE_INFO_1);
- default:
- return '';
- }
- }
- default:
- return '';
- }
- }
- );
-}
-
-export function setupMockRegularUserWithError() {
- fetchMock.mockResponse(
- async (request): Promise => {
- const { pathname } = new URL(request.url);
- // put a little delay in here so that we have a better
- // chance of catching temporary conditions, like loading.
- await new Promise((resolve) => {
- setTimeout(() => {
- resolve(null);
- }, 300);
- });
- switch (pathname) {
- // Mocks for the orcidlink api
- case '/services/orcidlink/api/v1': {
- if (request.method !== 'POST') {
- return '';
- }
- const body = await request.json();
- const id = body['id'] as string;
- switch (body['method']) {
- case 'is-linked':
- return jsonrpc20_errorResponse(
- id,
- ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED
- );
- case 'get-orcid-profile': {
- return jsonrpc20_errorResponse(
- id,
- ORCIDLINK_IS_LINKED_AUTHORIZATION_REQUIRED
- );
- }
- case 'owner-link':
- // simulate fetching the link record for a user
- return jsonrpc20_resultResponse(id, LINK_RECORD_1);
-
- case 'info':
- // simulate getting service info
- return jsonrpc20_resultResponse(id, SERVICE_INFO_1);
-
- default:
- return '';
- }
- }
- default:
- return '';
- }
- }
- );
+import ORCIDLinkAPI from '../common/api/ORCIDLInkAPI';
+import { ORCIDLINK_SERVICE_API_ENDPOINT } from '../constants';
+import { API_CALL_TIMEOUT } from './data';
+
+/**
+ * A convienence function to make an orcidlink service endpoint for tests.
+ *
+ * Note that the service endpoint should be computed
+ */
+export function makeORCIDLinkAPI(): ORCIDLinkAPI {
+ return new ORCIDLinkAPI({
+ timeout: API_CALL_TIMEOUT,
+ url: ORCIDLINK_SERVICE_API_ENDPOINT,
+ });
}
diff --git a/src/features/orcidlink/test/orcidlinkServiceMock.ts b/src/features/orcidlink/test/orcidlinkServiceMock.ts
new file mode 100644
index 00000000..4b4f67f4
--- /dev/null
+++ b/src/features/orcidlink/test/orcidlinkServiceMock.ts
@@ -0,0 +1,228 @@
+/**
+ * Mocks the orcidlink service for tests which rely upon the orcidlink service.
+ *
+ * Utilizes jest-fetch-mock to provide mock impelementations of orcidlink
+ * service methods.
+ */
+import 'core-js/actual/structured-clone';
+import { MockResponseInit } from 'jest-fetch-mock/types';
+import {
+ JSONRPC20ErrorResponseObject,
+ JSONRPC20Id,
+} from '../common/api/JSONRPC20';
+import ORCIDLinkAPI, {
+ ErrorInfo,
+ LinkingSessionPublicComplete,
+} from '../common/api/ORCIDLInkAPI';
+import {
+ ERROR_INFO_1,
+ LINKING_SESSION_1,
+ LINK_RECORD_1,
+ LINK_RECORD_OTHER_1,
+ PROFILE_1,
+ SERVICE_INFO_1,
+ STATUS_1,
+} from './data';
+import {
+ APIOverrides,
+ getOverride,
+ jsonrpc20_errorResponse,
+ jsonrpc20_response,
+ jsonrpc20_resultResponse,
+} from './jsonrpc20ServiceMock';
+
+export function makeError2(
+ id: JSONRPC20Id,
+ error: ErrorInfo
+): JSONRPC20ErrorResponseObject {
+ const { code, title } = error;
+ return {
+ jsonrpc: '2.0',
+ id,
+ error: {
+ code,
+ message: title,
+ },
+ };
+}
+
+export function makeOrcidlinkTestClient(): ORCIDLinkAPI {
+ return new ORCIDLinkAPI({
+ timeout: 1000,
+ url: 'http://localhost/services/orcidlink/api/v1',
+ });
+}
+
+export const orcidlinkErrors: Record = {
+ 1010: {
+ code: 1010,
+ title: 'Authorization Required',
+ description: '',
+ status_code: 100,
+ },
+};
+
+export function makeOrcidlinkServiceMock(overrides: APIOverrides = {}) {
+ return fetchMock.mockResponse(
+ async (request): Promise => {
+ const { pathname } = new URL(request.url);
+ // put a little delay in here so that we have a better
+ // chance of catching temporary conditions, like loading.
+ await new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(null);
+ }, 300);
+ });
+ switch (pathname) {
+ // Mocks for the orcidlink api
+ case '/services/orcidlink/api/v1': {
+ if (request.method !== 'POST') {
+ return '';
+ }
+ const body = await request.json();
+ const id = body['id'];
+ const method = body['method'];
+ const params = body['params'];
+ switch (method) {
+ case 'status':
+ return jsonrpc20_resultResponse(id, STATUS_1);
+ case 'info':
+ return jsonrpc20_resultResponse(id, SERVICE_INFO_1);
+ case 'error-info':
+ return jsonrpc20_resultResponse(id, ERROR_INFO_1);
+ case 'is-linked': {
+ // In this mock, user "foo" is linked, user "bar" is not.
+ // return jsonrpc20_resultResponse(id,
+ // mockIsLinkedResponse(body));
+ const username = params['username'] as unknown as string;
+
+ const override = getOverride(method, username, overrides);
+ if (override) {
+ return jsonrpc20_response(override(body));
+ }
+
+ switch (username) {
+ case 'foo':
+ return jsonrpc20_resultResponse(id, false);
+ case 'bar':
+ return jsonrpc20_resultResponse(id, true);
+ case 'not_json':
+ return {
+ body: 'bad',
+ status: 200,
+ headers: {
+ 'content-type': 'application/json',
+ },
+ };
+ default:
+ throw new Error('case not handled');
+ }
+ }
+ case 'get-orcid-profile':
+ // simulate fetching an orcid profile
+ return jsonrpc20_resultResponse(id, PROFILE_1);
+ case 'owner-link':
+ // simulate fetching the link record for a user
+ return jsonrpc20_resultResponse(id, LINK_RECORD_1);
+ case 'other-link':
+ // simulate fetching the link record for a user
+ return jsonrpc20_resultResponse(id, LINK_RECORD_OTHER_1);
+
+ case 'delete-own-link':
+ return jsonrpc20_resultResponse(id, null);
+
+ case 'get-linking-session': {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const sessionId = params['session_id'] as unknown as string;
+
+ switch (sessionId) {
+ case 'foo_session': {
+ const result =
+ structuredClone(
+ LINKING_SESSION_1
+ );
+ result.expires_at = Date.now() + 10000;
+ return jsonrpc20_resultResponse(id, result);
+ }
+ case 'foo_session_expired': {
+ const result =
+ structuredClone(
+ LINKING_SESSION_1
+ );
+ result.expires_at = Date.now() - 10000;
+ return jsonrpc20_resultResponse(id, result);
+ }
+ case 'foo_session2': {
+ return jsonrpc20_resultResponse(id, LINKING_SESSION_1);
+ }
+ case 'foo_session_error_1':
+ return jsonrpc20_errorResponse(id, {
+ code: 1010,
+ message: 'Authorization Required',
+ });
+ case 'not_a_session':
+ return jsonrpc20_errorResponse(id, {
+ code: 1020,
+ message: 'Not Found',
+ });
+ case 'bar_session':
+ return jsonrpc20_resultResponse(id, LINKING_SESSION_1);
+ default:
+ throw new Error('case not handled');
+ }
+ }
+
+ case 'create-linking-session': {
+ // const username = getObjectParam('username', params);
+ const params = body['params'];
+ const username = params['username'] as unknown as string;
+ switch (username) {
+ case 'foo':
+ return jsonrpc20_resultResponse(id, {
+ session_id: 'foo_session_id',
+ });
+ default:
+ throw new Error('case not handled');
+ }
+ }
+
+ case 'delete-linking-session': {
+ const sessionId = params['session_id'] as unknown as string;
+ const override = getOverride(method, sessionId, overrides);
+ if (override) {
+ return jsonrpc20_response(override(body));
+ }
+
+ switch (sessionId) {
+ case 'foo_session':
+ return jsonrpc20_resultResponse(id, null);
+ default:
+ throw new Error('case not handled');
+ }
+ }
+
+ case 'finish-linking-session': {
+ const sessionId = params['session_id'] as unknown as string;
+ const override = getOverride(method, sessionId, overrides);
+ if (override) {
+ return jsonrpc20_response(override(body));
+ }
+ switch (sessionId) {
+ case 'foo_session':
+ return jsonrpc20_resultResponse(id, null);
+ default: {
+ throw new Error('case not handled');
+ }
+ }
+ }
+
+ default:
+ throw new Error('case not handled');
+ }
+ }
+ default:
+ throw new Error('case not handled');
+ }
+ }
+ );
+}