diff --git a/.eslintrc.js b/.eslintrc.js index ee3a664c9..48581dfb1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -52,6 +52,7 @@ module.exports = { 'unicorn/no-fn-reference-in-iterator': 'off', // this prevents some functional programming paradigms 'unicorn/no-object-as-default-parameter': 'off', 'unicorn/numeric-separators-style': 'off', + 'unicorn/prefer-ternary': 'off', // can get ugly with large single statements // Naming conventions '@typescript-eslint/naming-convention': [ diff --git a/index.ts b/index.ts index efcb58e7b..3108e053e 100644 --- a/index.ts +++ b/index.ts @@ -97,20 +97,27 @@ export * from './src/storage/conversion/TypedRepresentationConverter'; export * from './src/storage/patch/PatchHandler'; export * from './src/storage/patch/SparqlUpdatePatchHandler'; +// Storage/Routing +export * from './src/storage/routing/ConvertingRouterRule'; +export * from './src/storage/routing/PreferenceSupport'; +export * from './src/storage/routing/RegexRouterRule'; +export * from './src/storage/routing/RouterRule'; + // Storage export * from './src/storage/AtomicResourceStore'; export * from './src/storage/Conditions'; export * from './src/storage/ContainerManager'; export * from './src/storage/DataAccessorBasedStore'; export * from './src/storage/ExtensionBasedMapper'; +export * from './src/storage/FileIdentifierMapper'; export * from './src/storage/Lock'; export * from './src/storage/LockingResourceStore'; export * from './src/storage/PassthroughStore'; export * from './src/storage/PatchingStore'; export * from './src/storage/RepresentationConvertingStore'; export * from './src/storage/ResourceLocker'; -export * from './src/storage/FileIdentifierMapper'; export * from './src/storage/ResourceStore'; +export * from './src/storage/RoutingResourceStore'; export * from './src/storage/SingleThreadedResourceLocker'; export * from './src/storage/UrlContainerManager'; diff --git a/src/storage/RoutingResourceStore.ts b/src/storage/RoutingResourceStore.ts index ca31c6e17..190a2d9d6 100644 --- a/src/storage/RoutingResourceStore.ts +++ b/src/storage/RoutingResourceStore.ts @@ -2,12 +2,16 @@ import type { Patch } from '../ldp/http/Patch'; import type { Representation } from '../ldp/representation/Representation'; import type { RepresentationPreferences } from '../ldp/representation/RepresentationPreferences'; import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; +import { UnsupportedHttpError } from '../util/errors/UnsupportedHttpError'; import type { Conditions } from './Conditions'; import type { ResourceStore } from './ResourceStore'; import type { RouterRule } from './routing/RouterRule'; /** - * Store that calls a specific store based on certain routing defined by the ResourceRouter. + * Store that routes the incoming request to a specific store based on the stored ResourceRouter. + * In case no store was found for one of the functions that take no data (GET/PATCH/DELETE), + * a 404 will be thrown. In the other cases the error of the router will be thrown (which would probably be 400). */ export class RoutingResourceStore implements ResourceStore { private readonly rule: RouterRule; @@ -18,28 +22,41 @@ export class RoutingResourceStore implements ResourceStore { public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences, conditions?: Conditions): Promise { - return (await this.rule.getMatchingResourceStore(identifier)) - .getRepresentation(identifier, preferences, conditions); + return (await this.getStore(identifier)).getRepresentation(identifier, preferences, conditions); } public async addResource(container: ResourceIdentifier, representation: Representation, conditions?: Conditions): Promise { - return (await this.rule.getMatchingResourceStore(container, representation)) - .addResource(container, representation, conditions); + return (await this.getStore(container, representation)).addResource(container, representation, conditions); } public async setRepresentation(identifier: ResourceIdentifier, representation: Representation, conditions?: Conditions): Promise { - return (await this.rule.getMatchingResourceStore(identifier, representation)) - .setRepresentation(identifier, representation, conditions); + return (await this.getStore(identifier, representation)).setRepresentation(identifier, representation, conditions); } public async deleteResource(identifier: ResourceIdentifier, conditions?: Conditions): Promise { - return (await this.rule.getMatchingResourceStore(identifier)).deleteResource(identifier, conditions); + return (await this.getStore(identifier)).deleteResource(identifier, conditions); } public async modifyResource(identifier: ResourceIdentifier, patch: Patch, conditions?: Conditions): Promise { - return (await this.rule.getMatchingResourceStore(identifier)).modifyResource(identifier, patch, conditions); + return (await this.getStore(identifier)).modifyResource(identifier, patch, conditions); + } + + private async getStore(identifier: ResourceIdentifier, representation?: Representation): Promise { + if (representation) { + return this.rule.handleSafe({ identifier, representation }); + } + + // In case there is no incoming data we want to return 404 if no store was found + try { + return await this.rule.handleSafe({ identifier }); + } catch (error: unknown) { + if (error instanceof UnsupportedHttpError) { + throw new NotFoundHttpError(); + } + throw error; + } } } diff --git a/src/storage/routing/ConvertingRouterRule.ts b/src/storage/routing/ConvertingRouterRule.ts new file mode 100644 index 000000000..b916372a9 --- /dev/null +++ b/src/storage/routing/ConvertingRouterRule.ts @@ -0,0 +1,80 @@ +import type { Representation } from '../../ldp/representation/Representation'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; +import type { ResourceStore } from '../ResourceStore'; +import type { PreferenceSupport } from './PreferenceSupport'; +import { RouterRule } from './RouterRule'; + +export interface ConvertingStoreEntry { + store: ResourceStore; + supportChecker: PreferenceSupport; +} + +/** + * Rule that directs requests based on how the data would need to be converted. + * In case the given converter can convert the data to the requested type, + * it will be directed to the `convertStore`. + * Otherwise the `defaultStore` will be chosen. + * + * In case there is no data and only an identifier the `defaultStore` will be checked + * if it contains the given identifier. + * If not, the `convertStore` will be returned. + */ +export class ConvertingRouterRule extends RouterRule { + private readonly typedStores: ConvertingStoreEntry[]; + private readonly defaultStore: ResourceStore; + + public constructor(typedStores: ConvertingStoreEntry[], defaultStore: ResourceStore) { + super(); + this.typedStores = typedStores; + this.defaultStore = defaultStore; + } + + public async handle(input: { identifier: ResourceIdentifier; representation?: Representation }): + Promise { + const { identifier, representation } = input; + let store: ResourceStore; + if (representation) { + // TS type checking is not smart enough to let us reuse the input object + store = await this.findStore(async(entry): Promise => + entry.supportChecker.supports({ identifier, representation })); + } else { + // No content-type given so we can only check if one of the stores has data for the identifier + store = await this.findStore(async(entry): Promise => + this.hasResource(entry.store, input.identifier)); + } + return store; + } + + /** + * Helper function that checks if the given store contains the given identifier or not. + */ + private async hasResource(store: ResourceStore, identifier: ResourceIdentifier): Promise { + try { + const response = await store.getRepresentation(identifier, {}); + response.data.destroy(); + return true; + } catch (error: unknown) { + if (error instanceof NotFoundHttpError) { + return false; + } + throw error; + } + } + + /** + * Helper function that runs the given callback function for all the stores + * and returns the first one that does not throw an error. + * + * Returns the default store if no match was found. + */ + private async findStore(supports: (entry: ConvertingStoreEntry) => Promise): Promise { + // Try all the stores, return default if there is no match + for (const entry of this.typedStores) { + if (await supports(entry)) { + return entry.store; + } + } + return this.defaultStore; + } +} diff --git a/src/storage/routing/PathRouterRule.ts b/src/storage/routing/PathRouterRule.ts deleted file mode 100644 index 4eafa09cb..000000000 --- a/src/storage/routing/PathRouterRule.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Representation } from '../../ldp/representation/Representation'; -import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; -import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError'; -import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; -import type { ResourceStore } from '../ResourceStore'; -import type { RouterRule } from './RouterRule'; - -// TODO: -export class PathRouterRule implements RouterRule { - private readonly pathMap: { [path: string]: ResourceStore }; - - public constructor(pathMap: { [path: string]: ResourceStore }) { - this.pathMap = pathMap; - } - - public async getMatchingResourceStore(identifier: ResourceIdentifier, representation?: Representation): - Promise { - const paths = Object.keys(this.pathMap); - const matches = paths.filter((path): boolean => identifier.path.includes(path)); - if (matches.length !== 1) { - // Incoming data, need to reject - if (representation) { - throw new UnsupportedHttpError( - `Identifiers need to have exactly 1 of the following in them: [${paths.join(', ')}]`, - ); - - // Because of the above requirement, we know this will always be a 404 for requests - } else { - throw new NotFoundHttpError(); - } - } - - return this.pathMap[matches[0]]; - } -} diff --git a/src/storage/routing/PreferenceSupport.ts b/src/storage/routing/PreferenceSupport.ts new file mode 100644 index 000000000..67d1c1d7a --- /dev/null +++ b/src/storage/routing/PreferenceSupport.ts @@ -0,0 +1,31 @@ +import type { Representation } from '../../ldp/representation/Representation'; +import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import type { RepresentationConverter } from '../conversion/RepresentationConverter'; + +/** + * Helper class that checks if the stored {@link RepresentationConverter} and {@link RepresentationPreferences} + * support the given input {@link RepresentationPreferences} and {@link Representation}. + * + * Creates a new object by combining the input arguments together with the stored preferences and checks + * if the converter can handle that object. + */ +export class PreferenceSupport { + private readonly preferences: RepresentationPreferences; + private readonly converter: RepresentationConverter; + + public constructor(preferences: RepresentationPreferences, converter: RepresentationConverter) { + this.preferences = preferences; + this.converter = converter; + } + + public async supports(input: { identifier: ResourceIdentifier; representation: Representation }): Promise { + const newInput = { ...input, preferences: this.preferences }; + try { + await this.converter.canHandle(newInput); + return true; + } catch { + return false; + } + } +} diff --git a/src/storage/routing/RdfConvertingRouterRule.ts b/src/storage/routing/RdfConvertingRouterRule.ts deleted file mode 100644 index cf3a363a3..000000000 --- a/src/storage/routing/RdfConvertingRouterRule.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Representation } from '../../ldp/representation/Representation'; -import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; -import { INTERNAL_QUADS } from '../../util/ContentTypes'; -import type { RepresentationConverter } from '../conversion/RepresentationConverter'; -import type { ResourceStore } from '../ResourceStore'; -import type { RouterRule } from './RouterRule'; - -// TODO: -export class RdfConvertingRouterRule implements RouterRule { - private readonly rdfStore: ResourceStore; - private readonly binaryStore: ResourceStore; - private readonly converter: RepresentationConverter; - - public constructor(rdfStore: ResourceStore, binaryStore: ResourceStore, converter: RepresentationConverter) { - this.rdfStore = rdfStore; - this.binaryStore = binaryStore; - this.converter = converter; - } - - public async getMatchingResourceStore(identifier: ResourceIdentifier, representation?: Representation): - Promise { - if (representation) { - try { - const preferences = { type: [{ value: INTERNAL_QUADS, weight: 1 }]}; - await this.converter.canHandle({ identifier, representation, preferences }); - return this.rdfStore; - } catch { - return this.binaryStore; - } - } else { - // No content-type given so we can only check if one of the stores has data for the identifier - // Any of the two stores can be used. Using the binary one here since that one would be faster in current cases. - try { - const response = await this.binaryStore.getRepresentation(identifier, {}); - response.data.destroy(); - return this.binaryStore; - } catch { - return this.rdfStore; - } - } - } -} diff --git a/src/storage/routing/RegexRouterRule.ts b/src/storage/routing/RegexRouterRule.ts new file mode 100644 index 000000000..a72296058 --- /dev/null +++ b/src/storage/routing/RegexRouterRule.ts @@ -0,0 +1,61 @@ +import type { Representation } from '../../ldp/representation/Representation'; +import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { UnsupportedHttpError } from '../../util/errors/UnsupportedHttpError'; +import { trimTrailingSlashes } from '../../util/Util'; +import type { ResourceStore } from '../ResourceStore'; +import { RouterRule } from './RouterRule'; + +/** + * Routes requests to a store based on the path of the identifier. + * The identifier will be stripped of the base URI after which regexes will be used to find the correct store. + * The trailing slash of the base URI will still be present so the first character a regex can match would be that `/`. + * This way regexes such as `/container/` can match containers in any position. + * + * In case none of the regexes match an error will be thrown. + */ +export class RegexRouterRule extends RouterRule { + private readonly base: string; + private readonly regexes: Map; + + /** + * The keys of the `storeMap` will be converted into actual RegExp objects that will be used for testing. + */ + public constructor(base: string, storeMap: Record) { + super(); + this.base = trimTrailingSlashes(base); + this.regexes = new Map(Object.keys(storeMap).map((regex): [ RegExp, ResourceStore ] => + [ new RegExp(regex, 'u'), storeMap[regex] ])); + } + + public async canHandle(input: { identifier: ResourceIdentifier; representation?: Representation }): Promise { + this.matchStore(input.identifier); + } + + public async handle(input: { identifier: ResourceIdentifier }): Promise { + return this.matchStore(input.identifier); + } + + /** + * Finds the store corresponding to the regex that matches the given identifier. + * Throws an error if none is found. + */ + private matchStore(identifier: ResourceIdentifier): ResourceStore { + const path = this.toRelative(identifier); + for (const regex of this.regexes.keys()) { + if (regex.test(path)) { + return this.regexes.get(regex)!; + } + } + throw new UnsupportedHttpError(`No stored regexes match ${identifier.path}`); + } + + /** + * Strips the base of the identifier and throws an error if there is no overlap. + */ + private toRelative(identifier: ResourceIdentifier): string { + if (!identifier.path.startsWith(this.base)) { + throw new UnsupportedHttpError(`Identifiers need to start with ${this.base}`); + } + return identifier.path.slice(this.base.length); + } +} diff --git a/src/storage/routing/RouterRule.ts b/src/storage/routing/RouterRule.ts index 88ae4b841..83cadd8e6 100644 --- a/src/storage/routing/RouterRule.ts +++ b/src/storage/routing/RouterRule.ts @@ -1,20 +1,11 @@ import type { Representation } from '../../ldp/representation/Representation'; import type { ResourceIdentifier } from '../../ldp/representation/ResourceIdentifier'; +import { AsyncHandler } from '../../util/AsyncHandler'; import type { ResourceStore } from '../ResourceStore'; /** - * A RouterRule represents a rule that decides which instance of a - * ResourceStore should be used to handle the incoming request. + * Finds which store needs to be accessed for the given resource, + * potentially based on the Representation of incoming data. */ -export interface RouterRule { - - /** - * Find the appropriate ResourceStore to which the request should be routed based on the incoming parameters. - * @param identifier - Incoming ResourceIdentifier. - * @param representation - Optional incoming Representation. - */ - getMatchingResourceStore: ( - identifier: ResourceIdentifier, - representation?: Representation, - ) => Promise; -} +export abstract class RouterRule + extends AsyncHandler<{ identifier: ResourceIdentifier; representation?: Representation }, ResourceStore> {} diff --git a/test/unit/storage/RoutingResourceStore.test.ts b/test/unit/storage/RoutingResourceStore.test.ts new file mode 100644 index 000000000..c0f0104b3 --- /dev/null +++ b/test/unit/storage/RoutingResourceStore.test.ts @@ -0,0 +1,77 @@ +import type { ResourceStore } from '../../../src/storage/ResourceStore'; +import { RoutingResourceStore } from '../../../src/storage/RoutingResourceStore'; +import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; +import { UnsupportedHttpError } from '../../../src/util/errors/UnsupportedHttpError'; +import { StaticAsyncHandler } from '../../util/StaticAsyncHandler'; + +describe('A RoutingResourceStore', (): void => { + let store: RoutingResourceStore; + let source: ResourceStore; + let rule: StaticAsyncHandler; + const identifier = { path: 'identifier' }; + + beforeEach(async(): Promise => { + source = { + getRepresentation: jest.fn(), + addResource: jest.fn(), + setRepresentation: jest.fn(), + modifyResource: jest.fn(), + deleteResource: jest.fn(), + }; + + rule = new StaticAsyncHandler(true, source); + + store = new RoutingResourceStore(rule); + }); + + it('calls getRepresentation on the resulting store.', async(): Promise => { + await expect(store.getRepresentation(identifier, 'preferences' as any, 'conditions' as any)) + .resolves.toBeUndefined(); + expect(source.getRepresentation).toHaveBeenCalledTimes(1); + expect(source.getRepresentation).toHaveBeenLastCalledWith(identifier, 'preferences', 'conditions'); + }); + + it('calls addRepresentation on the resulting store.', async(): Promise => { + await expect(store.addResource(identifier, 'representation' as any, 'conditions' as any)) + .resolves.toBeUndefined(); + expect(source.addResource).toHaveBeenCalledTimes(1); + expect(source.addResource).toHaveBeenLastCalledWith(identifier, 'representation', 'conditions'); + }); + + it('calls setRepresentation on the resulting store.', async(): Promise => { + await expect(store.setRepresentation(identifier, 'representation' as any, 'conditions' as any)) + .resolves.toBeUndefined(); + expect(source.setRepresentation).toHaveBeenCalledTimes(1); + expect(source.setRepresentation).toHaveBeenLastCalledWith(identifier, 'representation', 'conditions'); + }); + + it('calls modifyResource on the resulting store.', async(): Promise => { + await expect(store.modifyResource(identifier, 'patch' as any, 'conditions' as any)) + .resolves.toBeUndefined(); + expect(source.modifyResource).toHaveBeenCalledTimes(1); + expect(source.modifyResource).toHaveBeenLastCalledWith(identifier, 'patch', 'conditions'); + }); + + it('calls deleteResource on the resulting store.', async(): Promise => { + await expect(store.deleteResource(identifier, 'conditions' as any)) + .resolves.toBeUndefined(); + expect(source.deleteResource).toHaveBeenCalledTimes(1); + expect(source.deleteResource).toHaveBeenLastCalledWith(identifier, 'conditions'); + }); + + it('throws a 404 if there is no body and no store was found.', async(): Promise => { + rule.canHandle = (): any => { + throw new UnsupportedHttpError(); + }; + await expect(store.getRepresentation(identifier, 'preferences' as any, 'conditions' as any)) + .rejects.toThrow(NotFoundHttpError); + }); + + it('re-throws the error if something went wrong.', async(): Promise => { + rule.canHandle = (): any => { + throw new Error('error'); + }; + await expect(store.getRepresentation(identifier, 'preferences' as any, 'conditions' as any)) + .rejects.toThrow(new Error('error')); + }); +}); diff --git a/test/unit/storage/routing/ConvertingRouterRule.test.ts b/test/unit/storage/routing/ConvertingRouterRule.test.ts new file mode 100644 index 000000000..34233b0e5 --- /dev/null +++ b/test/unit/storage/routing/ConvertingRouterRule.test.ts @@ -0,0 +1,81 @@ +import type { Readable } from 'stream'; +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata'; +import type { ResourceStore } from '../../../../src/storage/ResourceStore'; +import { ConvertingRouterRule } from '../../../../src/storage/routing/ConvertingRouterRule'; +import type { PreferenceSupport } from '../../../../src/storage/routing/PreferenceSupport'; +import { InternalServerError } from '../../../../src/util/errors/InternalServerError'; +import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError'; + +describe('A ConvertingRouterRule', (): void => { + let store1: ResourceStore; + let store2: ResourceStore; + let defaultStore: ResourceStore; + let checker1: PreferenceSupport; + let checker2: PreferenceSupport; + let rule: ConvertingRouterRule; + let representation: Representation; + let metadata: RepresentationMetadata; + + beforeEach(async(): Promise => { + store1 = { name: 'turtleStore' } as any; + store2 = { name: 'textStore' } as any; + defaultStore = { name: 'defaultStore' } as any; + + checker1 = { + async supports(input: { representation: Representation }): Promise { + return input.representation.metadata.contentType === 'text/turtle'; + }, + } as any; + checker2 = { + async supports(input: { representation: Representation }): Promise { + return input.representation.metadata.contentType === 'application/ld+json'; + }, + } as any; + + rule = new ConvertingRouterRule([{ store: store1, supportChecker: checker1 }, + { store: store2, supportChecker: checker2 }], defaultStore); + + metadata = new RepresentationMetadata(); + representation = { binary: true, data: 'data!' as any, metadata }; + }); + + it('returns the corresponding store if it supports the input.', async(): Promise => { + metadata.contentType = 'text/turtle'; + await expect(rule.handle({ identifier: { path: 'identifier' }, representation })).resolves.toBe(store1); + + metadata.contentType = 'application/ld+json'; + await expect(rule.handle({ identifier: { path: 'identifier' }, representation })).resolves.toBe(store2); + }); + + it('returns the defaultStore if the converter does not support the input.', async(): Promise => { + await expect(rule.handle({ identifier: { path: 'identifier' }, representation })).resolves.toBe(defaultStore); + }); + + it('checks if the stores contain the identifier if there is no data.', async(): Promise => { + const data: Readable = { destroy: jest.fn() } as any; + store1.getRepresentation = (): any => { + throw new NotFoundHttpError(); + }; + store2.getRepresentation = async(): Promise => ({ data } as any); + await expect(rule.handle({ identifier: { path: 'identifier' }})).resolves.toBe(store2); + expect(data.destroy).toHaveBeenCalledTimes(1); + }); + + it('returns the defaultStore if no other store has the resource.', async(): Promise => { + store1.getRepresentation = (): any => { + throw new NotFoundHttpError(); + }; + store2.getRepresentation = (): any => { + throw new NotFoundHttpError(); + }; + await expect(rule.handle({ identifier: { path: 'identifier' }})).resolves.toBe(defaultStore); + }); + + it('throws the error if a store had a non-404 error.', async(): Promise => { + store1.getRepresentation = (): any => { + throw new InternalServerError(); + }; + await expect(rule.handle({ identifier: { path: 'identifier' }})).rejects.toThrow(InternalServerError); + }); +}); diff --git a/test/unit/storage/routing/PreferenceSupport.test.ts b/test/unit/storage/routing/PreferenceSupport.test.ts new file mode 100644 index 000000000..4498e3f53 --- /dev/null +++ b/test/unit/storage/routing/PreferenceSupport.test.ts @@ -0,0 +1,35 @@ +import type { Representation } from '../../../../src/ldp/representation/Representation'; +import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences'; +import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier'; +import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; +import { PreferenceSupport } from '../../../../src/storage/routing/PreferenceSupport'; +import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError'; + +describe('A PreferenceSupport', (): void => { + let preferences: RepresentationPreferences; + let converter: RepresentationConverter; + let support: PreferenceSupport; + const identifier: ResourceIdentifier = 'identifier' as any; + const representation: Representation = 'representation' as any; + + beforeEach(async(): Promise => { + preferences = { type: []}; + converter = { canHandle: jest.fn() } as any; + support = new PreferenceSupport(preferences, converter); + }); + + it('returns true if the converter supports the input.', async(): Promise => { + await expect(support.supports({ identifier, representation })).resolves.toBe(true); + expect(converter.canHandle).toHaveBeenCalledTimes(1); + expect(converter.canHandle).toHaveBeenLastCalledWith({ identifier, representation, preferences }); + }); + + it('returns false if the converter does not support the input.', async(): Promise => { + converter.canHandle = jest.fn((): any => { + throw new UnsupportedHttpError(); + }); + await expect(support.supports({ identifier, representation })).resolves.toBe(false); + expect(converter.canHandle).toHaveBeenCalledTimes(1); + expect(converter.canHandle).toHaveBeenLastCalledWith({ identifier, representation, preferences }); + }); +}); diff --git a/test/unit/storage/routing/RegexRouterRule.test.ts b/test/unit/storage/routing/RegexRouterRule.test.ts new file mode 100644 index 000000000..fa611c2ac --- /dev/null +++ b/test/unit/storage/routing/RegexRouterRule.test.ts @@ -0,0 +1,32 @@ +import type { ResourceStore } from '../../../../src/storage/ResourceStore'; +import { RegexRouterRule } from '../../../../src/storage/routing/RegexRouterRule'; +import { UnsupportedHttpError } from '../../../../src/util/errors/UnsupportedHttpError'; + +describe('A RegexRouterRule', (): void => { + const base = 'http://test.com/'; + const store: ResourceStore = 'resourceStore' as any; + + it('rejects identifiers not containing the base.', async(): Promise => { + const router = new RegexRouterRule(base, {}); + await expect(router.canHandle({ identifier: { path: 'http://notTest.com/apple' }})) + .rejects.toThrow(new UnsupportedHttpError(`Identifiers need to start with http://test.com`)); + }); + + it('rejects identifiers not matching any regex.', async(): Promise => { + const router = new RegexRouterRule(base, { pear: store }); + await expect(router.canHandle({ identifier: { path: `${base}apple/` }})) + .rejects.toThrow(new UnsupportedHttpError(`No stored regexes match http://test.com/apple/`)); + }); + + it('accepts identifiers matching any regex.', async(): Promise => { + const router = new RegexRouterRule(base, { '^/apple': store }); + await expect(router.canHandle({ identifier: { path: `${base}apple/` }})) + .resolves.toBeUndefined(); + }); + + it('returns the corresponding store.', async(): Promise => { + const store2: ResourceStore = 'resourceStore2' as any; + const router = new RegexRouterRule(base, { '^/apple': store2, '/pear/': store }); + await expect(router.handle({ identifier: { path: `${base}apple/` }})).resolves.toBe(store2); + }); +});