diff --git a/CHANGELOG.md b/CHANGELOG.md index 984f777b..043f06d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * `` 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. * Fetch `/saml/check` when starting a new session, i.e. before discovery. Refs STCOR-933, STCOR-816. +* Provide `useModuleInfo(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) diff --git a/index.js b/index.js index 3a9510d4..ba6c88b3 100644 --- a/index.js +++ b/index.js @@ -42,6 +42,7 @@ export { getUserTenantsPermissions } from './src/queries'; /* Hooks */ export { useUserTenantPermissions } from './src/hooks'; +export { useModuleInfo } from './src/hooks'; /* misc */ export { supportedLocales } from './src/loginServices'; diff --git a/src/hooks/index.js b/src/hooks/index.js index 8a889a1b..27142732 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -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 useModuleInfo } from './useModuleInfo'; + diff --git a/src/hooks/useModuleInfo.js b/src/hooks/useModuleInfo.js new file mode 100644 index 00000000..beb6e435 --- /dev/null +++ b/src/hooks/useModuleInfo.js @@ -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]}`; +}; + +/** + * useModuleInfo + * 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 useModuleInfo = (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 useModuleInfo; diff --git a/src/hooks/useModuleInfo.test.js b/src/hooks/useModuleInfo.test.js new file mode 100644 index 00000000..8413a9ef --- /dev/null +++ b/src/hooks/useModuleInfo.test.js @@ -0,0 +1,190 @@ +import { renderHook, waitFor } from '@folio/jest-config-stripes/testing-library/react'; +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; + +import useModuleInfo from './useModuleInfo'; +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 }) => ( + + {children} + +); + + +describe('useModuleInfo', () => { + 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(() => useModuleInfo('/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(() => useModuleInfo('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(() => useModuleInfo('/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(() => useModuleInfo('/monkey-bagel'), { wrapper }); + await waitFor(() => result.current.module); + expect(result.current.module).toBeUndefined(); + }); +});