-
Notifications
You must be signed in to change notification settings - Fork 26
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
Showing
3 changed files
with
301 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |