javascript-solid-server 0.0.5 → 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.
@@ -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';
@@ -43,14 +43,14 @@ export function createServer(options = {}) {
43
43
  return;
44
44
  }
45
45
 
46
- const { authorized, webId, wacAllow } = await authorize(request, reply);
46
+ const { authorized, webId, wacAllow, authError } = await authorize(request, reply);
47
47
 
48
48
  // Store webId and wacAllow on request for handlers to use
49
49
  request.webId = webId;
50
50
  request.wacAllow = wacAllow;
51
51
 
52
52
  if (!authorized) {
53
- return handleUnauthorized(reply, webId !== null, wacAllow);
53
+ return handleUnauthorized(reply, webId !== null, wacAllow, authError);
54
54
  }
55
55
  });
56
56
 
@@ -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