Skip to content

Commit 86f74c5

Browse files
committed
feat: Add Solid-PREP Notifications
Add Solid/Activity Streams format notifications: + Provides notifictions in JSON-LD and Turtle. + Extends notifications to PUT and POST methods. + Add Event-ID header field to the response of a write method.
1 parent 91a074d commit 86f74c5

File tree

8 files changed

+179
-12
lines changed

8 files changed

+179
-12
lines changed

lib/handlers/delete.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ async function handler (req, res, next) {
99
try {
1010
await ldp.delete(req)
1111
debug('DELETE -- Ok.')
12+
// Add event-id for notifications
13+
res.setHeader('Event-ID', res.setEventID())
1214
res.sendStatus(200)
1315
next()
1416
} catch (err) {

lib/handlers/get.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const error = require('../http-error')
1919
const RDFs = require('../ldp').mimeTypesAsArray()
2020
const isRdf = require('../ldp').mimeTypeIsRdf
2121

22+
const prepConfig = 'accept=("message/rfc822" "application/ld+json" "text/turtle")'
23+
2224
async function handler (req, res, next) {
2325
const ldp = req.app.locals.ldp
2426
const includeBody = req.method === 'GET'
@@ -104,7 +106,7 @@ async function handler (req, res, next) {
104106
debug(' sending data browser file: ' + dataBrowserPath)
105107
res.sendFile(dataBrowserPath)
106108
return
107-
} else if (stream) {
109+
} else if (stream) { // EXIT text/html
108110
res.setHeader('Content-Type', contentType)
109111
return stream.pipe(res)
110112
}
@@ -137,7 +139,7 @@ async function handler (req, res, next) {
137139
}
138140

139141
if (isRdf(contentType) && !res.sendEvents({
140-
config: { prep: '' },
142+
config: { prep: prepConfig },
141143
body: stream,
142144
isBodyStream: true,
143145
headers
@@ -159,7 +161,7 @@ async function handler (req, res, next) {
159161
'Content-Type': possibleRDFType
160162
}
161163
if (isRdf(contentType) && !res.sendEvents({
162-
config: { prep: '' },
164+
config: { prep: prepConfig },
163165
body: data,
164166
headers
165167
})) return

lib/handlers/notify.js

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,101 @@
11
module.exports = handler
22

3+
const libPath = require('path/posix')
4+
5+
const headerTemplate = require('express-prep/templates').header
6+
const solidRDFTemplate = require('../rdf-notification-template')
7+
8+
const ALLOWED_RDF_MIME_TYPES = [
9+
'application/ld+json',
10+
'application/activity+json',
11+
'text/turtle'
12+
]
13+
14+
function getActivity (method) {
15+
if (method === 'DELETE') {
16+
return 'Delete'
17+
}
18+
return 'Update'
19+
}
20+
21+
function getParentActivity (method, status) {
22+
if (method === 'DELETE') {
23+
return 'Remove'
24+
}
25+
if (status === 201) {
26+
return 'Add'
27+
}
28+
return 'Update'
29+
}
30+
331
function handler (req, res, next) {
4-
res.events.prep.trigger({
5-
generateNotifications () {
6-
return res.events.prep.defaultNotification({
7-
...(res.method === 'POST') && { location: res.getHeader('Content-Location') }
8-
})
9-
}
10-
})
32+
const { trigger, defaultNotification } = res.events.prep
33+
34+
const { method } = req
35+
const { statusCode } = res
36+
const eventID = res.getHeader('event-id')
37+
38+
const parent = `${libPath.dirname(req.path)}/`
39+
const parentID = res.setEventID(parent)
40+
const fullUrl = new URL(req.path, `${req.protocol}://${req.hostname}/`)
41+
const parentUrl = new URL(parent, fullUrl)
42+
43+
// Date is a hack since node does not seem to provide access to send date.
44+
// Date needs to be shared with parent notification
45+
const eventDate = res._header.match(/^Date: (.*?)$/m)?.[1] ||
46+
new Date().toUTCString()
47+
48+
// If the resource itself newly created,
49+
// it could not have been subscribed for notifications already
50+
if (!((method === 'PUT' || method === 'PATCH') && statusCode === 201)) {
51+
trigger({
52+
generateNotification (
53+
negotiatedFields
54+
) {
55+
const mediaType = negotiatedFields['content-type']
56+
57+
if (ALLOWED_RDF_MIME_TYPES.includes(mediaType?.[0])) {
58+
return `${headerTemplate(negotiatedFields)}\r\n${solidRDFTemplate({
59+
activity: getActivity(method),
60+
eventID,
61+
object: String(fullUrl),
62+
date: eventDate,
63+
// We use eTag as a proxy for state for now
64+
state: res.getHeader('ETag'),
65+
mediaType
66+
})}`
67+
} else {
68+
return defaultNotification({
69+
...(res.method === 'POST') && { location: res.getHeader('Content-Location') }
70+
})
71+
}
72+
}
73+
})
74+
}
75+
76+
// Write a notification to parent container
77+
// POST in Solid creates a child resource
78+
if (method !== 'POST') {
79+
trigger({
80+
path: parent,
81+
generateNotification (
82+
negotiatedFields
83+
) {
84+
const mediaType = negotiatedFields['content-type']
85+
if (ALLOWED_RDF_MIME_TYPES.includes(mediaType?.[0])) {
86+
return `${headerTemplate(negotiatedFields)}\r\n${solidRDFTemplate({
87+
activity: getParentActivity(method, statusCode),
88+
eventID: parentID,
89+
date: eventDate,
90+
object: String(parentUrl),
91+
target: statusCode === 201 ? String(fullUrl) : undefined,
92+
eTag: undefined,
93+
mediaType
94+
})}`
95+
}
96+
}
97+
})
98+
}
99+
11100
next()
12101
}

lib/handlers/patch.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ async function patchHandler (req, res, next) {
9191
return writeGraph(graph, resource, ldp.resourceMapper.resolveFilePath(req.hostname), ldp.serverUri)
9292
})
9393

94+
// Add event-id for notifications
95+
res.setHeader('Event-ID', res.setEventID())
9496
// Send the status and result to the client
9597
res.status(resourceExists ? 200 : 201)
9698
res.send(result)

lib/handlers/post.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ async function handler (req, res, next) {
7272
// Handled by backpressure of streams!
7373
busboy.on('finish', function () {
7474
debug('Done storing files')
75+
// Add event-id for notifications
76+
res.setHeader('Event-ID', res.setEventID())
7577
res.sendStatus(200)
7678
next()
7779
})
@@ -91,6 +93,8 @@ async function handler (req, res, next) {
9193
debug('File stored in ' + resourcePath)
9294
header.addLinks(res, links)
9395
res.set('Location', resourcePath)
96+
// Add event-id for notifications
97+
res.setHeader('Event-ID', res.setEventID())
9498
res.sendStatus(201)
9599
next()
96100
},

lib/handlers/put.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ async function putStream (req, res, next, stream = req) {
7777
// Fails with Append on existing resource
7878
if (!req.originalUrl.endsWith('.acl')) await checkPermission(req, resourceExists)
7979
await ldp.put(req, stream, getContentType(req.headers))
80+
// Add event-id for notifications
81+
res.setHeader('Event-ID', res.setEventID())
8082
res.sendStatus(resourceExists ? 204 : 201)
8183
return next()
8284
} catch (err) {

lib/ldp-middleware.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ function LdpMiddleware (corsSettings) {
2424

2525
router.copy('/*', allow('Write'), copy)
2626
router.get('/*', index, allow('Read'), header.addPermissions, get)
27-
router.post('/*', allow('Append'), post)
27+
router.post('/*', allow('Append'), post, notify)
2828
router.patch('/*', allow('Append'), patch, notify)
29-
router.put('/*', allow('Append'), put)
29+
router.put('/*', allow('Append'), put, notify)
3030
router.delete('/*', allow('Write'), del, notify)
3131

3232
return router

lib/rdf-notification-template.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const CONTEXT_ACTIVITYSTREAMS = 'https://www.w3.org/ns/activitystreams'
2+
const CONTEXT_NOTIFICATION = 'https://www.w3.org/ns/solid/notification/v1'
3+
const CONTEXT_XML_SCHEMA = 'http://www.w3.org/2001/XMLSchema'
4+
5+
function generateJSONNotification ({
6+
activity: type,
7+
eventId: id,
8+
date: published,
9+
object,
10+
target,
11+
state = undefined
12+
}) {
13+
return {
14+
published,
15+
type,
16+
id,
17+
object,
18+
...(type === 'Add') && { target },
19+
...(type === 'Remove') && { origin: target },
20+
...(state) && { state }
21+
}
22+
}
23+
24+
function generateTurtleNotification ({
25+
activity,
26+
eventId,
27+
date,
28+
object,
29+
target,
30+
state = undefined
31+
}) {
32+
const stateLine = `\n notify:state "${state}" ;`
33+
34+
return `@prefix as: <${CONTEXT_ACTIVITYSTREAMS}#> .
35+
@prefix notify: <${CONTEXT_NOTIFICATION}#> .
36+
@prefix xsd: <${CONTEXT_XML_SCHEMA}#> .
37+
38+
<${eventId}> a as:${activity} ;${state && stateLine}
39+
as:object ${object} ;
40+
as:published "${date}"^^xsd:dateTime .`.replaceAll('\n', '\r\n')
41+
}
42+
43+
function serializeToJSONLD (notification, isActivityStreams = false) {
44+
notification['@context'] = [CONTEXT_NOTIFICATION]
45+
if (!isActivityStreams) {
46+
notification['@context'].unshift(CONTEXT_ACTIVITYSTREAMS)
47+
}
48+
return JSON.stringify(notification, null, 2)
49+
}
50+
51+
function rdfTemplate (props) {
52+
const { mediaType } = props
53+
if (mediaType[0] === 'application/activity+json' || (mediaType[0] === 'application/ld+json' && mediaType[1].get('profile')?.toLowerCase() === 'https://www.w3.org/ns/activitystreams')) {
54+
return serializeToJSONLD(generateJSONNotification(props), true)
55+
}
56+
57+
if (mediaType[0] === 'application/ld+json') {
58+
return serializeToJSONLD(generateJSONNotification(props))
59+
}
60+
61+
if (mediaType[0] === 'text/turtle') {
62+
return generateTurtleNotification(props)
63+
}
64+
}
65+
66+
module.exports = rdfTemplate

0 commit comments

Comments
 (0)