Skip to content

Commit

Permalink
STCOR-932 implement useModuleFor(path) to map path -> module
Browse files Browse the repository at this point in the history
Query discovery to retrieve information about the module implementing a
given endpoint. Simple usage:
```
const { module } = useModuleFor('/path');
// { name: 'mod-foo', ... }
```

Refs STCOR-932
  • Loading branch information
zburke committed Dec 23, 2024
1 parent 93297f6 commit 1644c97
Show file tree
Hide file tree
Showing 3 changed files with 301 additions and 0 deletions.
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
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();
});
});

0 comments on commit 1644c97

Please sign in to comment.