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

STCOR-932 implement useModuleFor(path) to map path -> module #1578

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* Don't override initial discovery and okapi data in test mocks. Refs STCOR-913.
* `<Logout>` must consume `QueryClient` in order to supply it to `loginServices::logout()`. Refs STCOR-907.
* On resuming session, spread session and `_self` together to preserve session values. Refs STCOR-912.
* Provide `useModuleFor(path)` hook that maps paths to their implementing modules. Refs STCOR-932.

## [10.2.0](https://github.com/folio-org/stripes-core/tree/v10.2.0) (2024-10-11)
[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.1...v10.2.0)
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export { getUserTenantsPermissions } from './src/queries';

/* Hooks */
export { useUserTenantPermissions } from './src/hooks';
export { useModuleFor } from './src/hooks';

/* misc */
export { supportedLocales } from './src/loginServices';
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { default as useUserTenantPermissions } from './useUserTenantPermissions'; // eslint-disable-line import/prefer-default-export
export { default as useUserTenantPermissions } from './useUserTenantPermissions';
export { default as useModuleFor } from './useModuleFor';

110 changes: 110 additions & 0 deletions src/hooks/useModuleFor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { useQuery } from 'react-query';

import { useStripes } from '../StripesContext';
import { useNamespace } from '../components';
import useOkapiKy from '../useOkapiKy';

/**
* map a module implementation string to a module-name, hopefully.
* given a string like "mod-users-16.2.0-SNAPSHOT.127" return "mod-users"
*/
const implToModule = (impl) => {
const moduleName = impl.match(/^(.*)-[0-9]+\.[0-9]+\.[0-9]+.*/);
return moduleName[1] ? moduleName[1] : '';
};

/**
* mapPathToImpl
* Remap the input datastructure from an array of modules (containing
* details about the interfaces they implement, and the paths handled by
* each interface) to a map from path to module.
*
* i.e. map from sth like
* [{
* provides: {
* handlers: [
* { pathPattern: "/foo", ... }
* { pathPattern: "/bar", ... }
* ]
* }
* }, ... ]
* to
* {
* foo: { ...impl },
* bar: { ...impl },
* }
* @param {object} impl
* @returns object
*/
const mapPathToImpl = (impl) => {
const moduleName = implToModule(impl.id);
const paths = {};
if (impl.provides) {
// not all interfaces actually implement routes, e.g. edge-connexion
// so those must be filtered out
impl.provides.filter(i => i.handlers).forEach(i => {
i.handlers.forEach(handler => {
if (!paths[handler.pathPattern]) {
paths[handler.pathPattern] = { name: moduleName, impl };
}
});
});
}
return paths;
};

/**
* canonicalPath
* Prepend a leading / if none is present.
* Strip everything after ?
* @param {string} str a string that represents a portion of a URL
* @returns {string}
*/
const canonicalPath = (str) => {
return `${str.startsWith('/') ? '' : '/'}${str.split('?')[0]}`;
};

/**
* useModuleFor
* Given a path, retrieve information about the module that implements it
* by querying the discovery endpoint /_/proxy/tenants/${tenant}/modules.
*
* @param {string} path
* @returns object shaped like { isFetching, isFetched, isLoading, module }
*/
const useModuleFor = (path) => {
const stripes = useStripes();
const ky = useOkapiKy();
const [namespace] = useNamespace({ key: `/_/proxy/tenants/${stripes.okapi.tenant}/modules` });
let paths = {};

const {
isFetching,
isFetched,
isLoading,
data,
} = useQuery(
[namespace],
({ signal }) => {
return ky.get(
`/_/proxy/tenants/${stripes.okapi.tenant}/modules?full=true`,
{ signal },
).json();
}
);

if (data) {
data.forEach(impl => {
paths = { ...paths, ...mapPathToImpl(impl) };
});
}

return ({
isFetching,
isFetched,
isLoading,
module: paths?.[canonicalPath(path)],
});
};

export default useModuleFor;
190 changes: 190 additions & 0 deletions src/hooks/useModuleFor.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react';
import {
QueryClient,
QueryClientProvider,
} from 'react-query';

import useModuleFor from './useModuleFor';
import useOkapiKy from '../useOkapiKy';

const response = [
{
'id': 'mod-users-19.4.5-SNAPSHOT.330',
'name': 'users',
'provides': [
{
'id': 'users',
'version': '16.3',
'handlers': [
{
'methods': [
'GET'
],
'pathPattern': '/users',
'permissionsRequired': [
'users.collection.get'
],
'permissionsDesired': [
'users.basic-read.execute',
'users.restricted-read.execute'
]
},
{
'methods': [
'GET'
],
'pathPattern': '/users/{id}',
'permissionsRequired': [
'users.item.get'
],
'permissionsDesired': [
'users.basic-read.execute',
'users.restricted-read.execute'
]
},
{
'methods': [
'POST'
],
'pathPattern': '/users',
'permissionsRequired': [
'users.item.post'
]
},
{
'methods': [
'GET'
],
'pathPattern': '/users/profile-picture/{id}',
'permissionsRequired': [
'users.profile-picture.item.get'
]
},
]
}
]
},
{
'id': 'mod-circulation-24.4.0',
'name': 'Circulation Module',
'provides': [
{
'id': 'requests-reports',
'version': '0.8',
'handlers': [
{
'methods': [
'GET'
],
'pathPattern': '/circulation/requests-reports/hold-shelf-clearance/{id}',
'permissionsRequired': [
'circulation.requests.hold-shelf-clearance-report.get'
],
'modulePermissions': [
'modperms.circulation.requests.hold-shelf-clearance-report.get'
]
}
]
},
{
'id': 'inventory-reports',
'version': '0.4',
'handlers': [
{
'methods': [
'GET'
],
'pathPattern': '/inventory-reports/items-in-transit',
'permissionsRequired': [
'circulation.inventory.items-in-transit-report.get'
],
'modulePermissions': [
'modperms.inventory.items-in-transit-report.get'
]
}
]
},
{
'id': 'pick-slips',
'version': '0.4',
'handlers': [
{
'methods': [
'GET'
],
'pathPattern': '/circulation/pick-slips/{servicePointId}',
'permissionsRequired': [
'circulation.pick-slips.get'
],
'modulePermissions': [
'modperms.circulation.pick-slips.get'
]
}
]
}
],
}
];

jest.mock('../useOkapiKy', () => ({
__esModule: true, // this property makes it work
default: () => ({
get: () => ({
json: () => response,
})
})
}));
jest.mock('../components', () => ({
useNamespace: () => ([]),
}));
jest.mock('../StripesContext', () => ({
useStripes: () => ({
okapi: {
tenant: 't',
}
}),
}));

const queryClient = new QueryClient();

// eslint-disable-next-line react/prop-types
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);


describe('useModuleFor', () => {
beforeEach(() => {
useOkapiKy.get = () => ({
json: () => console.log({ response })
});
});

describe('returns the module-name that provides the interface containing a given path', () => {
it('handles paths with leading /', async () => {
const { result } = renderHook(() => useModuleFor('/users'), { wrapper });
await waitFor(() => result.current.module.name);
expect(result.current.module.name).toEqual('mod-users');
});

it('handles paths without leading /', async () => {
const { result } = renderHook(() => useModuleFor('inventory-reports/items-in-transit'), { wrapper });
await waitFor(() => result.current.module.name);
expect(result.current.module.name).toEqual('mod-circulation');
});

it('ignores query string', async () => {
const { result } = renderHook(() => useModuleFor('/users?query=foo==bar'), { wrapper });
await waitFor(() => result.current.module.name);
expect(result.current.module.name).toEqual('mod-users');
});
});

it('returns undefined given an unmatched path', async () => {
const { result } = renderHook(() => useModuleFor('/monkey-bagel'), { wrapper });
await waitFor(() => result.current.module);
expect(result.current.module).toBeUndefined();
});
});