From 1644c97d03a858abe3da45f699d88e76e2615348 Mon Sep 17 00:00:00 2001 From: Zak Burke Date: Mon, 23 Dec 2024 10:11:53 -0500 Subject: [PATCH] STCOR-932 implement useModuleFor(path) to map path -> module Query discovery to retrieve information about the module implementing a given endpoint. Simple usage: ``` const { module } = useModuleFor('/path'); // { name: 'mod-foo', ... } ``` Refs STCOR-932 --- CHANGELOG.md | 1 + src/hooks/useModuleFor.js | 110 +++++++++++++++++++ src/hooks/useModuleFor.test.js | 190 +++++++++++++++++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 src/hooks/useModuleFor.js create mode 100644 src/hooks/useModuleFor.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6032c8b1..916675fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Don't override initial discovery and okapi data in test mocks. Refs STCOR-913. * `` 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) diff --git a/src/hooks/useModuleFor.js b/src/hooks/useModuleFor.js new file mode 100644 index 00000000..e2978e02 --- /dev/null +++ b/src/hooks/useModuleFor.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]}`; +}; + +/** + * 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; diff --git a/src/hooks/useModuleFor.test.js b/src/hooks/useModuleFor.test.js new file mode 100644 index 00000000..ab804624 --- /dev/null +++ b/src/hooks/useModuleFor.test.js @@ -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 }) => ( + + {children} + +); + + +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(); + }); +});