mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Support composite PATCH updates
This commit is contained in:
@@ -22,8 +22,8 @@ export class SparqlPatchPermissionsExtractor extends PermissionsExtractor {
|
||||
if (!this.isSparql(body)) {
|
||||
throw new NotImplementedHttpError('Cannot determine permissions of non-SPARQL patches.');
|
||||
}
|
||||
if (!this.isDeleteInsert(body.algebra)) {
|
||||
throw new NotImplementedHttpError('Cannot determine permissions of a PATCH without DELETE/INSERT.');
|
||||
if (!this.isSupported(body.algebra)) {
|
||||
throw new NotImplementedHttpError('Can only determine permissions of a PATCH with DELETE/INSERT operations.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,15 +42,33 @@ export class SparqlPatchPermissionsExtractor extends PermissionsExtractor {
|
||||
return Boolean((data as SparqlUpdatePatch).algebra);
|
||||
}
|
||||
|
||||
private isSupported(op: Algebra.Operation): boolean {
|
||||
if (op.type === Algebra.types.DELETE_INSERT) {
|
||||
return true;
|
||||
}
|
||||
if (op.type === Algebra.types.COMPOSITE_UPDATE) {
|
||||
return (op as Algebra.CompositeUpdate).updates.every((update): boolean => this.isSupported(update));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
|
||||
return op.type === Algebra.types.DELETE_INSERT;
|
||||
}
|
||||
|
||||
private needsAppend(update: Algebra.DeleteInsert): boolean {
|
||||
return Boolean(update.insert && update.insert.length > 0);
|
||||
private needsAppend(update: Algebra.Operation): boolean {
|
||||
if (this.isDeleteInsert(update)) {
|
||||
return Boolean(update.insert && update.insert.length > 0);
|
||||
}
|
||||
|
||||
return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.needsAppend(op));
|
||||
}
|
||||
|
||||
private needsWrite(update: Algebra.DeleteInsert): boolean {
|
||||
return Boolean(update.delete && update.delete.length > 0);
|
||||
private needsWrite(update: Algebra.Operation): boolean {
|
||||
if (this.isDeleteInsert(update)) {
|
||||
return Boolean(update.delete && update.delete.length > 0);
|
||||
}
|
||||
|
||||
return (update as Algebra.CompositeUpdate).updates.some((op): boolean => this.needsWrite(op));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,43 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
|
||||
// Verify the patch
|
||||
const { identifier, patch } = input;
|
||||
const op = patch.algebra;
|
||||
if (!this.isDeleteInsert(op)) {
|
||||
this.validateUpdate(op);
|
||||
|
||||
const lock = await this.locker.acquire(identifier);
|
||||
try {
|
||||
await this.applyPatch(identifier, op);
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
|
||||
return op.type === Algebra.types.DELETE_INSERT;
|
||||
}
|
||||
|
||||
private isComposite(op: Algebra.Operation): op is Algebra.CompositeUpdate {
|
||||
return op.type === Algebra.types.COMPOSITE_UPDATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the input operation is of a supported type (DELETE/INSERT or composite of those)
|
||||
*/
|
||||
private validateUpdate(op: Algebra.Operation): void {
|
||||
if (this.isDeleteInsert(op)) {
|
||||
this.validateDeleteInsert(op);
|
||||
} else if (this.isComposite(op)) {
|
||||
this.validateComposite(op);
|
||||
} else {
|
||||
this.logger.warn(`Unsupported operation: ${op.type}`);
|
||||
throw new NotImplementedHttpError('Only DELETE/INSERT SPARQL update operations are supported');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the input DELETE/INSERT is supported.
|
||||
* This means: no GRAPH statements, no DELETE WHERE.
|
||||
*/
|
||||
private validateDeleteInsert(op: Algebra.DeleteInsert): void {
|
||||
const def = defaultGraph();
|
||||
const deletes = op.delete ?? [];
|
||||
const inserts = op.insert ?? [];
|
||||
@@ -62,24 +94,21 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
|
||||
this.logger.warn('WHERE statements are not supported');
|
||||
throw new NotImplementedHttpError('WHERE statements are not supported');
|
||||
}
|
||||
|
||||
const lock = await this.locker.acquire(identifier);
|
||||
try {
|
||||
await this.applyPatch(identifier, deletes, inserts);
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
private isDeleteInsert(op: Algebra.Operation): op is Algebra.DeleteInsert {
|
||||
return op.type === Algebra.types.DELETE_INSERT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the given deletes and inserts to the resource.
|
||||
* Checks if the composite update only contains supported update components.
|
||||
*/
|
||||
private async applyPatch(identifier: ResourceIdentifier, deletes: Algebra.Pattern[], inserts: Algebra.Pattern[]):
|
||||
Promise<void> {
|
||||
private validateComposite(op: Algebra.CompositeUpdate): void {
|
||||
for (const update of op.updates) {
|
||||
this.validateUpdate(update);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the given algebra operation to the given identifier.
|
||||
*/
|
||||
private async applyPatch(identifier: ResourceIdentifier, op: Algebra.Operation): Promise<void> {
|
||||
const store = new Store<BaseQuad>();
|
||||
try {
|
||||
// Read the quads of the current representation
|
||||
@@ -100,13 +129,42 @@ export class SparqlUpdatePatchHandler extends PatchHandler {
|
||||
this.logger.debug(`Patching new resource ${identifier.path}.`);
|
||||
}
|
||||
|
||||
// Apply the patch
|
||||
store.removeQuads(deletes);
|
||||
store.addQuads(inserts);
|
||||
this.logger.debug(`Removed ${deletes.length} and added ${inserts.length} quads to ${identifier.path}.`);
|
||||
this.applyOperation(store, op);
|
||||
this.logger.debug(`${store.size} quads will be stored to ${identifier.path}.`);
|
||||
|
||||
// Write the result
|
||||
await this.source.setRepresentation(identifier, new BasicRepresentation(store.match() as Readable, INTERNAL_QUADS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the given algebra update operation to the store of quads.
|
||||
*/
|
||||
private applyOperation(store: Store<BaseQuad>, op: Algebra.Operation): void {
|
||||
if (this.isDeleteInsert(op)) {
|
||||
this.applyDeleteInsert(store, op);
|
||||
// Only other options is Composite after passing `validateUpdate`
|
||||
} else {
|
||||
this.applyComposite(store, op as Algebra.CompositeUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the given composite update operation to the store of quads.
|
||||
*/
|
||||
private applyComposite(store: Store<BaseQuad>, op: Algebra.CompositeUpdate): void {
|
||||
for (const update of op.updates) {
|
||||
this.applyOperation(store, update);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the given DELETE/INSERT update operation to the store of quads.
|
||||
*/
|
||||
private applyDeleteInsert(store: Store<BaseQuad>, op: Algebra.DeleteInsert): void {
|
||||
const deletes = op.delete ?? [];
|
||||
const inserts = op.insert ?? [];
|
||||
store.removeQuads(deletes);
|
||||
store.addQuads(inserts);
|
||||
this.logger.debug(`Removed ${deletes.length} and added ${inserts.length} quads.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,21 +98,14 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
|
||||
expect(response._getData()).toHaveLength(0);
|
||||
|
||||
// GET
|
||||
requestUrl = new URL(id);
|
||||
response = await performRequest(
|
||||
handler,
|
||||
requestUrl,
|
||||
'GET',
|
||||
{ accept: 'text/turtle' },
|
||||
[],
|
||||
);
|
||||
response = await performRequest(handler, requestUrl, 'GET', { accept: 'text/turtle' }, []);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response._getData()).toContain(
|
||||
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2>.',
|
||||
);
|
||||
expect(response.getHeaders().link).toBe(`<${LDP.Resource}>; rel="type"`);
|
||||
const parser = new Parser();
|
||||
const triples = parser.parse(response._getData());
|
||||
let triples = parser.parse(response._getData());
|
||||
expect(triples).toBeRdfIsomorphic([
|
||||
quad(
|
||||
namedNode('http://test.com/s2'),
|
||||
@@ -125,6 +118,36 @@ describe('An integrated AuthenticatedLdpHandler', (): void => {
|
||||
namedNode('http://test.com/o3'),
|
||||
),
|
||||
]);
|
||||
|
||||
// PATCH
|
||||
response = await performRequest(
|
||||
handler,
|
||||
requestUrl,
|
||||
'PATCH',
|
||||
{ 'content-type': 'application/sparql-update', 'transfer-encoding': 'chunked' },
|
||||
[ 'DELETE DATA { <s2> <http://test.com/p2> <http://test.com/o2> }; ',
|
||||
'INSERT DATA {<s4> <http://test.com/p4> <http://test.com/o4>}',
|
||||
],
|
||||
);
|
||||
expect(response.statusCode).toBe(205);
|
||||
expect(response._getData()).toHaveLength(0);
|
||||
|
||||
// GET
|
||||
response = await performRequest(handler, requestUrl, 'GET', { accept: 'text/turtle' }, []);
|
||||
expect(response.statusCode).toBe(200);
|
||||
triples = parser.parse(response._getData());
|
||||
expect(triples).toBeRdfIsomorphic([
|
||||
quad(
|
||||
namedNode('http://test.com/s3'),
|
||||
namedNode('http://test.com/p3'),
|
||||
namedNode('http://test.com/o3'),
|
||||
),
|
||||
quad(
|
||||
namedNode('http://test.com/s4'),
|
||||
namedNode('http://test.com/p4'),
|
||||
namedNode('http://test.com/o4'),
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should overwrite the content on PUT request.', async(): Promise<void> => {
|
||||
|
||||
@@ -8,9 +8,11 @@ describe('A SparqlPatchPermissionsExtractor', (): void => {
|
||||
const extractor = new SparqlPatchPermissionsExtractor();
|
||||
const factory = new Factory();
|
||||
|
||||
it('can only handle SPARQL DELETE/INSERT PATCH operations.', async(): Promise<void> => {
|
||||
it('can only handle (composite) SPARQL DELETE/INSERT PATCH operations.', async(): Promise<void> => {
|
||||
const operation = { method: 'PATCH', body: { algebra: factory.createDeleteInsert() }} as unknown as Operation;
|
||||
await expect(extractor.canHandle(operation)).resolves.toBeUndefined();
|
||||
(operation.body as SparqlUpdatePatch).algebra = factory.createCompositeUpdate([ factory.createDeleteInsert() ]);
|
||||
await expect(extractor.canHandle(operation)).resolves.toBeUndefined();
|
||||
await expect(extractor.canHandle({ ...operation, method: 'GET' }))
|
||||
.rejects.toThrow(new BadRequestHttpError('Cannot determine permissions of GET, only PATCH.'));
|
||||
await expect(extractor.canHandle({ ...operation, body: undefined }))
|
||||
@@ -19,7 +21,8 @@ describe('A SparqlPatchPermissionsExtractor', (): void => {
|
||||
.rejects.toThrow(new BadRequestHttpError('Cannot determine permissions of non-SPARQL patches.'));
|
||||
await expect(extractor.canHandle({ ...operation,
|
||||
body: { algebra: factory.createMove('DEFAULT', 'DEFAULT') } as unknown as SparqlUpdatePatch }))
|
||||
.rejects.toThrow(new BadRequestHttpError('Cannot determine permissions of a PATCH without DELETE/INSERT.'));
|
||||
.rejects
|
||||
.toThrow(new BadRequestHttpError('Can only determine permissions of a PATCH with DELETE/INSERT operations.'));
|
||||
});
|
||||
|
||||
it('requires append for INSERT operations.', async(): Promise<void> => {
|
||||
@@ -49,4 +52,35 @@ describe('A SparqlPatchPermissionsExtractor', (): void => {
|
||||
write: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires append for composite operations with an insert.', async(): Promise<void> => {
|
||||
const operation = {
|
||||
method: 'PATCH',
|
||||
body: { algebra: factory.createCompositeUpdate([ factory.createDeleteInsert(undefined, [
|
||||
factory.createPattern(factory.createTerm('<s>'), factory.createTerm('<p>'), factory.createTerm('<o>')),
|
||||
]) ]) },
|
||||
} as unknown as Operation;
|
||||
await expect(extractor.handle(operation)).resolves.toEqual({
|
||||
read: false,
|
||||
append: true,
|
||||
write: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires write for composite operations with a delete.', async(): Promise<void> => {
|
||||
const operation = {
|
||||
method: 'PATCH',
|
||||
body: { algebra: factory.createCompositeUpdate([ factory.createDeleteInsert(undefined, [
|
||||
factory.createPattern(factory.createTerm('<s>'), factory.createTerm('<p>'), factory.createTerm('<o>')),
|
||||
]),
|
||||
factory.createDeleteInsert([
|
||||
factory.createPattern(factory.createTerm('<s>'), factory.createTerm('<p>'), factory.createTerm('<o>')),
|
||||
]) ]) },
|
||||
} as unknown as Operation;
|
||||
await expect(extractor.handle(operation)).resolves.toEqual({
|
||||
read: false,
|
||||
append: true,
|
||||
write: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,6 +148,47 @@ describe('A SparqlUpdatePatchHandler', (): void => {
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
it('handles composite INSERT/DELETE updates.', async(): Promise<void> => {
|
||||
await handler.handle({ identifier: { path: 'path' },
|
||||
patch: { algebra: translate(
|
||||
'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. ' +
|
||||
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2> };' +
|
||||
'DELETE WHERE { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>.' +
|
||||
'<http://test.com/startS1> <http://test.com/startP1> <http://test.com/startO1> }',
|
||||
{ quads: true },
|
||||
) } as SparqlUpdatePatch });
|
||||
expect(await basicChecks([
|
||||
quad(namedNode('http://test.com/startS2'),
|
||||
namedNode('http://test.com/startP2'),
|
||||
namedNode('http://test.com/startO2')),
|
||||
quad(namedNode('http://test.com/s2'),
|
||||
namedNode('http://test.com/p2'),
|
||||
namedNode('http://test.com/o2')),
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
it('handles composite DELETE/INSERT updates.', async(): Promise<void> => {
|
||||
await handler.handle({ identifier: { path: 'path' },
|
||||
patch: { algebra: translate(
|
||||
'DELETE DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>.' +
|
||||
'<http://test.com/startS1> <http://test.com/startP1> <http://test.com/startO1> };' +
|
||||
'INSERT DATA { <http://test.com/s1> <http://test.com/p1> <http://test.com/o1>. ' +
|
||||
'<http://test.com/s2> <http://test.com/p2> <http://test.com/o2> }',
|
||||
{ quads: true },
|
||||
) } as SparqlUpdatePatch });
|
||||
expect(await basicChecks([
|
||||
quad(namedNode('http://test.com/startS2'),
|
||||
namedNode('http://test.com/startP2'),
|
||||
namedNode('http://test.com/startO2')),
|
||||
quad(namedNode('http://test.com/s1'),
|
||||
namedNode('http://test.com/p1'),
|
||||
namedNode('http://test.com/o1')),
|
||||
quad(namedNode('http://test.com/s2'),
|
||||
namedNode('http://test.com/p2'),
|
||||
namedNode('http://test.com/o2')),
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects GRAPH inserts.', async(): Promise<void> => {
|
||||
const handle = handler.handle({ identifier: { path: 'path' },
|
||||
patch: { algebra: translate(
|
||||
|
||||
Reference in New Issue
Block a user