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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
+ });