javascript-solid-server 0.0.6 → 0.0.8
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/README.md +167 -146
- package/package.json +3 -2
- package/src/handlers/container.js +34 -3
- package/src/handlers/resource.js +184 -7
- package/src/ldp/headers.js +11 -9
- package/src/patch/n3-patch.js +522 -0
- package/src/rdf/conneg.js +215 -0
- package/src/rdf/turtle.js +411 -0
- package/src/server.js +14 -1
- package/test/conneg.test.js +289 -0
- package/test/helpers.js +4 -2
- package/test/patch.test.js +295 -0
package/src/handlers/resource.js
CHANGED
|
@@ -2,6 +2,15 @@ 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';
|
|
6
|
+
import {
|
|
7
|
+
selectContentType,
|
|
8
|
+
canAcceptInput,
|
|
9
|
+
toJsonLd,
|
|
10
|
+
fromJsonLd,
|
|
11
|
+
getVaryHeader,
|
|
12
|
+
RDF_TYPES
|
|
13
|
+
} from '../rdf/conneg.js';
|
|
5
14
|
|
|
6
15
|
/**
|
|
7
16
|
* Handle GET request
|
|
@@ -19,6 +28,8 @@ export async function handleGet(request, reply) {
|
|
|
19
28
|
|
|
20
29
|
// Handle container
|
|
21
30
|
if (stats.isDirectory) {
|
|
31
|
+
const connegEnabled = request.connegEnabled || false;
|
|
32
|
+
|
|
22
33
|
// Check for index.html (serves as both profile and container representation)
|
|
23
34
|
const indexPath = urlPath.endsWith('/') ? `${urlPath}index.html` : `${urlPath}/index.html`;
|
|
24
35
|
const indexExists = await storage.exists(indexPath);
|
|
@@ -33,7 +44,8 @@ export async function handleGet(request, reply) {
|
|
|
33
44
|
etag: indexStats?.etag || stats.etag,
|
|
34
45
|
contentType: 'text/html',
|
|
35
46
|
origin,
|
|
36
|
-
resourceUrl
|
|
47
|
+
resourceUrl,
|
|
48
|
+
connegEnabled
|
|
37
49
|
});
|
|
38
50
|
|
|
39
51
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
@@ -49,7 +61,8 @@ export async function handleGet(request, reply) {
|
|
|
49
61
|
etag: stats.etag,
|
|
50
62
|
contentType: 'application/ld+json',
|
|
51
63
|
origin,
|
|
52
|
-
resourceUrl
|
|
64
|
+
resourceUrl,
|
|
65
|
+
connegEnabled
|
|
53
66
|
});
|
|
54
67
|
|
|
55
68
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
@@ -62,14 +75,54 @@ export async function handleGet(request, reply) {
|
|
|
62
75
|
return reply.code(500).send({ error: 'Read error' });
|
|
63
76
|
}
|
|
64
77
|
|
|
65
|
-
const
|
|
78
|
+
const storedContentType = getContentType(urlPath);
|
|
79
|
+
const connegEnabled = request.connegEnabled || false;
|
|
80
|
+
|
|
81
|
+
// Content negotiation for RDF resources
|
|
82
|
+
if (connegEnabled && isRdfContentType(storedContentType)) {
|
|
83
|
+
try {
|
|
84
|
+
// Parse stored content as JSON-LD
|
|
85
|
+
const jsonLd = JSON.parse(content.toString());
|
|
86
|
+
|
|
87
|
+
// Select output format based on Accept header
|
|
88
|
+
const acceptHeader = request.headers.accept;
|
|
89
|
+
const targetType = selectContentType(acceptHeader, connegEnabled);
|
|
90
|
+
|
|
91
|
+
// Convert to requested format
|
|
92
|
+
const { content: outputContent, contentType: outputType } = await fromJsonLd(
|
|
93
|
+
jsonLd,
|
|
94
|
+
targetType,
|
|
95
|
+
resourceUrl,
|
|
96
|
+
connegEnabled
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const headers = getAllHeaders({
|
|
100
|
+
isContainer: false,
|
|
101
|
+
etag: stats.etag,
|
|
102
|
+
contentType: outputType,
|
|
103
|
+
origin,
|
|
104
|
+
resourceUrl,
|
|
105
|
+
connegEnabled
|
|
106
|
+
});
|
|
107
|
+
headers['Vary'] = getVaryHeader(connegEnabled);
|
|
108
|
+
|
|
109
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
110
|
+
return reply.send(outputContent);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
// If not valid JSON-LD, serve as-is
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Serve content as-is (no conneg or non-RDF resource)
|
|
66
117
|
const headers = getAllHeaders({
|
|
67
118
|
isContainer: false,
|
|
68
119
|
etag: stats.etag,
|
|
69
|
-
contentType,
|
|
120
|
+
contentType: storedContentType,
|
|
70
121
|
origin,
|
|
71
|
-
resourceUrl
|
|
122
|
+
resourceUrl,
|
|
123
|
+
connegEnabled
|
|
72
124
|
});
|
|
125
|
+
headers['Vary'] = getVaryHeader(connegEnabled);
|
|
73
126
|
|
|
74
127
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
75
128
|
return reply.send(content);
|
|
@@ -117,6 +170,20 @@ export async function handlePut(request, reply) {
|
|
|
117
170
|
return reply.code(409).send({ error: 'Cannot PUT to container. Use POST instead.' });
|
|
118
171
|
}
|
|
119
172
|
|
|
173
|
+
const connegEnabled = request.connegEnabled || false;
|
|
174
|
+
const contentType = request.headers['content-type'] || '';
|
|
175
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
176
|
+
|
|
177
|
+
// Check if we can accept this input type
|
|
178
|
+
if (!canAcceptInput(contentType, connegEnabled)) {
|
|
179
|
+
return reply.code(415).send({
|
|
180
|
+
error: 'Unsupported Media Type',
|
|
181
|
+
message: connegEnabled
|
|
182
|
+
? 'Supported types: application/ld+json, text/turtle, text/n3'
|
|
183
|
+
: 'Supported type: application/ld+json (enable conneg for Turtle support)'
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
120
187
|
// Check if resource already exists
|
|
121
188
|
const existed = await storage.exists(urlPath);
|
|
122
189
|
|
|
@@ -134,15 +201,29 @@ export async function handlePut(request, reply) {
|
|
|
134
201
|
content = Buffer.from('');
|
|
135
202
|
}
|
|
136
203
|
|
|
204
|
+
// Convert Turtle/N3 to JSON-LD if conneg enabled
|
|
205
|
+
const inputType = contentType.split(';')[0].trim().toLowerCase();
|
|
206
|
+
if (connegEnabled && (inputType === RDF_TYPES.TURTLE || inputType === RDF_TYPES.N3)) {
|
|
207
|
+
try {
|
|
208
|
+
const jsonLd = await toJsonLd(content, contentType, resourceUrl, connegEnabled);
|
|
209
|
+
content = Buffer.from(JSON.stringify(jsonLd, null, 2));
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return reply.code(400).send({
|
|
212
|
+
error: 'Bad Request',
|
|
213
|
+
message: 'Invalid Turtle/N3 format: ' + e.message
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
137
218
|
const success = await storage.write(urlPath, content);
|
|
138
219
|
if (!success) {
|
|
139
220
|
return reply.code(500).send({ error: 'Write failed' });
|
|
140
221
|
}
|
|
141
222
|
|
|
142
223
|
const origin = request.headers.origin;
|
|
143
|
-
const
|
|
144
|
-
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
|
|
224
|
+
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl, connegEnabled });
|
|
145
225
|
headers['Location'] = resourceUrl;
|
|
226
|
+
headers['Vary'] = getVaryHeader(connegEnabled);
|
|
146
227
|
|
|
147
228
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
148
229
|
return reply.code(existed ? 204 : 201).send();
|
|
@@ -190,3 +271,99 @@ export async function handleOptions(request, reply) {
|
|
|
190
271
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
191
272
|
return reply.code(204).send();
|
|
192
273
|
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Handle PATCH request
|
|
277
|
+
* Supports N3 Patch format (text/n3) for updating RDF resources
|
|
278
|
+
*/
|
|
279
|
+
export async function handlePatch(request, reply) {
|
|
280
|
+
const urlPath = request.url.split('?')[0];
|
|
281
|
+
|
|
282
|
+
// Don't allow PATCH to containers
|
|
283
|
+
if (isContainer(urlPath)) {
|
|
284
|
+
return reply.code(409).send({ error: 'Cannot PATCH containers' });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Check content type
|
|
288
|
+
const contentType = request.headers['content-type'] || '';
|
|
289
|
+
const isN3Patch = contentType.includes('text/n3') ||
|
|
290
|
+
contentType.includes('application/n3') ||
|
|
291
|
+
contentType.includes('application/sparql-update');
|
|
292
|
+
|
|
293
|
+
if (!isN3Patch) {
|
|
294
|
+
return reply.code(415).send({
|
|
295
|
+
error: 'Unsupported Media Type',
|
|
296
|
+
message: 'PATCH requires Content-Type: text/n3 for N3 Patch format'
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Check if resource exists
|
|
301
|
+
const stats = await storage.stat(urlPath);
|
|
302
|
+
if (!stats) {
|
|
303
|
+
return reply.code(404).send({ error: 'Not Found' });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Read existing content
|
|
307
|
+
const existingContent = await storage.read(urlPath);
|
|
308
|
+
if (existingContent === null) {
|
|
309
|
+
return reply.code(500).send({ error: 'Read error' });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Parse existing document as JSON-LD
|
|
313
|
+
let document;
|
|
314
|
+
try {
|
|
315
|
+
document = JSON.parse(existingContent.toString());
|
|
316
|
+
} catch (e) {
|
|
317
|
+
return reply.code(409).send({
|
|
318
|
+
error: 'Conflict',
|
|
319
|
+
message: 'Resource is not valid JSON-LD and cannot be patched'
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Parse the patch
|
|
324
|
+
const patchContent = Buffer.isBuffer(request.body)
|
|
325
|
+
? request.body.toString()
|
|
326
|
+
: request.body;
|
|
327
|
+
|
|
328
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
329
|
+
let patch;
|
|
330
|
+
try {
|
|
331
|
+
patch = parseN3Patch(patchContent, resourceUrl);
|
|
332
|
+
} catch (e) {
|
|
333
|
+
return reply.code(400).send({
|
|
334
|
+
error: 'Bad Request',
|
|
335
|
+
message: 'Invalid N3 Patch format: ' + e.message
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Validate that deletes exist (optional strict mode)
|
|
340
|
+
// const validation = validatePatch(document, patch, resourceUrl);
|
|
341
|
+
// if (!validation.valid) {
|
|
342
|
+
// return reply.code(409).send({ error: 'Conflict', message: validation.error });
|
|
343
|
+
// }
|
|
344
|
+
|
|
345
|
+
// Apply the patch
|
|
346
|
+
let updatedDocument;
|
|
347
|
+
try {
|
|
348
|
+
updatedDocument = applyN3Patch(document, patch, resourceUrl);
|
|
349
|
+
} catch (e) {
|
|
350
|
+
return reply.code(409).send({
|
|
351
|
+
error: 'Conflict',
|
|
352
|
+
message: 'Failed to apply patch: ' + e.message
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Write updated document
|
|
357
|
+
const updatedContent = JSON.stringify(updatedDocument, null, 2);
|
|
358
|
+
const success = await storage.write(urlPath, Buffer.from(updatedContent));
|
|
359
|
+
|
|
360
|
+
if (!success) {
|
|
361
|
+
return reply.code(500).send({ error: 'Write failed' });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const origin = request.headers.origin;
|
|
365
|
+
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
|
|
366
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
367
|
+
|
|
368
|
+
return reply.code(204).send();
|
|
369
|
+
}
|
package/src/ldp/headers.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* LDP (Linked Data Platform) header utilities
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { getAcceptHeaders } from '../rdf/conneg.js';
|
|
6
|
+
|
|
5
7
|
const LDP = 'http://www.w3.org/ns/ldp#';
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -47,21 +49,21 @@ export function getAclUrl(resourceUrl, isContainer) {
|
|
|
47
49
|
* @param {object} options
|
|
48
50
|
* @returns {object}
|
|
49
51
|
*/
|
|
50
|
-
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null }) {
|
|
52
|
+
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null, connegEnabled = false }) {
|
|
51
53
|
// Calculate ACL URL if resource URL provided
|
|
52
54
|
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
|
|
53
55
|
|
|
54
56
|
const headers = {
|
|
55
57
|
'Link': getLinkHeader(isContainer, aclUrl),
|
|
56
58
|
'WAC-Allow': wacAllow || 'user="read write append control", public="read write append"',
|
|
57
|
-
'Accept-Patch': 'application/sparql-update',
|
|
58
|
-
'Allow': 'GET, HEAD, PUT, DELETE, OPTIONS' + (isContainer ? ', POST' : ''),
|
|
59
|
-
'Vary': 'Accept, Authorization, Origin'
|
|
59
|
+
'Accept-Patch': 'text/n3, application/sparql-update',
|
|
60
|
+
'Allow': 'GET, HEAD, PUT, DELETE, PATCH, OPTIONS' + (isContainer ? ', POST' : ''),
|
|
61
|
+
'Vary': connegEnabled ? 'Accept, Authorization, Origin' : 'Authorization, Origin'
|
|
60
62
|
};
|
|
61
63
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
// Add Accept-* headers (conneg-aware)
|
|
65
|
+
const acceptHeaders = getAcceptHeaders(connegEnabled, isContainer);
|
|
66
|
+
Object.assign(headers, acceptHeaders);
|
|
65
67
|
|
|
66
68
|
if (etag) {
|
|
67
69
|
headers['ETag'] = etag;
|
|
@@ -95,9 +97,9 @@ export function getCorsHeaders(origin) {
|
|
|
95
97
|
* @param {object} options
|
|
96
98
|
* @returns {object}
|
|
97
99
|
*/
|
|
98
|
-
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null }) {
|
|
100
|
+
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null, connegEnabled = false }) {
|
|
99
101
|
return {
|
|
100
|
-
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow }),
|
|
102
|
+
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow, connegEnabled }),
|
|
101
103
|
...getCorsHeaders(origin)
|
|
102
104
|
};
|
|
103
105
|
}
|