javascript-solid-server 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/handlers/resource.js +97 -0
- package/src/patch/n3-patch.js +522 -0
- package/src/server.js +2 -1
- package/test/patch.test.js +295 -0
package/package.json
CHANGED
package/src/handlers/resource.js
CHANGED
|
@@ -2,6 +2,7 @@ import * as storage from '../storage/filesystem.js';
|
|
|
2
2
|
import { getAllHeaders } from '../ldp/headers.js';
|
|
3
3
|
import { generateContainerJsonLd, serializeJsonLd } from '../ldp/container.js';
|
|
4
4
|
import { isContainer, getContentType, isRdfContentType } from '../utils/url.js';
|
|
5
|
+
import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Handle GET request
|
|
@@ -190,3 +191,99 @@ export async function handleOptions(request, reply) {
|
|
|
190
191
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
191
192
|
return reply.code(204).send();
|
|
192
193
|
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Handle PATCH request
|
|
197
|
+
* Supports N3 Patch format (text/n3) for updating RDF resources
|
|
198
|
+
*/
|
|
199
|
+
export async function handlePatch(request, reply) {
|
|
200
|
+
const urlPath = request.url.split('?')[0];
|
|
201
|
+
|
|
202
|
+
// Don't allow PATCH to containers
|
|
203
|
+
if (isContainer(urlPath)) {
|
|
204
|
+
return reply.code(409).send({ error: 'Cannot PATCH containers' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check content type
|
|
208
|
+
const contentType = request.headers['content-type'] || '';
|
|
209
|
+
const isN3Patch = contentType.includes('text/n3') ||
|
|
210
|
+
contentType.includes('application/n3') ||
|
|
211
|
+
contentType.includes('application/sparql-update');
|
|
212
|
+
|
|
213
|
+
if (!isN3Patch) {
|
|
214
|
+
return reply.code(415).send({
|
|
215
|
+
error: 'Unsupported Media Type',
|
|
216
|
+
message: 'PATCH requires Content-Type: text/n3 for N3 Patch format'
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check if resource exists
|
|
221
|
+
const stats = await storage.stat(urlPath);
|
|
222
|
+
if (!stats) {
|
|
223
|
+
return reply.code(404).send({ error: 'Not Found' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Read existing content
|
|
227
|
+
const existingContent = await storage.read(urlPath);
|
|
228
|
+
if (existingContent === null) {
|
|
229
|
+
return reply.code(500).send({ error: 'Read error' });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Parse existing document as JSON-LD
|
|
233
|
+
let document;
|
|
234
|
+
try {
|
|
235
|
+
document = JSON.parse(existingContent.toString());
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return reply.code(409).send({
|
|
238
|
+
error: 'Conflict',
|
|
239
|
+
message: 'Resource is not valid JSON-LD and cannot be patched'
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Parse the patch
|
|
244
|
+
const patchContent = Buffer.isBuffer(request.body)
|
|
245
|
+
? request.body.toString()
|
|
246
|
+
: request.body;
|
|
247
|
+
|
|
248
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
249
|
+
let patch;
|
|
250
|
+
try {
|
|
251
|
+
patch = parseN3Patch(patchContent, resourceUrl);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return reply.code(400).send({
|
|
254
|
+
error: 'Bad Request',
|
|
255
|
+
message: 'Invalid N3 Patch format: ' + e.message
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Validate that deletes exist (optional strict mode)
|
|
260
|
+
// const validation = validatePatch(document, patch, resourceUrl);
|
|
261
|
+
// if (!validation.valid) {
|
|
262
|
+
// return reply.code(409).send({ error: 'Conflict', message: validation.error });
|
|
263
|
+
// }
|
|
264
|
+
|
|
265
|
+
// Apply the patch
|
|
266
|
+
let updatedDocument;
|
|
267
|
+
try {
|
|
268
|
+
updatedDocument = applyN3Patch(document, patch, resourceUrl);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
return reply.code(409).send({
|
|
271
|
+
error: 'Conflict',
|
|
272
|
+
message: 'Failed to apply patch: ' + e.message
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Write updated document
|
|
277
|
+
const updatedContent = JSON.stringify(updatedDocument, null, 2);
|
|
278
|
+
const success = await storage.write(urlPath, Buffer.from(updatedContent));
|
|
279
|
+
|
|
280
|
+
if (!success) {
|
|
281
|
+
return reply.code(500).send({ error: 'Write failed' });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const origin = request.headers.origin;
|
|
285
|
+
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
|
|
286
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
287
|
+
|
|
288
|
+
return reply.code(204).send();
|
|
289
|
+
}
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* N3 Patch Parser and Applier
|
|
3
|
+
*
|
|
4
|
+
* Implements Solid's N3 Patch format for updating RDF resources.
|
|
5
|
+
* https://solid.github.io/specification/protocol#n3-patch
|
|
6
|
+
*
|
|
7
|
+
* Supported format:
|
|
8
|
+
* @prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
9
|
+
* _:patch a solid:InsertDeletePatch;
|
|
10
|
+
* solid:inserts { <subject> <predicate> "object" };
|
|
11
|
+
* solid:deletes { <subject> <predicate> "old" }.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SOLID_NS = 'http://www.w3.org/ns/solid/terms#';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse an N3 Patch document
|
|
18
|
+
* @param {string} patchText - The N3 patch content
|
|
19
|
+
* @param {string} baseUri - Base URI for resolving relative references
|
|
20
|
+
* @returns {{inserts: Array, deletes: Array, where: Array}}
|
|
21
|
+
*/
|
|
22
|
+
export function parseN3Patch(patchText, baseUri) {
|
|
23
|
+
const result = {
|
|
24
|
+
inserts: [],
|
|
25
|
+
deletes: [],
|
|
26
|
+
where: []
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Extract prefixes
|
|
30
|
+
const prefixes = {};
|
|
31
|
+
const prefixRegex = /@prefix\s+(\w*):?\s*<([^>]+)>\s*\./g;
|
|
32
|
+
let match;
|
|
33
|
+
while ((match = prefixRegex.exec(patchText)) !== null) {
|
|
34
|
+
prefixes[match[1]] = match[2];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Find solid:inserts block
|
|
38
|
+
const insertsMatch = patchText.match(/solid:inserts\s*\{([^}]*)\}/s);
|
|
39
|
+
if (insertsMatch) {
|
|
40
|
+
result.inserts = parseTriples(insertsMatch[1], prefixes, baseUri);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Find solid:deletes block
|
|
44
|
+
const deletesMatch = patchText.match(/solid:deletes\s*\{([^}]*)\}/s);
|
|
45
|
+
if (deletesMatch) {
|
|
46
|
+
result.deletes = parseTriples(deletesMatch[1], prefixes, baseUri);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Find solid:where block (for conditions - simplified support)
|
|
50
|
+
const whereMatch = patchText.match(/solid:where\s*\{([^}]*)\}/s);
|
|
51
|
+
if (whereMatch) {
|
|
52
|
+
result.where = parseTriples(whereMatch[1], prefixes, baseUri);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse triples from N3 block content
|
|
60
|
+
*/
|
|
61
|
+
function parseTriples(content, prefixes, baseUri) {
|
|
62
|
+
const triples = [];
|
|
63
|
+
|
|
64
|
+
// Clean up content
|
|
65
|
+
content = content.trim();
|
|
66
|
+
if (!content) return triples;
|
|
67
|
+
|
|
68
|
+
// Split by '.' but be careful with strings containing '.'
|
|
69
|
+
const statements = splitStatements(content);
|
|
70
|
+
|
|
71
|
+
for (const stmt of statements) {
|
|
72
|
+
const triple = parseStatement(stmt.trim(), prefixes, baseUri);
|
|
73
|
+
if (triple) {
|
|
74
|
+
triples.push(triple);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return triples;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Split content into statements (handling quoted strings)
|
|
83
|
+
*/
|
|
84
|
+
function splitStatements(content) {
|
|
85
|
+
const statements = [];
|
|
86
|
+
let current = '';
|
|
87
|
+
let inString = false;
|
|
88
|
+
let stringChar = null;
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < content.length; i++) {
|
|
91
|
+
const char = content[i];
|
|
92
|
+
|
|
93
|
+
if (!inString && (char === '"' || char === "'")) {
|
|
94
|
+
inString = true;
|
|
95
|
+
stringChar = char;
|
|
96
|
+
current += char;
|
|
97
|
+
} else if (inString && char === stringChar && content[i - 1] !== '\\') {
|
|
98
|
+
inString = false;
|
|
99
|
+
stringChar = null;
|
|
100
|
+
current += char;
|
|
101
|
+
} else if (!inString && char === '.') {
|
|
102
|
+
if (current.trim()) {
|
|
103
|
+
statements.push(current);
|
|
104
|
+
}
|
|
105
|
+
current = '';
|
|
106
|
+
} else if (!inString && char === ';') {
|
|
107
|
+
// Turtle shorthand - same subject, different predicate
|
|
108
|
+
if (current.trim()) {
|
|
109
|
+
statements.push(current);
|
|
110
|
+
}
|
|
111
|
+
current = '';
|
|
112
|
+
} else {
|
|
113
|
+
current += char;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (current.trim()) {
|
|
118
|
+
statements.push(current);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return statements;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse a single N3 statement into a triple
|
|
126
|
+
*/
|
|
127
|
+
function parseStatement(stmt, prefixes, baseUri) {
|
|
128
|
+
if (!stmt) return null;
|
|
129
|
+
|
|
130
|
+
// Tokenize - split by whitespace but respect quotes
|
|
131
|
+
const tokens = tokenize(stmt);
|
|
132
|
+
if (tokens.length < 3) return null;
|
|
133
|
+
|
|
134
|
+
const subject = resolveValue(tokens[0], prefixes, baseUri);
|
|
135
|
+
const predicate = resolveValue(tokens[1], prefixes, baseUri);
|
|
136
|
+
const object = resolveValue(tokens.slice(2).join(' '), prefixes, baseUri);
|
|
137
|
+
|
|
138
|
+
return { subject, predicate, object };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Tokenize a statement respecting quoted strings
|
|
143
|
+
*/
|
|
144
|
+
function tokenize(stmt) {
|
|
145
|
+
const tokens = [];
|
|
146
|
+
let current = '';
|
|
147
|
+
let inString = false;
|
|
148
|
+
let stringChar = null;
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < stmt.length; i++) {
|
|
151
|
+
const char = stmt[i];
|
|
152
|
+
|
|
153
|
+
if (!inString && (char === '"' || char === "'")) {
|
|
154
|
+
inString = true;
|
|
155
|
+
stringChar = char;
|
|
156
|
+
current += char;
|
|
157
|
+
} else if (inString && char === stringChar && stmt[i - 1] !== '\\') {
|
|
158
|
+
inString = false;
|
|
159
|
+
stringChar = null;
|
|
160
|
+
current += char;
|
|
161
|
+
} else if (!inString && /\s/.test(char)) {
|
|
162
|
+
if (current) {
|
|
163
|
+
tokens.push(current);
|
|
164
|
+
current = '';
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
current += char;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (current) {
|
|
172
|
+
tokens.push(current);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return tokens;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve a value (URI, prefixed name, or literal)
|
|
180
|
+
*/
|
|
181
|
+
function resolveValue(value, prefixes, baseUri) {
|
|
182
|
+
value = value.trim();
|
|
183
|
+
|
|
184
|
+
// Full URI
|
|
185
|
+
if (value.startsWith('<') && value.endsWith('>')) {
|
|
186
|
+
const uri = value.slice(1, -1);
|
|
187
|
+
// Resolve relative URIs
|
|
188
|
+
if (!uri.includes('://') && !uri.startsWith('#')) {
|
|
189
|
+
return new URL(uri, baseUri).href;
|
|
190
|
+
}
|
|
191
|
+
if (uri.startsWith('#')) {
|
|
192
|
+
return baseUri + uri;
|
|
193
|
+
}
|
|
194
|
+
return uri;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Prefixed name
|
|
198
|
+
if (value.includes(':') && !value.startsWith('"')) {
|
|
199
|
+
const [prefix, local] = value.split(':', 2);
|
|
200
|
+
if (prefixes[prefix]) {
|
|
201
|
+
return prefixes[prefix] + local;
|
|
202
|
+
}
|
|
203
|
+
// Common prefixes
|
|
204
|
+
const commonPrefixes = {
|
|
205
|
+
'solid': SOLID_NS,
|
|
206
|
+
'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
|
|
207
|
+
'rdfs': 'http://www.w3.org/2000/01/rdf-schema#',
|
|
208
|
+
'foaf': 'http://xmlns.com/foaf/0.1/',
|
|
209
|
+
'dc': 'http://purl.org/dc/terms/',
|
|
210
|
+
'schema': 'http://schema.org/',
|
|
211
|
+
'ldp': 'http://www.w3.org/ns/ldp#'
|
|
212
|
+
};
|
|
213
|
+
if (commonPrefixes[prefix]) {
|
|
214
|
+
return commonPrefixes[prefix] + local;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// String literal
|
|
219
|
+
if (value.startsWith('"')) {
|
|
220
|
+
// Handle typed literals "value"^^<type>
|
|
221
|
+
const typedMatch = value.match(/^"(.*)"\^\^<([^>]+)>$/);
|
|
222
|
+
if (typedMatch) {
|
|
223
|
+
return { value: typedMatch[1], type: typedMatch[2] };
|
|
224
|
+
}
|
|
225
|
+
// Handle language tags "value"@en
|
|
226
|
+
const langMatch = value.match(/^"(.*)"@(\w+)$/);
|
|
227
|
+
if (langMatch) {
|
|
228
|
+
return { value: langMatch[1], language: langMatch[2] };
|
|
229
|
+
}
|
|
230
|
+
// Plain string
|
|
231
|
+
const plainMatch = value.match(/^"(.*)"$/);
|
|
232
|
+
if (plainMatch) {
|
|
233
|
+
return plainMatch[1];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Variable (for WHERE patterns)
|
|
238
|
+
if (value.startsWith('?')) {
|
|
239
|
+
return { variable: value.slice(1) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Blank node
|
|
243
|
+
if (value.startsWith('_:')) {
|
|
244
|
+
return { blankNode: value.slice(2) };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Apply an N3 Patch to a JSON-LD document
|
|
252
|
+
* @param {object} document - The JSON-LD document
|
|
253
|
+
* @param {object} patch - Parsed patch {inserts, deletes}
|
|
254
|
+
* @param {string} baseUri - Base URI of the document
|
|
255
|
+
* @returns {object} Updated JSON-LD document
|
|
256
|
+
*/
|
|
257
|
+
export function applyN3Patch(document, patch, baseUri) {
|
|
258
|
+
// Clone the document
|
|
259
|
+
let doc = JSON.parse(JSON.stringify(document));
|
|
260
|
+
|
|
261
|
+
// Handle @graph array or single object
|
|
262
|
+
const isGraph = Array.isArray(doc['@graph']);
|
|
263
|
+
let nodes = isGraph ? doc['@graph'] : [doc];
|
|
264
|
+
|
|
265
|
+
// Apply deletes first
|
|
266
|
+
for (const triple of patch.deletes) {
|
|
267
|
+
nodes = deleteTriple(nodes, triple, baseUri);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Then apply inserts
|
|
271
|
+
for (const triple of patch.inserts) {
|
|
272
|
+
nodes = insertTriple(nodes, triple, baseUri);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Reconstruct document
|
|
276
|
+
if (isGraph) {
|
|
277
|
+
doc['@graph'] = nodes;
|
|
278
|
+
} else {
|
|
279
|
+
doc = nodes[0] || doc;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return doc;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Delete a triple from JSON-LD nodes
|
|
287
|
+
*/
|
|
288
|
+
function deleteTriple(nodes, triple, baseUri) {
|
|
289
|
+
const { subject, predicate, object } = triple;
|
|
290
|
+
|
|
291
|
+
for (const node of nodes) {
|
|
292
|
+
const nodeId = node['@id'] || '';
|
|
293
|
+
const resolvedNodeId = nodeId.startsWith('#') ? baseUri + nodeId : nodeId;
|
|
294
|
+
|
|
295
|
+
// Check if this node matches the subject
|
|
296
|
+
if (resolvedNodeId === subject || nodeId === subject) {
|
|
297
|
+
// Find the predicate
|
|
298
|
+
for (const key of Object.keys(node)) {
|
|
299
|
+
if (key.startsWith('@')) continue;
|
|
300
|
+
|
|
301
|
+
// Check if key matches predicate (could be full URI or prefixed)
|
|
302
|
+
if (key === predicate || expandPredicate(key) === predicate) {
|
|
303
|
+
const values = Array.isArray(node[key]) ? node[key] : [node[key]];
|
|
304
|
+
const newValues = values.filter(v => !valueMatches(v, object));
|
|
305
|
+
|
|
306
|
+
if (newValues.length === 0) {
|
|
307
|
+
delete node[key];
|
|
308
|
+
} else if (newValues.length === 1) {
|
|
309
|
+
node[key] = newValues[0];
|
|
310
|
+
} else {
|
|
311
|
+
node[key] = newValues;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return nodes;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Insert a triple into JSON-LD nodes
|
|
323
|
+
*/
|
|
324
|
+
function insertTriple(nodes, triple, baseUri) {
|
|
325
|
+
const { subject, predicate, object } = triple;
|
|
326
|
+
|
|
327
|
+
// Find or create the subject node
|
|
328
|
+
let subjectNode = nodes.find(n => {
|
|
329
|
+
const nodeId = n['@id'] || '';
|
|
330
|
+
const resolvedNodeId = nodeId.startsWith('#') ? baseUri + nodeId : nodeId;
|
|
331
|
+
return resolvedNodeId === subject || nodeId === subject;
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (!subjectNode) {
|
|
335
|
+
// Create new node
|
|
336
|
+
subjectNode = { '@id': subject };
|
|
337
|
+
nodes.push(subjectNode);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Add the predicate-object
|
|
341
|
+
const predicateKey = compactPredicate(predicate);
|
|
342
|
+
const objectValue = convertToJsonLd(object);
|
|
343
|
+
|
|
344
|
+
if (subjectNode[predicateKey]) {
|
|
345
|
+
// Add to existing values
|
|
346
|
+
const existing = Array.isArray(subjectNode[predicateKey])
|
|
347
|
+
? subjectNode[predicateKey]
|
|
348
|
+
: [subjectNode[predicateKey]];
|
|
349
|
+
|
|
350
|
+
// Check if value already exists
|
|
351
|
+
if (!existing.some(v => valueMatches(v, object))) {
|
|
352
|
+
subjectNode[predicateKey] = [...existing, objectValue];
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
subjectNode[predicateKey] = objectValue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return nodes;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Check if a JSON-LD value matches an N3 object
|
|
363
|
+
*/
|
|
364
|
+
function valueMatches(jsonLdValue, n3Object) {
|
|
365
|
+
// Handle null/undefined
|
|
366
|
+
if (jsonLdValue === null || jsonLdValue === undefined) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Number comparison - N3 numbers may come as strings
|
|
371
|
+
if (typeof jsonLdValue === 'number') {
|
|
372
|
+
if (typeof n3Object === 'number') {
|
|
373
|
+
return jsonLdValue === n3Object;
|
|
374
|
+
}
|
|
375
|
+
if (typeof n3Object === 'string') {
|
|
376
|
+
return jsonLdValue === parseFloat(n3Object) || jsonLdValue.toString() === n3Object;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// String comparison
|
|
381
|
+
if (typeof jsonLdValue === 'string' && typeof n3Object === 'string') {
|
|
382
|
+
return jsonLdValue === n3Object;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// @id comparison
|
|
386
|
+
if (jsonLdValue && jsonLdValue['@id']) {
|
|
387
|
+
return jsonLdValue['@id'] === n3Object;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// @value comparison
|
|
391
|
+
if (jsonLdValue && jsonLdValue['@value']) {
|
|
392
|
+
if (typeof n3Object === 'object' && n3Object.value) {
|
|
393
|
+
return jsonLdValue['@value'] === n3Object.value;
|
|
394
|
+
}
|
|
395
|
+
return jsonLdValue['@value'] === n3Object;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Direct equality (for booleans, numbers parsed as numbers, etc.)
|
|
399
|
+
return jsonLdValue === n3Object || String(jsonLdValue) === String(n3Object);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Convert N3 object to JSON-LD value
|
|
404
|
+
*/
|
|
405
|
+
function convertToJsonLd(object) {
|
|
406
|
+
if (typeof object === 'string') {
|
|
407
|
+
// Check if it's a URI
|
|
408
|
+
if (object.startsWith('http://') || object.startsWith('https://')) {
|
|
409
|
+
return { '@id': object };
|
|
410
|
+
}
|
|
411
|
+
return object;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (typeof object === 'object') {
|
|
415
|
+
if (object.value && object.type) {
|
|
416
|
+
return { '@value': object.value, '@type': object.type };
|
|
417
|
+
}
|
|
418
|
+
if (object.value && object.language) {
|
|
419
|
+
return { '@value': object.value, '@language': object.language };
|
|
420
|
+
}
|
|
421
|
+
if (object.value) {
|
|
422
|
+
return object.value;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return object;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Expand a potentially prefixed predicate to full URI
|
|
431
|
+
*/
|
|
432
|
+
function expandPredicate(predicate) {
|
|
433
|
+
const commonPrefixes = {
|
|
434
|
+
'solid': SOLID_NS,
|
|
435
|
+
'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
|
|
436
|
+
'rdfs': 'http://www.w3.org/2000/01/rdf-schema#',
|
|
437
|
+
'foaf': 'http://xmlns.com/foaf/0.1/',
|
|
438
|
+
'dc': 'http://purl.org/dc/terms/',
|
|
439
|
+
'schema': 'http://schema.org/',
|
|
440
|
+
'ldp': 'http://www.w3.org/ns/ldp#'
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
if (predicate.includes(':')) {
|
|
444
|
+
const [prefix, local] = predicate.split(':', 2);
|
|
445
|
+
if (commonPrefixes[prefix]) {
|
|
446
|
+
return commonPrefixes[prefix] + local;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return predicate;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Compact a full URI predicate to prefixed form if possible
|
|
455
|
+
*/
|
|
456
|
+
function compactPredicate(predicate) {
|
|
457
|
+
const prefixMap = {
|
|
458
|
+
[SOLID_NS]: 'solid:',
|
|
459
|
+
'http://www.w3.org/1999/02/22-rdf-syntax-ns#': 'rdf:',
|
|
460
|
+
'http://www.w3.org/2000/01/rdf-schema#': 'rdfs:',
|
|
461
|
+
'http://xmlns.com/foaf/0.1/': 'foaf:',
|
|
462
|
+
'http://purl.org/dc/terms/': 'dc:',
|
|
463
|
+
'http://schema.org/': 'schema:',
|
|
464
|
+
'http://www.w3.org/ns/ldp#': 'ldp:'
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
for (const [uri, prefix] of Object.entries(prefixMap)) {
|
|
468
|
+
if (predicate.startsWith(uri)) {
|
|
469
|
+
return prefix + predicate.slice(uri.length);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return predicate;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Validate that a patch can be applied
|
|
478
|
+
* @param {object} document - The JSON-LD document
|
|
479
|
+
* @param {object} patch - Parsed patch
|
|
480
|
+
* @param {string} baseUri - Base URI
|
|
481
|
+
* @returns {{valid: boolean, error: string|null}}
|
|
482
|
+
*/
|
|
483
|
+
export function validatePatch(document, patch, baseUri) {
|
|
484
|
+
// Check that all deletes exist in the document
|
|
485
|
+
for (const triple of patch.deletes) {
|
|
486
|
+
if (!tripleExists(document, triple, baseUri)) {
|
|
487
|
+
return {
|
|
488
|
+
valid: false,
|
|
489
|
+
error: `Triple to delete not found: ${JSON.stringify(triple)}`
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return { valid: true, error: null };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Check if a triple exists in a document
|
|
499
|
+
*/
|
|
500
|
+
function tripleExists(document, triple, baseUri) {
|
|
501
|
+
const nodes = document['@graph'] || [document];
|
|
502
|
+
const { subject, predicate, object } = triple;
|
|
503
|
+
|
|
504
|
+
for (const node of nodes) {
|
|
505
|
+
const nodeId = node['@id'] || '';
|
|
506
|
+
const resolvedNodeId = nodeId.startsWith('#') ? baseUri + nodeId : nodeId;
|
|
507
|
+
|
|
508
|
+
if (resolvedNodeId === subject || nodeId === subject) {
|
|
509
|
+
for (const key of Object.keys(node)) {
|
|
510
|
+
if (key.startsWith('@')) continue;
|
|
511
|
+
if (key === predicate || expandPredicate(key) === predicate) {
|
|
512
|
+
const values = Array.isArray(node[key]) ? node[key] : [node[key]];
|
|
513
|
+
if (values.some(v => valueMatches(v, object))) {
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return false;
|
|
522
|
+
}
|
package/src/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Fastify from 'fastify';
|
|
2
|
-
import { handleGet, handleHead, handlePut, handleDelete, handleOptions } from './handlers/resource.js';
|
|
2
|
+
import { handleGet, handleHead, handlePut, handleDelete, handleOptions, handlePatch } from './handlers/resource.js';
|
|
3
3
|
import { handlePost, handleCreatePod } from './handlers/container.js';
|
|
4
4
|
import { getCorsHeaders } from './ldp/headers.js';
|
|
5
5
|
import { authorize, handleUnauthorized } from './auth/middleware.js';
|
|
@@ -63,6 +63,7 @@ export function createServer(options = {}) {
|
|
|
63
63
|
fastify.put('/*', handlePut);
|
|
64
64
|
fastify.delete('/*', handleDelete);
|
|
65
65
|
fastify.post('/*', handlePost);
|
|
66
|
+
fastify.patch('/*', handlePatch);
|
|
66
67
|
fastify.options('/*', handleOptions);
|
|
67
68
|
|
|
68
69
|
// Root route
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PATCH (N3 Patch) tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, before, after } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import {
|
|
8
|
+
startTestServer,
|
|
9
|
+
stopTestServer,
|
|
10
|
+
request,
|
|
11
|
+
createTestPod,
|
|
12
|
+
getBaseUrl,
|
|
13
|
+
assertStatus,
|
|
14
|
+
assertHeader
|
|
15
|
+
} from './helpers.js';
|
|
16
|
+
|
|
17
|
+
describe('PATCH Operations', () => {
|
|
18
|
+
before(async () => {
|
|
19
|
+
await startTestServer();
|
|
20
|
+
await createTestPod('patchtest');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
after(async () => {
|
|
24
|
+
await stopTestServer();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('N3 Patch', () => {
|
|
28
|
+
it('should insert a triple into a JSON-LD resource', async () => {
|
|
29
|
+
// Create initial resource
|
|
30
|
+
const initial = {
|
|
31
|
+
'@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
32
|
+
'@id': '#me',
|
|
33
|
+
'foaf:name': 'Alice'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
await request('/patchtest/public/patch-insert.json', {
|
|
37
|
+
method: 'PUT',
|
|
38
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
39
|
+
body: JSON.stringify(initial),
|
|
40
|
+
auth: 'patchtest'
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Apply N3 Patch to insert a new triple
|
|
44
|
+
const patch = `
|
|
45
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
46
|
+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
|
47
|
+
_:patch a solid:InsertDeletePatch;
|
|
48
|
+
solid:inserts { <#me> foaf:mbox <mailto:alice@example.org> }.
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
const res = await request('/patchtest/public/patch-insert.json', {
|
|
52
|
+
method: 'PATCH',
|
|
53
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
54
|
+
body: patch,
|
|
55
|
+
auth: 'patchtest'
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assertStatus(res, 204);
|
|
59
|
+
|
|
60
|
+
// Verify the change
|
|
61
|
+
const verify = await request('/patchtest/public/patch-insert.json');
|
|
62
|
+
const data = await verify.json();
|
|
63
|
+
|
|
64
|
+
assert.ok(data['foaf:mbox'], 'Should have new mbox property');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should delete a triple from a JSON-LD resource', async () => {
|
|
68
|
+
// Create initial resource with multiple properties
|
|
69
|
+
const initial = {
|
|
70
|
+
'@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
71
|
+
'@id': '#me',
|
|
72
|
+
'foaf:name': 'Bob',
|
|
73
|
+
'foaf:age': 30
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
await request('/patchtest/public/patch-delete.json', {
|
|
77
|
+
method: 'PUT',
|
|
78
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
79
|
+
body: JSON.stringify(initial),
|
|
80
|
+
auth: 'patchtest'
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Apply N3 Patch to delete the age property
|
|
84
|
+
const patch = `
|
|
85
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
86
|
+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
|
87
|
+
_:patch a solid:InsertDeletePatch;
|
|
88
|
+
solid:deletes { <#me> foaf:age 30 }.
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
const res = await request('/patchtest/public/patch-delete.json', {
|
|
92
|
+
method: 'PATCH',
|
|
93
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
94
|
+
body: patch,
|
|
95
|
+
auth: 'patchtest'
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assertStatus(res, 204);
|
|
99
|
+
|
|
100
|
+
// Verify the change
|
|
101
|
+
const verify = await request('/patchtest/public/patch-delete.json');
|
|
102
|
+
const data = await verify.json();
|
|
103
|
+
|
|
104
|
+
assert.ok(!data['foaf:age'], 'Should not have age property');
|
|
105
|
+
assert.strictEqual(data['foaf:name'], 'Bob', 'Should still have name');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should insert and delete in same patch', async () => {
|
|
109
|
+
// Create initial resource
|
|
110
|
+
const initial = {
|
|
111
|
+
'@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
112
|
+
'@id': '#me',
|
|
113
|
+
'foaf:name': 'Charlie'
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
await request('/patchtest/public/patch-both.json', {
|
|
117
|
+
method: 'PUT',
|
|
118
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
119
|
+
body: JSON.stringify(initial),
|
|
120
|
+
auth: 'patchtest'
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Apply N3 Patch to change name
|
|
124
|
+
const patch = `
|
|
125
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
126
|
+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
|
127
|
+
_:patch a solid:InsertDeletePatch;
|
|
128
|
+
solid:deletes { <#me> foaf:name "Charlie" };
|
|
129
|
+
solid:inserts { <#me> foaf:name "Charles" }.
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
const res = await request('/patchtest/public/patch-both.json', {
|
|
133
|
+
method: 'PATCH',
|
|
134
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
135
|
+
body: patch,
|
|
136
|
+
auth: 'patchtest'
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
assertStatus(res, 204);
|
|
140
|
+
|
|
141
|
+
// Verify the change
|
|
142
|
+
const verify = await request('/patchtest/public/patch-both.json');
|
|
143
|
+
const data = await verify.json();
|
|
144
|
+
|
|
145
|
+
assert.strictEqual(data['foaf:name'], 'Charles', 'Name should be updated');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should add a new subject node', async () => {
|
|
149
|
+
// Create initial resource with @graph
|
|
150
|
+
const initial = {
|
|
151
|
+
'@context': { 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
152
|
+
'@graph': [
|
|
153
|
+
{ '@id': '#alice', 'foaf:name': 'Alice' }
|
|
154
|
+
]
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
await request('/patchtest/public/patch-newnode.json', {
|
|
158
|
+
method: 'PUT',
|
|
159
|
+
headers: { 'Content-Type': 'application/ld+json' },
|
|
160
|
+
body: JSON.stringify(initial),
|
|
161
|
+
auth: 'patchtest'
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Add a new person
|
|
165
|
+
const patch = `
|
|
166
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
167
|
+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
|
168
|
+
_:patch a solid:InsertDeletePatch;
|
|
169
|
+
solid:inserts { <#bob> foaf:name "Bob" }.
|
|
170
|
+
`;
|
|
171
|
+
|
|
172
|
+
const res = await request('/patchtest/public/patch-newnode.json', {
|
|
173
|
+
method: 'PATCH',
|
|
174
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
175
|
+
body: patch,
|
|
176
|
+
auth: 'patchtest'
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assertStatus(res, 204);
|
|
180
|
+
|
|
181
|
+
// Verify the change
|
|
182
|
+
const verify = await request('/patchtest/public/patch-newnode.json');
|
|
183
|
+
const data = await verify.json();
|
|
184
|
+
|
|
185
|
+
assert.ok(data['@graph'], 'Should have @graph');
|
|
186
|
+
assert.strictEqual(data['@graph'].length, 2, 'Should have 2 nodes');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('PATCH Error Handling', () => {
|
|
191
|
+
it('should return 415 for unsupported content type', async () => {
|
|
192
|
+
// Create a resource first
|
|
193
|
+
await request('/patchtest/public/patch-error.json', {
|
|
194
|
+
method: 'PUT',
|
|
195
|
+
headers: { 'Content-Type': 'application/json' },
|
|
196
|
+
body: JSON.stringify({ test: true }),
|
|
197
|
+
auth: 'patchtest'
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const res = await request('/patchtest/public/patch-error.json', {
|
|
201
|
+
method: 'PATCH',
|
|
202
|
+
headers: { 'Content-Type': 'application/json' },
|
|
203
|
+
body: JSON.stringify({ op: 'add' }),
|
|
204
|
+
auth: 'patchtest'
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
assertStatus(res, 415);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should return 404 for non-existent resource', async () => {
|
|
211
|
+
const patch = `
|
|
212
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
213
|
+
_:patch a solid:InsertDeletePatch;
|
|
214
|
+
solid:inserts { <#me> <http://example.org/p> "test" }.
|
|
215
|
+
`;
|
|
216
|
+
|
|
217
|
+
const res = await request('/patchtest/public/nonexistent.json', {
|
|
218
|
+
method: 'PATCH',
|
|
219
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
220
|
+
body: patch,
|
|
221
|
+
auth: 'patchtest'
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
assertStatus(res, 404);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should return 409 when patching non-JSON-LD resource', async () => {
|
|
228
|
+
// Create a plain text resource
|
|
229
|
+
await request('/patchtest/public/plain.txt', {
|
|
230
|
+
method: 'PUT',
|
|
231
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
232
|
+
body: 'Hello World',
|
|
233
|
+
auth: 'patchtest'
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const patch = `
|
|
237
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
238
|
+
_:patch a solid:InsertDeletePatch;
|
|
239
|
+
solid:inserts { <#me> <http://example.org/p> "test" }.
|
|
240
|
+
`;
|
|
241
|
+
|
|
242
|
+
const res = await request('/patchtest/public/plain.txt', {
|
|
243
|
+
method: 'PATCH',
|
|
244
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
245
|
+
body: patch,
|
|
246
|
+
auth: 'patchtest'
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
assertStatus(res, 409);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should return 409 for PATCH to container', async () => {
|
|
253
|
+
const patch = `
|
|
254
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
255
|
+
_:patch a solid:InsertDeletePatch;
|
|
256
|
+
solid:inserts { <#me> <http://example.org/p> "test" }.
|
|
257
|
+
`;
|
|
258
|
+
|
|
259
|
+
const res = await request('/patchtest/public/', {
|
|
260
|
+
method: 'PATCH',
|
|
261
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
262
|
+
body: patch,
|
|
263
|
+
auth: 'patchtest'
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
assertStatus(res, 409);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should require authentication for PATCH', async () => {
|
|
270
|
+
// Create a resource first
|
|
271
|
+
await request('/patchtest/public/patch-auth.json', {
|
|
272
|
+
method: 'PUT',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify({ test: true }),
|
|
275
|
+
auth: 'patchtest'
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const patch = `
|
|
279
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
280
|
+
_:patch a solid:InsertDeletePatch;
|
|
281
|
+
solid:inserts { <#me> <http://example.org/p> "test" }.
|
|
282
|
+
`;
|
|
283
|
+
|
|
284
|
+
// Try without auth
|
|
285
|
+
const res = await request('/patchtest/public/patch-auth.json', {
|
|
286
|
+
method: 'PATCH',
|
|
287
|
+
headers: { 'Content-Type': 'text/n3' },
|
|
288
|
+
body: patch
|
|
289
|
+
// No auth
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
assertStatus(res, 401);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|