mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add IndexRepresentationStore to support index resources
This commit is contained in:
parent
4e0c8291ca
commit
cc1e332394
@ -14,6 +14,8 @@
|
|||||||
"@id": "urn:solid-server:default:ResourceStore",
|
"@id": "urn:solid-server:default:ResourceStore",
|
||||||
"@type": "MonitoringStore",
|
"@type": "MonitoringStore",
|
||||||
"MonitoringStore:_source": {
|
"MonitoringStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Index",
|
||||||
|
"IndexRepresentationStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
||||||
"LockingResourceStore:_source": {
|
"LockingResourceStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
||||||
@ -26,6 +28,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"comment": "Which store to use for requests that do not match any pod, e.g. for storage.",
|
"comment": "Which store to use for requests that do not match any pod, e.g. for storage.",
|
||||||
|
@ -13,6 +13,8 @@
|
|||||||
"@id": "urn:solid-server:default:ResourceStore",
|
"@id": "urn:solid-server:default:ResourceStore",
|
||||||
"@type": "MonitoringStore",
|
"@type": "MonitoringStore",
|
||||||
"MonitoringStore:_source": {
|
"MonitoringStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Index",
|
||||||
|
"IndexRepresentationStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
||||||
"LockingResourceStore:_source": {
|
"LockingResourceStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
||||||
@ -28,5 +30,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,8 @@
|
|||||||
"@id": "urn:solid-server:default:ResourceStore",
|
"@id": "urn:solid-server:default:ResourceStore",
|
||||||
"@type": "MonitoringStore",
|
"@type": "MonitoringStore",
|
||||||
"MonitoringStore:_source": {
|
"MonitoringStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Index",
|
||||||
|
"IndexRepresentationStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
||||||
"LockingResourceStore:_source": {
|
"LockingResourceStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
||||||
@ -28,6 +30,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,8 @@
|
|||||||
"@id": "urn:solid-server:default:ResourceStore",
|
"@id": "urn:solid-server:default:ResourceStore",
|
||||||
"@type": "MonitoringStore",
|
"@type": "MonitoringStore",
|
||||||
"MonitoringStore:_source": {
|
"MonitoringStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Index",
|
||||||
|
"IndexRepresentationStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
||||||
"LockingResourceStore:_source": {
|
"LockingResourceStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
||||||
@ -26,6 +28,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
@ -13,13 +13,17 @@
|
|||||||
"@id": "urn:solid-server:default:ResourceStore",
|
"@id": "urn:solid-server:default:ResourceStore",
|
||||||
"@type": "MonitoringStore",
|
"@type": "MonitoringStore",
|
||||||
"MonitoringStore:_source": {
|
"MonitoringStore:_source": {
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Index",
|
||||||
|
"IndexRepresentationStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
"@id": "urn:solid-server:default:ResourceStore_Locking",
|
||||||
"LockingResourceStore:_source": {
|
"LockingResourceStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
"@id": "urn:solid-server:default:ResourceStore_Patching",
|
||||||
"PatchingStore:_source": {
|
"PatchingStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_Converting",
|
"@id": "urn:solid-server:default:ResourceStore_Converting",
|
||||||
"comment": "This makes it so all incoming data is converted to quad objects.",
|
"comment": "This makes it so all incoming data is converted to quad objects.",
|
||||||
"RepresentationConvertingStore:_options_inConverter": { "@id": "urn:solid-server:default:RepresentationConverter" },
|
"RepresentationConvertingStore:_options_inConverter": {
|
||||||
|
"@id": "urn:solid-server:default:RepresentationConverter"
|
||||||
|
},
|
||||||
"RepresentationConvertingStore:_options_inType": "internal/quads",
|
"RepresentationConvertingStore:_options_inType": "internal/quads",
|
||||||
"RepresentationConvertingStore:_source": {
|
"RepresentationConvertingStore:_source": {
|
||||||
"@id": "urn:solid-server:default:ResourceStore_DataAccessor",
|
"@id": "urn:solid-server:default:ResourceStore_DataAccessor",
|
||||||
@ -31,5 +35,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
|
||||||
"@graph": [
|
"@graph": [
|
||||||
|
{
|
||||||
|
"comment": "When a container with an index.html document is accessed, serve that HTML document instead of the container.",
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Index",
|
||||||
|
"@type": "IndexRepresentationStore"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"comment": "This value can be used to set a custom handler for index files. See the example file.",
|
"comment": "This value can be used to set a custom handler for index files. See the example file.",
|
||||||
"@id": "urn:solid-server:default:DefaultUiConverter",
|
"@id": "urn:solid-server:default:DefaultUiConverter",
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
|
||||||
"@graph": [
|
"@graph": [
|
||||||
|
{
|
||||||
|
"comment": "This an example on how to disable the routing to index.html when accessing a container.",
|
||||||
|
"@id": "urn:solid-server:default:ResourceStore_Index",
|
||||||
|
"@type": "PassthroughStore"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"comment": [
|
"comment": [
|
||||||
"This converter replaces every browser request for text/html with the Databrowser UI (which then in turn loads a Turtle or other representation of the same resource).",
|
"This converter replaces every browser request for text/html with the Databrowser UI (which then in turn loads a Turtle or other representation of the same resource).",
|
||||||
|
@ -265,6 +265,7 @@ export * from './storage/AtomicResourceStore';
|
|||||||
export * from './storage/BaseResourceStore';
|
export * from './storage/BaseResourceStore';
|
||||||
export * from './storage/Conditions';
|
export * from './storage/Conditions';
|
||||||
export * from './storage/DataAccessorBasedStore';
|
export * from './storage/DataAccessorBasedStore';
|
||||||
|
export * from './storage/IndexRepresentationStore';
|
||||||
export * from './storage/LockingResourceStore';
|
export * from './storage/LockingResourceStore';
|
||||||
export * from './storage/MonitoringStore';
|
export * from './storage/MonitoringStore';
|
||||||
export * from './storage/PassthroughStore';
|
export * from './storage/PassthroughStore';
|
||||||
|
64
src/storage/IndexRepresentationStore.ts
Normal file
64
src/storage/IndexRepresentationStore.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'assert';
|
||||||
|
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 { isContainerIdentifier } from '../util/PathUtil';
|
||||||
|
import type { Conditions } from './Conditions';
|
||||||
|
import { cleanPreferences, matchesMediaType } from './conversion/ConversionUtil';
|
||||||
|
import { PassthroughStore } from './PassthroughStore';
|
||||||
|
import type { ResourceStore } from './ResourceStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow containers to have a custom representation.
|
||||||
|
* The index representation will be returned when the following conditions are fulfilled:
|
||||||
|
* * The request targets a container.
|
||||||
|
* * A resource with the given `indexName` exists in the container. (default: "index.html")
|
||||||
|
* * The highest weighted preference matches the `mediaRange` (default: "text/html")
|
||||||
|
* Otherwise the request will be passed on to the source store.
|
||||||
|
* In case the index representation should always be returned when it exists,
|
||||||
|
* the `mediaRange` should be set to "\*∕\*".
|
||||||
|
*
|
||||||
|
* Note: this functionality is not yet part of the specification. Relevant issues are:
|
||||||
|
* - https://github.com/solid/specification/issues/69
|
||||||
|
* - https://github.com/solid/specification/issues/198
|
||||||
|
* - https://github.com/solid/specification/issues/109
|
||||||
|
* - https://github.com/solid/web-access-control-spec/issues/36
|
||||||
|
*/
|
||||||
|
export class IndexRepresentationStore extends PassthroughStore {
|
||||||
|
private readonly indexName: string;
|
||||||
|
private readonly mediaRange: string;
|
||||||
|
|
||||||
|
public constructor(source: ResourceStore, indexName = 'index.html', mediaRange = 'text/html') {
|
||||||
|
super(source);
|
||||||
|
assert(/^[\w.-]+$/u.test(indexName), 'Invalid index name');
|
||||||
|
this.indexName = indexName;
|
||||||
|
this.mediaRange = mediaRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRepresentation(identifier: ResourceIdentifier, preferences: RepresentationPreferences,
|
||||||
|
conditions?: Conditions): Promise<Representation> {
|
||||||
|
if (isContainerIdentifier(identifier) && this.matchesPreferences(preferences)) {
|
||||||
|
try {
|
||||||
|
const indexIdentifier = { path: `${identifier.path}${this.indexName}` };
|
||||||
|
return await this.source.getRepresentation(indexIdentifier, preferences, conditions);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!NotFoundHttpError.isInstance(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.source.getRepresentation(identifier, preferences, conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes sure the stored media range matches the highest weight preference.
|
||||||
|
*/
|
||||||
|
private matchesPreferences(preferences: RepresentationPreferences): boolean {
|
||||||
|
const cleaned = cleanPreferences(preferences.type);
|
||||||
|
const max = Math.max(...Object.values(cleaned));
|
||||||
|
return Object.entries(cleaned).some(([ range, weight ]): boolean =>
|
||||||
|
matchesMediaType(range, this.mediaRange) && weight === max);
|
||||||
|
}
|
||||||
|
}
|
75
test/unit/storage/IndexRepresentationstore.test.ts
Normal file
75
test/unit/storage/IndexRepresentationstore.test.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation';
|
||||||
|
import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier';
|
||||||
|
import { IndexRepresentationStore } from '../../../src/storage/IndexRepresentationStore';
|
||||||
|
import type { ResourceStore } from '../../../src/storage/ResourceStore';
|
||||||
|
import { ConflictHttpError } from '../../../src/util/errors/ConflictHttpError';
|
||||||
|
import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
|
||||||
|
import { readableToString } from '../../../src/util/StreamUtil';
|
||||||
|
|
||||||
|
describe('An IndexRepresentationStore', (): void => {
|
||||||
|
const baseUrl = 'http://test.com/';
|
||||||
|
const emptyContainer = { path: `${baseUrl}/container/` };
|
||||||
|
let source: jest.Mocked<ResourceStore>;
|
||||||
|
let store: IndexRepresentationStore;
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
source = {
|
||||||
|
getRepresentation: jest.fn((identifier: ResourceIdentifier): any => {
|
||||||
|
if (identifier.path === `${baseUrl}index.html`) {
|
||||||
|
return new BasicRepresentation('index data', 'text/html');
|
||||||
|
}
|
||||||
|
if (identifier.path.endsWith('/')) {
|
||||||
|
return new BasicRepresentation('container data', 'text/turtle');
|
||||||
|
}
|
||||||
|
throw new NotFoundHttpError();
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
store = new IndexRepresentationStore(source);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors on invalid index names.', async(): Promise<void> => {
|
||||||
|
expect((): any => new IndexRepresentationStore(source, '../secretContainer/secret.key'))
|
||||||
|
.toThrow('Invalid index name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retrieves the index resource if it exists.', async(): Promise<void> => {
|
||||||
|
const result = await store.getRepresentation({ path: baseUrl }, {});
|
||||||
|
await expect(readableToString(result.data)).resolves.toBe('index data');
|
||||||
|
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors if a non-404 error was thrown when accessing the index resource.', async(): Promise<void> => {
|
||||||
|
source.getRepresentation.mockRejectedValueOnce(new ConflictHttpError('conflict!'));
|
||||||
|
await expect(store.getRepresentation({ path: baseUrl }, {})).rejects.toThrow('conflict!');
|
||||||
|
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests the usual data if there is no index resource.', async(): Promise<void> => {
|
||||||
|
const result = await store.getRepresentation(emptyContainer, {});
|
||||||
|
await expect(readableToString(result.data)).resolves.toBe('container data');
|
||||||
|
expect(source.getRepresentation).toHaveBeenCalledTimes(2);
|
||||||
|
expect(source.getRepresentation).toHaveBeenCalledWith({ path: `${emptyContainer.path}index.html` }, {}, undefined);
|
||||||
|
expect(source.getRepresentation).toHaveBeenLastCalledWith(emptyContainer, {}, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests the usual data if the index media range is not the most preferred.', async(): Promise<void> => {
|
||||||
|
const preferences = { type: { 'text/turtle': 0.8, 'text/html': 0.5 }};
|
||||||
|
const result = await store.getRepresentation({ path: baseUrl }, preferences);
|
||||||
|
await expect(readableToString(result.data)).resolves.toBe('container data');
|
||||||
|
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: baseUrl }, preferences, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always returns the index resource if the media range is set to */*.', async(): Promise<void> => {
|
||||||
|
store = new IndexRepresentationStore(source, 'base.html', '*/*');
|
||||||
|
// Mocking because we also change the index name
|
||||||
|
source.getRepresentation.mockResolvedValueOnce(new BasicRepresentation('index data', 'text/html'));
|
||||||
|
|
||||||
|
const preferences = { type: { 'text/turtle': 0.8, 'text/html': 0.5 }};
|
||||||
|
const result = await store.getRepresentation({ path: baseUrl }, preferences);
|
||||||
|
await expect(readableToString(result.data)).resolves.toBe('index data');
|
||||||
|
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source.getRepresentation).toHaveBeenLastCalledWith({ path: `${baseUrl}base.html` }, preferences, undefined);
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user