javascript-solid-server 0.0.9 → 0.0.10

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.
@@ -3,6 +3,7 @@ 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
5
  import { parseN3Patch, applyN3Patch, validatePatch } from '../patch/n3-patch.js';
6
+ import { parseSparqlUpdate, applySparqlUpdate } from '../patch/sparql-update.js';
6
7
  import {
7
8
  selectContentType,
8
9
  canAcceptInput,
@@ -12,6 +13,7 @@ import {
12
13
  RDF_TYPES
13
14
  } from '../rdf/conneg.js';
14
15
  import { emitChange } from '../notifications/events.js';
16
+ import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
15
17
 
16
18
  /**
17
19
  * Handle GET request
@@ -24,6 +26,15 @@ export async function handleGet(request, reply) {
24
26
  return reply.code(404).send({ error: 'Not Found' });
25
27
  }
26
28
 
29
+ // Check If-None-Match for conditional GET (304 Not Modified)
30
+ const ifNoneMatch = request.headers['if-none-match'];
31
+ if (ifNoneMatch) {
32
+ const check = checkIfNoneMatchForGet(ifNoneMatch, stats.etag);
33
+ if (!check.ok && check.notModified) {
34
+ return reply.code(304).send();
35
+ }
36
+ }
37
+
27
38
  const origin = request.headers.origin;
28
39
  const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
29
40
 
@@ -185,8 +196,28 @@ export async function handlePut(request, reply) {
185
196
  });
186
197
  }
187
198
 
188
- // Check if resource already exists
189
- const existed = await storage.exists(urlPath);
199
+ // Check if resource already exists and get current ETag
200
+ const stats = await storage.stat(urlPath);
201
+ const existed = stats !== null;
202
+ const currentEtag = stats?.etag || null;
203
+
204
+ // Check If-Match header (for safe updates)
205
+ const ifMatch = request.headers['if-match'];
206
+ if (ifMatch) {
207
+ const check = checkIfMatch(ifMatch, currentEtag);
208
+ if (!check.ok) {
209
+ return reply.code(check.status).send({ error: check.error });
210
+ }
211
+ }
212
+
213
+ // Check If-None-Match header (for create-only semantics)
214
+ const ifNoneMatch = request.headers['if-none-match'];
215
+ if (ifNoneMatch) {
216
+ const check = checkIfNoneMatchForWrite(ifNoneMatch, currentEtag);
217
+ if (!check.ok) {
218
+ return reply.code(check.status).send({ error: check.error });
219
+ }
220
+ }
190
221
 
191
222
  // Get content from request body
192
223
  let content = request.body;
@@ -242,11 +273,21 @@ export async function handlePut(request, reply) {
242
273
  export async function handleDelete(request, reply) {
243
274
  const urlPath = request.url.split('?')[0];
244
275
 
245
- const existed = await storage.exists(urlPath);
246
- if (!existed) {
276
+ // Check if resource exists and get current ETag
277
+ const stats = await storage.stat(urlPath);
278
+ if (!stats) {
247
279
  return reply.code(404).send({ error: 'Not Found' });
248
280
  }
249
281
 
282
+ // Check If-Match header (for safe deletes)
283
+ const ifMatch = request.headers['if-match'];
284
+ if (ifMatch) {
285
+ const check = checkIfMatch(ifMatch, stats.etag);
286
+ if (!check.ok) {
287
+ return reply.code(check.status).send({ error: check.error });
288
+ }
289
+ }
290
+
250
291
  const success = await storage.remove(urlPath);
251
292
  if (!success) {
252
293
  return reply.code(500).send({ error: 'Delete failed' });
@@ -286,7 +327,7 @@ export async function handleOptions(request, reply) {
286
327
 
287
328
  /**
288
329
  * Handle PATCH request
289
- * Supports N3 Patch format (text/n3) for updating RDF resources
330
+ * Supports N3 Patch format (text/n3) and SPARQL Update for updating RDF resources
290
331
  */
291
332
  export async function handlePatch(request, reply) {
292
333
  const urlPath = request.url.split('?')[0];
@@ -298,14 +339,13 @@ export async function handlePatch(request, reply) {
298
339
 
299
340
  // Check content type
300
341
  const contentType = request.headers['content-type'] || '';
301
- const isN3Patch = contentType.includes('text/n3') ||
302
- contentType.includes('application/n3') ||
303
- contentType.includes('application/sparql-update');
342
+ const isN3Patch = contentType.includes('text/n3') || contentType.includes('application/n3');
343
+ const isSparqlUpdate = contentType.includes('application/sparql-update');
304
344
 
305
- if (!isN3Patch) {
345
+ if (!isN3Patch && !isSparqlUpdate) {
306
346
  return reply.code(415).send({
307
347
  error: 'Unsupported Media Type',
308
- message: 'PATCH requires Content-Type: text/n3 for N3 Patch format'
348
+ message: 'PATCH requires Content-Type: text/n3 (N3 Patch) or application/sparql-update (SPARQL Update)'
309
349
  });
310
350
  }
311
351
 
@@ -315,6 +355,15 @@ export async function handlePatch(request, reply) {
315
355
  return reply.code(404).send({ error: 'Not Found' });
316
356
  }
317
357
 
358
+ // Check If-Match header (for safe updates)
359
+ const ifMatch = request.headers['if-match'];
360
+ if (ifMatch) {
361
+ const check = checkIfMatch(ifMatch, stats.etag);
362
+ if (!check.ok) {
363
+ return reply.code(check.status).send({ error: check.error });
364
+ }
365
+ }
366
+
318
367
  // Read existing content
319
368
  const existingContent = await storage.read(urlPath);
320
369
  if (existingContent === null) {
@@ -338,31 +387,49 @@ export async function handlePatch(request, reply) {
338
387
  : request.body;
339
388
 
340
389
  const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
341
- let patch;
342
- try {
343
- patch = parseN3Patch(patchContent, resourceUrl);
344
- } catch (e) {
345
- return reply.code(400).send({
346
- error: 'Bad Request',
347
- message: 'Invalid N3 Patch format: ' + e.message
348
- });
349
- }
350
-
351
- // Validate that deletes exist (optional strict mode)
352
- // const validation = validatePatch(document, patch, resourceUrl);
353
- // if (!validation.valid) {
354
- // return reply.code(409).send({ error: 'Conflict', message: validation.error });
355
- // }
356
390
 
357
- // Apply the patch
358
391
  let updatedDocument;
359
- try {
360
- updatedDocument = applyN3Patch(document, patch, resourceUrl);
361
- } catch (e) {
362
- return reply.code(409).send({
363
- error: 'Conflict',
364
- message: 'Failed to apply patch: ' + e.message
365
- });
392
+
393
+ if (isSparqlUpdate) {
394
+ // Handle SPARQL Update
395
+ let update;
396
+ try {
397
+ update = parseSparqlUpdate(patchContent, resourceUrl);
398
+ } catch (e) {
399
+ return reply.code(400).send({
400
+ error: 'Bad Request',
401
+ message: 'Invalid SPARQL Update: ' + e.message
402
+ });
403
+ }
404
+
405
+ try {
406
+ updatedDocument = applySparqlUpdate(document, update, resourceUrl);
407
+ } catch (e) {
408
+ return reply.code(409).send({
409
+ error: 'Conflict',
410
+ message: 'Failed to apply SPARQL Update: ' + e.message
411
+ });
412
+ }
413
+ } else {
414
+ // Handle N3 Patch
415
+ let patch;
416
+ try {
417
+ patch = parseN3Patch(patchContent, resourceUrl);
418
+ } catch (e) {
419
+ return reply.code(400).send({
420
+ error: 'Bad Request',
421
+ message: 'Invalid N3 Patch format: ' + e.message
422
+ });
423
+ }
424
+
425
+ try {
426
+ updatedDocument = applyN3Patch(document, patch, resourceUrl);
427
+ } catch (e) {
428
+ return reply.code(409).send({
429
+ error: 'Conflict',
430
+ message: 'Failed to apply patch: ' + e.message
431
+ });
432
+ }
366
433
  }
367
434
 
368
435
  // Write updated document
@@ -0,0 +1,401 @@
1
+ /**
2
+ * SPARQL Update Parser
3
+ *
4
+ * Parses SPARQL Update syntax and applies changes to JSON-LD documents.
5
+ * Supports:
6
+ * - INSERT DATA { triples }
7
+ * - DELETE DATA { triples }
8
+ * - DELETE { pattern } INSERT { pattern } WHERE { pattern }
9
+ *
10
+ * Note: This is a simplified parser for common Solid use cases.
11
+ */
12
+
13
+ import { Parser, Writer, DataFactory } from 'n3';
14
+ const { namedNode, literal, blankNode, quad } = DataFactory;
15
+
16
+ /**
17
+ * Parse a SPARQL Update query
18
+ * @param {string} sparql - The SPARQL Update query
19
+ * @param {string} baseUri - Base URI for relative references
20
+ * @returns {{ inserts: Array, deletes: Array }}
21
+ */
22
+ export function parseSparqlUpdate(sparql, baseUri) {
23
+ const result = {
24
+ inserts: [],
25
+ deletes: []
26
+ };
27
+
28
+ // Normalize whitespace
29
+ const normalized = sparql.trim();
30
+
31
+ // Extract prefixes
32
+ const prefixes = {};
33
+ const prefixRegex = /PREFIX\s+(\w*):?\s*<([^>]+)>/gi;
34
+ let match;
35
+ while ((match = prefixRegex.exec(normalized)) !== null) {
36
+ prefixes[match[1] || ''] = match[2];
37
+ }
38
+
39
+ // Remove prefix declarations for parsing
40
+ let query = normalized.replace(/PREFIX\s+\w*:?\s*<[^>]+>\s*/gi, '').trim();
41
+
42
+ // Handle INSERT DATA
43
+ const insertDataMatch = query.match(/INSERT\s+DATA\s*\{([^}]+)\}/is);
44
+ if (insertDataMatch) {
45
+ const triples = parseTriples(insertDataMatch[1], baseUri, prefixes);
46
+ result.inserts.push(...triples);
47
+ }
48
+
49
+ // Handle DELETE DATA
50
+ const deleteDataMatch = query.match(/DELETE\s+DATA\s*\{([^}]+)\}/is);
51
+ if (deleteDataMatch) {
52
+ const triples = parseTriples(deleteDataMatch[1], baseUri, prefixes);
53
+ result.deletes.push(...triples);
54
+ }
55
+
56
+ // Handle DELETE { } INSERT { } WHERE { }
57
+ // This is a simplified version - we treat DELETE/INSERT as data operations
58
+ const deleteInsertMatch = query.match(/DELETE\s*\{([^}]*)\}\s*INSERT\s*\{([^}]*)\}\s*WHERE\s*\{[^}]*\}/is);
59
+ if (deleteInsertMatch) {
60
+ if (deleteInsertMatch[1].trim()) {
61
+ const deleteTriples = parseTriples(deleteInsertMatch[1], baseUri, prefixes);
62
+ result.deletes.push(...deleteTriples);
63
+ }
64
+ if (deleteInsertMatch[2].trim()) {
65
+ const insertTriples = parseTriples(deleteInsertMatch[2], baseUri, prefixes);
66
+ result.inserts.push(...insertTriples);
67
+ }
68
+ }
69
+
70
+ // Handle DELETE WHERE { } (shorthand for DELETE { pattern } WHERE { pattern })
71
+ const deleteWhereMatch = query.match(/DELETE\s+WHERE\s*\{([^}]+)\}/is);
72
+ if (deleteWhereMatch) {
73
+ const triples = parseTriples(deleteWhereMatch[1], baseUri, prefixes);
74
+ result.deletes.push(...triples);
75
+ }
76
+
77
+ // Handle standalone INSERT { } WHERE { }
78
+ const insertWhereMatch = query.match(/INSERT\s*\{([^}]+)\}\s*WHERE\s*\{[^}]*\}/is);
79
+ if (insertWhereMatch && !deleteInsertMatch) {
80
+ const triples = parseTriples(insertWhereMatch[1], baseUri, prefixes);
81
+ result.inserts.push(...triples);
82
+ }
83
+
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * Parse Turtle-like triples into an array of triple objects
89
+ * @param {string} triplesStr - Turtle-formatted triples
90
+ * @param {string} baseUri - Base URI
91
+ * @param {object} prefixes - Prefix mappings
92
+ * @returns {Array<{subject: string, predicate: string, object: any}>}
93
+ */
94
+ function parseTriples(triplesStr, baseUri, prefixes = {}) {
95
+ const triples = [];
96
+
97
+ // Build prefix declarations for N3 parser
98
+ let turtleDoc = '';
99
+ for (const [prefix, uri] of Object.entries(prefixes)) {
100
+ turtleDoc += `@prefix ${prefix}: <${uri}> .\n`;
101
+ }
102
+ turtleDoc += `@base <${baseUri}> .\n`;
103
+ turtleDoc += triplesStr;
104
+
105
+ try {
106
+ const parser = new Parser({ baseIRI: baseUri });
107
+ const quads = parser.parse(turtleDoc);
108
+
109
+ for (const q of quads) {
110
+ triples.push({
111
+ subject: termToValue(q.subject, baseUri),
112
+ predicate: termToValue(q.predicate, baseUri),
113
+ object: termToJsonLdValue(q.object, baseUri)
114
+ });
115
+ }
116
+ } catch (e) {
117
+ // If N3 parsing fails, try simple regex parsing for basic patterns
118
+ const simpleTriples = parseSimpleTriples(triplesStr, baseUri, prefixes);
119
+ triples.push(...simpleTriples);
120
+ }
121
+
122
+ return triples;
123
+ }
124
+
125
+ /**
126
+ * Convert N3 term to a string value
127
+ */
128
+ function termToValue(term, baseUri) {
129
+ if (term.termType === 'NamedNode') {
130
+ return term.value;
131
+ } else if (term.termType === 'BlankNode') {
132
+ return `_:${term.value}`;
133
+ }
134
+ return term.value;
135
+ }
136
+
137
+ /**
138
+ * Convert N3 term to JSON-LD value format
139
+ */
140
+ function termToJsonLdValue(term, baseUri) {
141
+ if (term.termType === 'NamedNode') {
142
+ return { '@id': term.value };
143
+ } else if (term.termType === 'BlankNode') {
144
+ return { '@id': `_:${term.value}` };
145
+ } else if (term.termType === 'Literal') {
146
+ if (term.datatype && term.datatype.value !== 'http://www.w3.org/2001/XMLSchema#string') {
147
+ return {
148
+ '@value': term.value,
149
+ '@type': term.datatype.value
150
+ };
151
+ } else if (term.language) {
152
+ return {
153
+ '@value': term.value,
154
+ '@language': term.language
155
+ };
156
+ }
157
+ return term.value;
158
+ }
159
+ return term.value;
160
+ }
161
+
162
+ /**
163
+ * Simple regex-based triple parsing as fallback
164
+ */
165
+ function parseSimpleTriples(triplesStr, baseUri, prefixes) {
166
+ const triples = [];
167
+
168
+ // Very basic pattern matching for simple cases
169
+ // Format: <subject> <predicate> <object> .
170
+ // or: <subject> <predicate> "literal" .
171
+ const lines = triplesStr.split(/\.\s*/).filter(l => l.trim());
172
+
173
+ for (const line of lines) {
174
+ const parts = line.trim().match(/^(<[^>]+>|[\w:]+)\s+(<[^>]+>|[\w:]+)\s+(.+)$/);
175
+ if (parts) {
176
+ const subject = expandUri(parts[1].trim(), baseUri, prefixes);
177
+ const predicate = expandUri(parts[2].trim(), baseUri, prefixes);
178
+ const objectStr = parts[3].trim();
179
+
180
+ let object;
181
+ if (objectStr.startsWith('<') && objectStr.endsWith('>')) {
182
+ object = { '@id': expandUri(objectStr, baseUri, prefixes) };
183
+ } else if (objectStr.startsWith('"')) {
184
+ // Parse literal
185
+ const literalMatch = objectStr.match(/^"([^"]*)"(?:@(\w+)|\^\^<([^>]+)>)?/);
186
+ if (literalMatch) {
187
+ if (literalMatch[2]) {
188
+ object = { '@value': literalMatch[1], '@language': literalMatch[2] };
189
+ } else if (literalMatch[3]) {
190
+ object = { '@value': literalMatch[1], '@type': literalMatch[3] };
191
+ } else {
192
+ object = literalMatch[1];
193
+ }
194
+ } else {
195
+ object = objectStr.replace(/^"|"$/g, '');
196
+ }
197
+ } else {
198
+ // Prefixed name
199
+ object = { '@id': expandUri(objectStr, baseUri, prefixes) };
200
+ }
201
+
202
+ triples.push({ subject, predicate, object });
203
+ }
204
+ }
205
+
206
+ return triples;
207
+ }
208
+
209
+ /**
210
+ * Expand a prefixed name or relative URI
211
+ */
212
+ function expandUri(uri, baseUri, prefixes) {
213
+ if (uri.startsWith('<') && uri.endsWith('>')) {
214
+ const inner = uri.slice(1, -1);
215
+ if (inner.startsWith('http://') || inner.startsWith('https://')) {
216
+ return inner;
217
+ }
218
+ // Relative URI
219
+ return new URL(inner, baseUri).href;
220
+ }
221
+
222
+ // Check for prefixed name
223
+ const colonIndex = uri.indexOf(':');
224
+ if (colonIndex > 0) {
225
+ const prefix = uri.substring(0, colonIndex);
226
+ const local = uri.substring(colonIndex + 1);
227
+ if (prefixes[prefix]) {
228
+ return prefixes[prefix] + local;
229
+ }
230
+ }
231
+
232
+ return uri;
233
+ }
234
+
235
+ /**
236
+ * Apply SPARQL Update to a JSON-LD document
237
+ * @param {object} document - The JSON-LD document
238
+ * @param {{ inserts: Array, deletes: Array }} update - Parsed SPARQL Update
239
+ * @param {string} baseUri - Base URI of the document
240
+ * @returns {object} Updated document
241
+ */
242
+ export function applySparqlUpdate(document, update, baseUri) {
243
+ // Clone the document
244
+ let doc = JSON.parse(JSON.stringify(document));
245
+
246
+ // Ensure document is in a workable format
247
+ if (!Array.isArray(doc)) {
248
+ doc = [doc];
249
+ }
250
+
251
+ // Apply deletes
252
+ for (const triple of update.deletes) {
253
+ doc = deleteTriple(doc, triple, baseUri);
254
+ }
255
+
256
+ // Apply inserts
257
+ for (const triple of update.inserts) {
258
+ doc = insertTriple(doc, triple, baseUri);
259
+ }
260
+
261
+ // Return single object if only one node
262
+ if (Array.isArray(doc) && doc.length === 1) {
263
+ return doc[0];
264
+ }
265
+
266
+ return doc;
267
+ }
268
+
269
+ /**
270
+ * Delete a triple from a JSON-LD document
271
+ */
272
+ function deleteTriple(doc, triple, baseUri) {
273
+ const subjectId = resolveSubjectId(triple.subject, baseUri);
274
+
275
+ for (const node of doc) {
276
+ const nodeId = node['@id'] || '';
277
+ if (matchesSubject(nodeId, subjectId, baseUri)) {
278
+ // Found the subject node, remove the predicate-object pair
279
+ if (node[triple.predicate]) {
280
+ const values = Array.isArray(node[triple.predicate])
281
+ ? node[triple.predicate]
282
+ : [node[triple.predicate]];
283
+
284
+ const filtered = values.filter(v => !valuesMatch(v, triple.object));
285
+
286
+ if (filtered.length === 0) {
287
+ delete node[triple.predicate];
288
+ } else if (filtered.length === 1) {
289
+ node[triple.predicate] = filtered[0];
290
+ } else {
291
+ node[triple.predicate] = filtered;
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ return doc;
298
+ }
299
+
300
+ /**
301
+ * Insert a triple into a JSON-LD document
302
+ */
303
+ function insertTriple(doc, triple, baseUri) {
304
+ const subjectId = resolveSubjectId(triple.subject, baseUri);
305
+
306
+ // Find existing node for subject
307
+ let targetNode = null;
308
+ for (const node of doc) {
309
+ const nodeId = node['@id'] || '';
310
+ if (matchesSubject(nodeId, subjectId, baseUri)) {
311
+ targetNode = node;
312
+ break;
313
+ }
314
+ }
315
+
316
+ // Create new node if not found
317
+ if (!targetNode) {
318
+ targetNode = { '@id': subjectId };
319
+ doc.push(targetNode);
320
+ }
321
+
322
+ // Add the predicate-object pair
323
+ if (targetNode[triple.predicate]) {
324
+ const existing = targetNode[triple.predicate];
325
+ if (Array.isArray(existing)) {
326
+ if (!existing.some(v => valuesMatch(v, triple.object))) {
327
+ existing.push(triple.object);
328
+ }
329
+ } else {
330
+ if (!valuesMatch(existing, triple.object)) {
331
+ targetNode[triple.predicate] = [existing, triple.object];
332
+ }
333
+ }
334
+ } else {
335
+ targetNode[triple.predicate] = triple.object;
336
+ }
337
+
338
+ return doc;
339
+ }
340
+
341
+ /**
342
+ * Resolve subject ID, handling relative URIs and hash URIs
343
+ */
344
+ function resolveSubjectId(subject, baseUri) {
345
+ if (subject.startsWith('#')) {
346
+ return baseUri + subject;
347
+ }
348
+ if (subject.startsWith('_:')) {
349
+ return subject;
350
+ }
351
+ if (!subject.includes('://')) {
352
+ return new URL(subject, baseUri).href;
353
+ }
354
+ return subject;
355
+ }
356
+
357
+ /**
358
+ * Check if a node ID matches a subject
359
+ */
360
+ function matchesSubject(nodeId, subjectId, baseUri) {
361
+ if (nodeId === subjectId) return true;
362
+
363
+ // Handle hash URIs
364
+ if (subjectId.startsWith('#') && nodeId === baseUri + subjectId) return true;
365
+ if (nodeId.startsWith('#') && subjectId === baseUri + nodeId) return true;
366
+
367
+ return false;
368
+ }
369
+
370
+ /**
371
+ * Check if two JSON-LD values match
372
+ */
373
+ function valuesMatch(a, b) {
374
+ if (typeof a === 'string' && typeof b === 'string') {
375
+ return a === b;
376
+ }
377
+
378
+ if (typeof a === 'object' && typeof b === 'object') {
379
+ // Compare @id
380
+ if (a['@id'] && b['@id']) {
381
+ return a['@id'] === b['@id'];
382
+ }
383
+
384
+ // Compare @value
385
+ if (a['@value'] !== undefined && b['@value'] !== undefined) {
386
+ return a['@value'] === b['@value'] &&
387
+ a['@type'] === b['@type'] &&
388
+ a['@language'] === b['@language'];
389
+ }
390
+ }
391
+
392
+ // Mixed string/object comparison
393
+ if (typeof a === 'string' && typeof b === 'object' && b['@value']) {
394
+ return a === b['@value'];
395
+ }
396
+ if (typeof b === 'string' && typeof a === 'object' && a['@value']) {
397
+ return b === a['@value'];
398
+ }
399
+
400
+ return JSON.stringify(a) === JSON.stringify(b);
401
+ }