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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Content Negotiation for RDF Resources
3
+ *
4
+ * Handles Accept header parsing and format selection.
5
+ * OFF by default - this is a JSON-LD native implementation.
6
+ * Enable with { conneg: true } in server options.
7
+ */
8
+
9
+ import { turtleToJsonLd, jsonLdToTurtle } from './turtle.js';
10
+
11
+ // RDF content types we support
12
+ export const RDF_TYPES = {
13
+ JSON_LD: 'application/ld+json',
14
+ TURTLE: 'text/turtle',
15
+ N3: 'text/n3',
16
+ NTRIPLES: 'application/n-triples',
17
+ RDF_XML: 'application/rdf+xml' // Not supported, but recognized
18
+ };
19
+
20
+ // Content types we can serve (when conneg enabled)
21
+ const SUPPORTED_OUTPUT = [RDF_TYPES.JSON_LD, RDF_TYPES.TURTLE];
22
+
23
+ // Content types we can accept for input (when conneg enabled)
24
+ const SUPPORTED_INPUT = [RDF_TYPES.JSON_LD, RDF_TYPES.TURTLE, RDF_TYPES.N3];
25
+
26
+ /**
27
+ * Parse Accept header and select best content type
28
+ * @param {string} acceptHeader - Accept header value
29
+ * @param {boolean} connegEnabled - Whether content negotiation is enabled
30
+ * @returns {string} Selected content type
31
+ */
32
+ export function selectContentType(acceptHeader, connegEnabled = false) {
33
+ // If conneg disabled, always return JSON-LD
34
+ if (!connegEnabled) {
35
+ return RDF_TYPES.JSON_LD;
36
+ }
37
+
38
+ if (!acceptHeader) {
39
+ return RDF_TYPES.JSON_LD;
40
+ }
41
+
42
+ // Parse Accept header
43
+ const accepts = parseAcceptHeader(acceptHeader);
44
+
45
+ // Find best match
46
+ for (const { type } of accepts) {
47
+ if (type === '*/*' || type === 'application/*') {
48
+ return RDF_TYPES.JSON_LD;
49
+ }
50
+ if (SUPPORTED_OUTPUT.includes(type)) {
51
+ return type;
52
+ }
53
+ // Handle text/* preference
54
+ if (type === 'text/*') {
55
+ return RDF_TYPES.TURTLE;
56
+ }
57
+ }
58
+
59
+ // Default to JSON-LD
60
+ return RDF_TYPES.JSON_LD;
61
+ }
62
+
63
+ /**
64
+ * Parse Accept header into sorted list
65
+ */
66
+ function parseAcceptHeader(header) {
67
+ const types = header.split(',').map(part => {
68
+ const [type, ...params] = part.trim().split(';');
69
+ let q = 1;
70
+
71
+ for (const param of params) {
72
+ const [key, value] = param.trim().split('=');
73
+ if (key === 'q') {
74
+ q = parseFloat(value) || 0;
75
+ }
76
+ }
77
+
78
+ return { type: type.trim().toLowerCase(), q };
79
+ });
80
+
81
+ // Sort by q value descending
82
+ return types.sort((a, b) => b.q - a.q);
83
+ }
84
+
85
+ /**
86
+ * Check if content type is RDF
87
+ */
88
+ export function isRdfType(contentType) {
89
+ if (!contentType) return false;
90
+ const type = contentType.split(';')[0].trim().toLowerCase();
91
+ return Object.values(RDF_TYPES).includes(type) ||
92
+ type === 'application/json'; // Treat as JSON-LD
93
+ }
94
+
95
+ /**
96
+ * Check if we can accept this input type for RDF resources
97
+ * Non-RDF content types are always accepted (passthrough)
98
+ */
99
+ export function canAcceptInput(contentType, connegEnabled = false) {
100
+ if (!contentType) return true; // No content type = accept
101
+
102
+ const type = contentType.split(';')[0].trim().toLowerCase();
103
+
104
+ // Always accept JSON-LD and JSON
105
+ if (type === RDF_TYPES.JSON_LD || type === 'application/json') {
106
+ return true;
107
+ }
108
+
109
+ // Check if it's an RDF type we need to handle
110
+ const isRdf = Object.values(RDF_TYPES).includes(type);
111
+
112
+ // Non-RDF types are accepted as-is (passthrough)
113
+ if (!isRdf) {
114
+ return true;
115
+ }
116
+
117
+ // RDF types other than JSON-LD only if conneg enabled
118
+ if (connegEnabled) {
119
+ return SUPPORTED_INPUT.includes(type);
120
+ }
121
+
122
+ // RDF type but conneg disabled - reject (should use JSON-LD)
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Convert content to JSON-LD (internal storage format)
128
+ * @param {Buffer|string} content - Input content
129
+ * @param {string} contentType - Content-Type header
130
+ * @param {string} baseUri - Base URI
131
+ * @param {boolean} connegEnabled - Whether conneg is enabled
132
+ * @returns {Promise<object>} JSON-LD document
133
+ */
134
+ export async function toJsonLd(content, contentType, baseUri, connegEnabled = false) {
135
+ const type = (contentType || '').split(';')[0].trim().toLowerCase();
136
+ const text = Buffer.isBuffer(content) ? content.toString() : content;
137
+
138
+ // JSON-LD or JSON
139
+ if (type === RDF_TYPES.JSON_LD || type === 'application/json' || !type) {
140
+ return JSON.parse(text);
141
+ }
142
+
143
+ // Turtle/N3 - only if conneg enabled
144
+ if (connegEnabled && (type === RDF_TYPES.TURTLE || type === RDF_TYPES.N3)) {
145
+ return turtleToJsonLd(text, baseUri);
146
+ }
147
+
148
+ throw new Error(`Unsupported content type: ${type}`);
149
+ }
150
+
151
+ /**
152
+ * Convert JSON-LD to requested format
153
+ * @param {object} jsonLd - JSON-LD document
154
+ * @param {string} targetType - Target content type
155
+ * @param {string} baseUri - Base URI
156
+ * @param {boolean} connegEnabled - Whether conneg is enabled
157
+ * @returns {Promise<{content: string, contentType: string}>}
158
+ */
159
+ export async function fromJsonLd(jsonLd, targetType, baseUri, connegEnabled = false) {
160
+ // If conneg disabled, always output JSON-LD
161
+ if (!connegEnabled) {
162
+ return {
163
+ content: JSON.stringify(jsonLd, null, 2),
164
+ contentType: RDF_TYPES.JSON_LD
165
+ };
166
+ }
167
+
168
+ // JSON-LD
169
+ if (targetType === RDF_TYPES.JSON_LD || !targetType) {
170
+ return {
171
+ content: JSON.stringify(jsonLd, null, 2),
172
+ contentType: RDF_TYPES.JSON_LD
173
+ };
174
+ }
175
+
176
+ // Turtle
177
+ if (targetType === RDF_TYPES.TURTLE) {
178
+ const turtle = await jsonLdToTurtle(jsonLd, baseUri);
179
+ return { content: turtle, contentType: RDF_TYPES.TURTLE };
180
+ }
181
+
182
+ // Fallback to JSON-LD
183
+ return {
184
+ content: JSON.stringify(jsonLd, null, 2),
185
+ contentType: RDF_TYPES.JSON_LD
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Get Vary header value for content negotiation
191
+ */
192
+ export function getVaryHeader(connegEnabled) {
193
+ return connegEnabled ? 'Accept, Origin' : 'Origin';
194
+ }
195
+
196
+ /**
197
+ * Get Accept-* headers for responses
198
+ */
199
+ export function getAcceptHeaders(connegEnabled, isContainer = false) {
200
+ const headers = {};
201
+
202
+ if (isContainer) {
203
+ headers['Accept-Post'] = connegEnabled
204
+ ? `${RDF_TYPES.JSON_LD}, ${RDF_TYPES.TURTLE}, */*`
205
+ : `${RDF_TYPES.JSON_LD}, */*`;
206
+ }
207
+
208
+ headers['Accept-Put'] = connegEnabled
209
+ ? `${RDF_TYPES.JSON_LD}, ${RDF_TYPES.TURTLE}, */*`
210
+ : `${RDF_TYPES.JSON_LD}, */*`;
211
+
212
+ headers['Accept-Patch'] = 'text/n3, application/sparql-update';
213
+
214
+ return headers;
215
+ }
@@ -0,0 +1,411 @@
1
+ /**
2
+ * Turtle <-> JSON-LD Conversion
3
+ *
4
+ * Provides bidirectional conversion between Turtle and JSON-LD formats.
5
+ * Uses the N3.js library for parsing and serializing Turtle.
6
+ */
7
+
8
+ import { Parser, Writer, DataFactory } from 'n3';
9
+ const { namedNode, literal, blankNode, quad } = DataFactory;
10
+
11
+ // Common prefixes for compact output
12
+ const COMMON_PREFIXES = {
13
+ rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
14
+ rdfs: 'http://www.w3.org/2000/01/rdf-schema#',
15
+ xsd: 'http://www.w3.org/2001/XMLSchema#',
16
+ foaf: 'http://xmlns.com/foaf/0.1/',
17
+ ldp: 'http://www.w3.org/ns/ldp#',
18
+ solid: 'http://www.w3.org/ns/solid/terms#',
19
+ acl: 'http://www.w3.org/ns/auth/acl#',
20
+ pim: 'http://www.w3.org/ns/pim/space#',
21
+ dc: 'http://purl.org/dc/terms/',
22
+ schema: 'http://schema.org/',
23
+ vcard: 'http://www.w3.org/2006/vcard/ns#'
24
+ };
25
+
26
+ /**
27
+ * Parse Turtle to JSON-LD
28
+ * @param {string} turtle - Turtle content
29
+ * @param {string} baseUri - Base URI for relative references
30
+ * @returns {Promise<object>} JSON-LD document
31
+ */
32
+ export async function turtleToJsonLd(turtle, baseUri) {
33
+ return new Promise((resolve, reject) => {
34
+ const parser = new Parser({ baseIRI: baseUri });
35
+ const quads = [];
36
+
37
+ parser.parse(turtle, (error, quad, prefixes) => {
38
+ if (error) {
39
+ reject(error);
40
+ return;
41
+ }
42
+
43
+ if (quad) {
44
+ quads.push(quad);
45
+ } else {
46
+ // Parsing complete
47
+ try {
48
+ const jsonLd = quadsToJsonLd(quads, baseUri, prefixes);
49
+ resolve(jsonLd);
50
+ } catch (e) {
51
+ reject(e);
52
+ }
53
+ }
54
+ });
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Convert JSON-LD to Turtle
60
+ * @param {object} jsonLd - JSON-LD document
61
+ * @param {string} baseUri - Base URI for the document
62
+ * @returns {Promise<string>} Turtle content
63
+ */
64
+ export async function jsonLdToTurtle(jsonLd, baseUri) {
65
+ return new Promise((resolve, reject) => {
66
+ try {
67
+ const quads = jsonLdToQuads(jsonLd, baseUri);
68
+
69
+ const writer = new Writer({
70
+ prefixes: COMMON_PREFIXES,
71
+ baseIRI: baseUri
72
+ });
73
+
74
+ for (const q of quads) {
75
+ writer.addQuad(q);
76
+ }
77
+
78
+ writer.end((error, result) => {
79
+ if (error) {
80
+ reject(error);
81
+ } else {
82
+ resolve(result);
83
+ }
84
+ });
85
+ } catch (e) {
86
+ reject(e);
87
+ }
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Convert N3.js quads to JSON-LD
93
+ */
94
+ function quadsToJsonLd(quads, baseUri, prefixes = {}) {
95
+ if (quads.length === 0) {
96
+ return { '@context': buildContext(prefixes) };
97
+ }
98
+
99
+ // Group quads by subject
100
+ const subjects = new Map();
101
+
102
+ for (const quad of quads) {
103
+ const subjectKey = quad.subject.value;
104
+ if (!subjects.has(subjectKey)) {
105
+ subjects.set(subjectKey, {
106
+ '@id': makeRelative(quad.subject.value, baseUri),
107
+ _quads: []
108
+ });
109
+ }
110
+ subjects.get(subjectKey)._quads.push(quad);
111
+ }
112
+
113
+ // Build nodes
114
+ const nodes = [];
115
+ for (const [subjectUri, node] of subjects) {
116
+ const jsonNode = { '@id': node['@id'] };
117
+
118
+ for (const quad of node._quads) {
119
+ const predicate = quad.predicate.value;
120
+ const predicateKey = compactUri(predicate, prefixes);
121
+
122
+ // Handle rdf:type specially
123
+ if (predicate === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type') {
124
+ const typeValue = compactUri(quad.object.value, prefixes);
125
+ if (jsonNode['@type']) {
126
+ if (Array.isArray(jsonNode['@type'])) {
127
+ jsonNode['@type'].push(typeValue);
128
+ } else {
129
+ jsonNode['@type'] = [jsonNode['@type'], typeValue];
130
+ }
131
+ } else {
132
+ jsonNode['@type'] = typeValue;
133
+ }
134
+ continue;
135
+ }
136
+
137
+ const objectValue = termToJsonLd(quad.object, baseUri, prefixes);
138
+
139
+ if (jsonNode[predicateKey]) {
140
+ // Multiple values - make array
141
+ if (Array.isArray(jsonNode[predicateKey])) {
142
+ jsonNode[predicateKey].push(objectValue);
143
+ } else {
144
+ jsonNode[predicateKey] = [jsonNode[predicateKey], objectValue];
145
+ }
146
+ } else {
147
+ jsonNode[predicateKey] = objectValue;
148
+ }
149
+ }
150
+
151
+ nodes.push(jsonNode);
152
+ }
153
+
154
+ // Build result
155
+ const context = buildContext(prefixes);
156
+
157
+ if (nodes.length === 1) {
158
+ return { '@context': context, ...nodes[0] };
159
+ }
160
+
161
+ return { '@context': context, '@graph': nodes };
162
+ }
163
+
164
+ /**
165
+ * Convert JSON-LD to N3.js quads
166
+ */
167
+ function jsonLdToQuads(jsonLd, baseUri) {
168
+ const quads = [];
169
+ const context = jsonLd['@context'] || {};
170
+
171
+ // Handle @graph or single object
172
+ const nodes = jsonLd['@graph'] || [jsonLd];
173
+
174
+ for (const node of nodes) {
175
+ if (!node['@id']) continue;
176
+
177
+ const subjectUri = resolveUri(node['@id'], baseUri);
178
+ const subject = subjectUri.startsWith('_:')
179
+ ? blankNode(subjectUri.slice(2))
180
+ : namedNode(subjectUri);
181
+
182
+ // Handle @type
183
+ if (node['@type']) {
184
+ const types = Array.isArray(node['@type']) ? node['@type'] : [node['@type']];
185
+ for (const type of types) {
186
+ const typeUri = expandUri(type, context);
187
+ quads.push(quad(
188
+ subject,
189
+ namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
190
+ namedNode(typeUri)
191
+ ));
192
+ }
193
+ }
194
+
195
+ // Handle other properties
196
+ for (const [key, value] of Object.entries(node)) {
197
+ if (key.startsWith('@')) continue;
198
+
199
+ const predicateUri = expandUri(key, context);
200
+ const predicate = namedNode(predicateUri);
201
+
202
+ const values = Array.isArray(value) ? value : [value];
203
+ for (const v of values) {
204
+ const object = valueToTerm(v, baseUri, context);
205
+ if (object) {
206
+ quads.push(quad(subject, predicate, object));
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ return quads;
213
+ }
214
+
215
+ /**
216
+ * Convert N3.js term to JSON-LD value
217
+ */
218
+ function termToJsonLd(term, baseUri, prefixes) {
219
+ if (term.termType === 'NamedNode') {
220
+ const uri = makeRelative(term.value, baseUri);
221
+ // Check if it looks like a URI or should be @id
222
+ if (uri.includes('://') || uri.startsWith('#') || uri.startsWith('/')) {
223
+ return { '@id': uri };
224
+ }
225
+ return { '@id': uri };
226
+ }
227
+
228
+ if (term.termType === 'BlankNode') {
229
+ return { '@id': '_:' + term.value };
230
+ }
231
+
232
+ if (term.termType === 'Literal') {
233
+ // Check for language tag
234
+ if (term.language) {
235
+ return { '@value': term.value, '@language': term.language };
236
+ }
237
+
238
+ // Check for datatype
239
+ const datatype = term.datatype?.value;
240
+ if (datatype) {
241
+ // Handle common XSD types
242
+ if (datatype === 'http://www.w3.org/2001/XMLSchema#integer') {
243
+ return parseInt(term.value, 10);
244
+ }
245
+ if (datatype === 'http://www.w3.org/2001/XMLSchema#decimal' ||
246
+ datatype === 'http://www.w3.org/2001/XMLSchema#double' ||
247
+ datatype === 'http://www.w3.org/2001/XMLSchema#float') {
248
+ return parseFloat(term.value);
249
+ }
250
+ if (datatype === 'http://www.w3.org/2001/XMLSchema#boolean') {
251
+ return term.value === 'true';
252
+ }
253
+ if (datatype === 'http://www.w3.org/2001/XMLSchema#string') {
254
+ return term.value;
255
+ }
256
+ // Other typed literals
257
+ return { '@value': term.value, '@type': compactUri(datatype, prefixes) };
258
+ }
259
+
260
+ return term.value;
261
+ }
262
+
263
+ return term.value;
264
+ }
265
+
266
+ /**
267
+ * Convert JSON-LD value to N3.js term
268
+ */
269
+ function valueToTerm(value, baseUri, context) {
270
+ if (value === null || value === undefined) {
271
+ return null;
272
+ }
273
+
274
+ // Plain values
275
+ if (typeof value === 'string') {
276
+ return literal(value);
277
+ }
278
+ if (typeof value === 'number') {
279
+ if (Number.isInteger(value)) {
280
+ return literal(value.toString(), namedNode('http://www.w3.org/2001/XMLSchema#integer'));
281
+ }
282
+ return literal(value.toString(), namedNode('http://www.w3.org/2001/XMLSchema#decimal'));
283
+ }
284
+ if (typeof value === 'boolean') {
285
+ return literal(value.toString(), namedNode('http://www.w3.org/2001/XMLSchema#boolean'));
286
+ }
287
+
288
+ // Object values
289
+ if (typeof value === 'object') {
290
+ // @id reference
291
+ if (value['@id']) {
292
+ const uri = resolveUri(value['@id'], baseUri);
293
+ return uri.startsWith('_:')
294
+ ? blankNode(uri.slice(2))
295
+ : namedNode(uri);
296
+ }
297
+
298
+ // @value with @language
299
+ if (value['@value'] && value['@language']) {
300
+ return literal(value['@value'], value['@language']);
301
+ }
302
+
303
+ // @value with @type
304
+ if (value['@value'] && value['@type']) {
305
+ const typeUri = expandUri(value['@type'], context);
306
+ return literal(value['@value'], namedNode(typeUri));
307
+ }
308
+
309
+ // Plain @value
310
+ if (value['@value']) {
311
+ return literal(value['@value']);
312
+ }
313
+ }
314
+
315
+ return null;
316
+ }
317
+
318
+ /**
319
+ * Make URI relative to base
320
+ */
321
+ function makeRelative(uri, baseUri) {
322
+ if (uri.startsWith(baseUri)) {
323
+ const relative = uri.slice(baseUri.length);
324
+ if (relative.startsWith('#') || relative === '') {
325
+ return relative || '.';
326
+ }
327
+ return relative;
328
+ }
329
+ return uri;
330
+ }
331
+
332
+ /**
333
+ * Resolve relative URI against base
334
+ */
335
+ function resolveUri(uri, baseUri) {
336
+ if (uri.startsWith('http://') || uri.startsWith('https://') || uri.startsWith('_:')) {
337
+ return uri;
338
+ }
339
+ if (uri.startsWith('#')) {
340
+ return baseUri + uri;
341
+ }
342
+ try {
343
+ return new URL(uri, baseUri).href;
344
+ } catch {
345
+ return uri;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Expand prefixed URI using context
351
+ */
352
+ function expandUri(uri, context) {
353
+ if (uri.includes('://')) {
354
+ return uri;
355
+ }
356
+
357
+ if (uri.includes(':')) {
358
+ const [prefix, local] = uri.split(':', 2);
359
+ const ns = context[prefix] || COMMON_PREFIXES[prefix];
360
+ if (ns) {
361
+ return ns + local;
362
+ }
363
+ }
364
+
365
+ // Check if it's a term in context
366
+ if (context[uri]) {
367
+ const expansion = context[uri];
368
+ if (typeof expansion === 'string') {
369
+ return expansion;
370
+ }
371
+ if (expansion['@id']) {
372
+ return expansion['@id'];
373
+ }
374
+ }
375
+
376
+ return uri;
377
+ }
378
+
379
+ /**
380
+ * Compact URI using prefixes
381
+ */
382
+ function compactUri(uri, prefixes) {
383
+ // Check custom prefixes first
384
+ for (const [prefix, ns] of Object.entries(prefixes)) {
385
+ if (uri.startsWith(ns)) {
386
+ return prefix + ':' + uri.slice(ns.length);
387
+ }
388
+ }
389
+
390
+ // Check common prefixes
391
+ for (const [prefix, ns] of Object.entries(COMMON_PREFIXES)) {
392
+ if (uri.startsWith(ns)) {
393
+ return prefix + ':' + uri.slice(ns.length);
394
+ }
395
+ }
396
+
397
+ return uri;
398
+ }
399
+
400
+ /**
401
+ * Build JSON-LD @context from prefixes
402
+ */
403
+ function buildContext(prefixes) {
404
+ const context = { ...COMMON_PREFIXES };
405
+ for (const [prefix, ns] of Object.entries(prefixes)) {
406
+ if (prefix && ns) {
407
+ context[prefix] = ns;
408
+ }
409
+ }
410
+ return context;
411
+ }
package/src/server.js CHANGED
@@ -1,13 +1,19 @@
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';
6
6
 
7
7
  /**
8
8
  * Create and configure Fastify server
9
+ * @param {object} options - Server options
10
+ * @param {boolean} options.logger - Enable logging (default true)
11
+ * @param {boolean} options.conneg - Enable content negotiation for RDF (default false)
9
12
  */
10
13
  export function createServer(options = {}) {
14
+ // Content negotiation is OFF by default - we're a JSON-LD native server
15
+ const connegEnabled = options.conneg ?? false;
16
+
11
17
  const fastify = Fastify({
12
18
  logger: options.logger ?? true,
13
19
  trustProxy: true,
@@ -20,6 +26,12 @@ export function createServer(options = {}) {
20
26
  done(null, body);
21
27
  });
22
28
 
29
+ // Attach server config to requests
30
+ fastify.decorateRequest('connegEnabled', null);
31
+ fastify.addHook('onRequest', async (request) => {
32
+ request.connegEnabled = connegEnabled;
33
+ });
34
+
23
35
  // Global CORS preflight
24
36
  fastify.addHook('onRequest', async (request, reply) => {
25
37
  // Add CORS headers to all responses
@@ -63,6 +75,7 @@ export function createServer(options = {}) {
63
75
  fastify.put('/*', handlePut);
64
76
  fastify.delete('/*', handleDelete);
65
77
  fastify.post('/*', handlePost);
78
+ fastify.patch('/*', handlePatch);
66
79
  fastify.options('/*', handleOptions);
67
80
 
68
81
  // Root route