feat: Add IndexRepresentationStore to support index resources

This commit is contained in:
Joachim Van Herwegen 2021-07-14 13:54:46 +02:00
parent 4e0c8291ca
commit cc1e332394
10 changed files with 212 additions and 45 deletions

View File

@ -14,14 +14,17 @@
"@id": "urn:solid-server:default:ResourceStore",
"@type": "MonitoringStore",
"MonitoringStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Locking",
"LockingResourceStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Patching",
"PatchingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Converting",
"RepresentationConvertingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Routing",
"comment": "A router rule will be defined when activating dynamic pods."
"@id": "urn:solid-server:default:ResourceStore_Index",
"IndexRepresentationStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Locking",
"LockingResourceStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Patching",
"PatchingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Converting",
"RepresentationConvertingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Routing",
"comment": "A router rule will be defined when activating dynamic pods."
}
}
}
}

View File

@ -13,15 +13,18 @@
"@id": "urn:solid-server:default:ResourceStore",
"@type": "MonitoringStore",
"MonitoringStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Locking",
"LockingResourceStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Patching",
"PatchingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Converting",
"RepresentationConvertingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_DataAccessor",
"DataAccessorBasedStore:_accessor": {
"@id": "urn:solid-server:default:FileDataAccessor"
"@id": "urn:solid-server:default:ResourceStore_Index",
"IndexRepresentationStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Locking",
"LockingResourceStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Patching",
"PatchingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Converting",
"RepresentationConvertingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_DataAccessor",
"DataAccessorBasedStore:_accessor": {
"@id": "urn:solid-server:default:FileDataAccessor"
}
}
}
}

View File

@ -13,15 +13,18 @@
"@id": "urn:solid-server:default:ResourceStore",
"@type": "MonitoringStore",
"MonitoringStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Locking",
"LockingResourceStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Patching",
"PatchingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Converting",
"RepresentationConvertingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_DataAccessor",
"DataAccessorBasedStore:_accessor": {
"@id": "urn:solid-server:default:MemoryDataAccessor"
"@id": "urn:solid-server:default:ResourceStore_Index",
"IndexRepresentationStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Locking",
"LockingResourceStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Patching",
"PatchingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Converting",
"RepresentationConvertingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_DataAccessor",
"DataAccessorBasedStore:_accessor": {
"@id": "urn:solid-server:default:MemoryDataAccessor"
}
}
}
}

View File

@ -15,13 +15,16 @@
"@id": "urn:solid-server:default:ResourceStore",
"@type": "MonitoringStore",
"MonitoringStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Locking",
"LockingResourceStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Patching",
"PatchingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Converting",
"RepresentationConvertingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Routing"
"@id": "urn:solid-server:default:ResourceStore_Index",
"IndexRepresentationStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Locking",
"LockingResourceStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Patching",
"PatchingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Converting",
"RepresentationConvertingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Routing"
}
}
}
}

View File

@ -13,18 +13,23 @@
"@id": "urn:solid-server:default:ResourceStore",
"@type": "MonitoringStore",
"MonitoringStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Locking",
"LockingResourceStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Patching",
"PatchingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Converting",
"comment": "This makes it so all incoming data is converted to quad objects.",
"RepresentationConvertingStore:_options_inConverter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"RepresentationConvertingStore:_options_inType": "internal/quads",
"RepresentationConvertingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_DataAccessor",
"DataAccessorBasedStore:_accessor": {
"@id": "urn:solid-server:default:SparqlDataAccessor"
"@id": "urn:solid-server:default:ResourceStore_Index",
"IndexRepresentationStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Locking",
"LockingResourceStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Patching",
"PatchingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_Converting",
"comment": "This makes it so all incoming data is converted to quad objects.",
"RepresentationConvertingStore:_options_inConverter": {
"@id": "urn:solid-server:default:RepresentationConverter"
},
"RepresentationConvertingStore:_options_inType": "internal/quads",
"RepresentationConvertingStore:_source": {
"@id": "urn:solid-server:default:ResourceStore_DataAccessor",
"DataAccessorBasedStore:_accessor": {
"@id": "urn:solid-server:default:SparqlDataAccessor"
}
}
}
}

View File

@ -1,6 +1,11 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@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.",
"@id": "urn:solid-server:default:DefaultUiConverter",

View File

@ -1,6 +1,11 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld",
"@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": [
"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).",

View File

@ -265,6 +265,7 @@ export * from './storage/AtomicResourceStore';
export * from './storage/BaseResourceStore';
export * from './storage/Conditions';
export * from './storage/DataAccessorBasedStore';
export * from './storage/IndexRepresentationStore';
export * from './storage/LockingResourceStore';
export * from './storage/MonitoringStore';
export * from './storage/PassthroughStore';

View 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);
}
}

View 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);
});
});