Skip to content

Commit

Permalink
feat: Add Solid-PREP Notifications
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
CxRes committed Aug 28, 2024
1 parent 91a074d commit 86f74c5
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 12 deletions.
2 changes: 2 additions & 0 deletions lib/handlers/delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ async function handler (req, res, next) {
try {
await ldp.delete(req)
debug('DELETE -- Ok.')
// Add event-id for notifications
res.setHeader('Event-ID', res.setEventID())
res.sendStatus(200)
next()
} catch (err) {
Expand Down
8 changes: 5 additions & 3 deletions lib/handlers/get.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const error = require('../http-error')
const RDFs = require('../ldp').mimeTypesAsArray()
const isRdf = require('../ldp').mimeTypeIsRdf

const prepConfig = 'accept=("message/rfc822" "application/ld+json" "text/turtle")'

async function handler (req, res, next) {
const ldp = req.app.locals.ldp
const includeBody = req.method === 'GET'
Expand Down Expand Up @@ -104,7 +106,7 @@ async function handler (req, res, next) {
debug(' sending data browser file: ' + dataBrowserPath)
res.sendFile(dataBrowserPath)
return
} else if (stream) {
} else if (stream) { // EXIT text/html
res.setHeader('Content-Type', contentType)
return stream.pipe(res)
}
Expand Down Expand Up @@ -137,7 +139,7 @@ async function handler (req, res, next) {
}

if (isRdf(contentType) && !res.sendEvents({
config: { prep: '' },
config: { prep: prepConfig },
body: stream,
isBodyStream: true,
headers
Expand All @@ -159,7 +161,7 @@ async function handler (req, res, next) {
'Content-Type': possibleRDFType
}
if (isRdf(contentType) && !res.sendEvents({
config: { prep: '' },
config: { prep: prepConfig },
body: data,
headers
})) return
Expand Down
103 changes: 96 additions & 7 deletions lib/handlers/notify.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,101 @@
module.exports = handler

const libPath = require('path/posix')

const headerTemplate = require('express-prep/templates').header
const solidRDFTemplate = require('../rdf-notification-template')

const ALLOWED_RDF_MIME_TYPES = [
'application/ld+json',
'application/activity+json',
'text/turtle'
]

function getActivity (method) {
if (method === 'DELETE') {
return 'Delete'
}
return 'Update'
}

function getParentActivity (method, status) {
if (method === 'DELETE') {
return 'Remove'
}
if (status === 201) {
return 'Add'
}
return 'Update'
}

function handler (req, res, next) {
res.events.prep.trigger({
generateNotifications () {
return res.events.prep.defaultNotification({
...(res.method === 'POST') && { location: res.getHeader('Content-Location') }
})
}
})
const { trigger, defaultNotification } = res.events.prep

const { method } = req
const { statusCode } = res
const eventID = res.getHeader('event-id')

const parent = `${libPath.dirname(req.path)}/`
const parentID = res.setEventID(parent)
const fullUrl = new URL(req.path, `${req.protocol}://${req.hostname}/`)
const parentUrl = new URL(parent, fullUrl)

// Date is a hack since node does not seem to provide access to send date.
// Date needs to be shared with parent notification
const eventDate = res._header.match(/^Date: (.*?)$/m)?.[1] ||
new Date().toUTCString()

// If the resource itself newly created,
// it could not have been subscribed for notifications already
if (!((method === 'PUT' || method === 'PATCH') && statusCode === 201)) {
trigger({
generateNotification (
negotiatedFields
) {
const mediaType = negotiatedFields['content-type']

if (ALLOWED_RDF_MIME_TYPES.includes(mediaType?.[0])) {
return `${headerTemplate(negotiatedFields)}\r\n${solidRDFTemplate({
activity: getActivity(method),
eventID,
object: String(fullUrl),
date: eventDate,
// We use eTag as a proxy for state for now
state: res.getHeader('ETag'),
mediaType
})}`
} else {
return defaultNotification({
...(res.method === 'POST') && { location: res.getHeader('Content-Location') }
})
}
}
})
}

// Write a notification to parent container
// POST in Solid creates a child resource
if (method !== 'POST') {
trigger({
path: parent,
generateNotification (
negotiatedFields
) {
const mediaType = negotiatedFields['content-type']
if (ALLOWED_RDF_MIME_TYPES.includes(mediaType?.[0])) {
return `${headerTemplate(negotiatedFields)}\r\n${solidRDFTemplate({
activity: getParentActivity(method, statusCode),
eventID: parentID,
date: eventDate,
object: String(parentUrl),
target: statusCode === 201 ? String(fullUrl) : undefined,
eTag: undefined,
mediaType
})}`
}
}
})
}

next()
}
2 changes: 2 additions & 0 deletions lib/handlers/patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ async function patchHandler (req, res, next) {
return writeGraph(graph, resource, ldp.resourceMapper.resolveFilePath(req.hostname), ldp.serverUri)
})

// Add event-id for notifications
res.setHeader('Event-ID', res.setEventID())
// Send the status and result to the client
res.status(resourceExists ? 200 : 201)
res.send(result)
Expand Down
4 changes: 4 additions & 0 deletions lib/handlers/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ async function handler (req, res, next) {
// Handled by backpressure of streams!
busboy.on('finish', function () {
debug('Done storing files')
// Add event-id for notifications
res.setHeader('Event-ID', res.setEventID())
res.sendStatus(200)
next()
})
Expand All @@ -91,6 +93,8 @@ async function handler (req, res, next) {
debug('File stored in ' + resourcePath)
header.addLinks(res, links)
res.set('Location', resourcePath)
// Add event-id for notifications
res.setHeader('Event-ID', res.setEventID())
res.sendStatus(201)
next()
},
Expand Down
2 changes: 2 additions & 0 deletions lib/handlers/put.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ async function putStream (req, res, next, stream = req) {
// Fails with Append on existing resource
if (!req.originalUrl.endsWith('.acl')) await checkPermission(req, resourceExists)
await ldp.put(req, stream, getContentType(req.headers))
// Add event-id for notifications
res.setHeader('Event-ID', res.setEventID())
res.sendStatus(resourceExists ? 204 : 201)
return next()
} catch (err) {
Expand Down
4 changes: 2 additions & 2 deletions lib/ldp-middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ function LdpMiddleware (corsSettings) {

router.copy('/*', allow('Write'), copy)
router.get('/*', index, allow('Read'), header.addPermissions, get)
router.post('/*', allow('Append'), post)
router.post('/*', allow('Append'), post, notify)
router.patch('/*', allow('Append'), patch, notify)
router.put('/*', allow('Append'), put)
router.put('/*', allow('Append'), put, notify)
router.delete('/*', allow('Write'), del, notify)

return router
Expand Down
66 changes: 66 additions & 0 deletions lib/rdf-notification-template.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const CONTEXT_ACTIVITYSTREAMS = 'https://www.w3.org/ns/activitystreams'
const CONTEXT_NOTIFICATION = 'https://www.w3.org/ns/solid/notification/v1'
const CONTEXT_XML_SCHEMA = 'http://www.w3.org/2001/XMLSchema'

function generateJSONNotification ({
activity: type,
eventId: id,
date: published,
object,
target,
state = undefined
}) {
return {
published,
type,
id,
object,
...(type === 'Add') && { target },
...(type === 'Remove') && { origin: target },
...(state) && { state }
}
}

function generateTurtleNotification ({
activity,
eventId,
date,
object,
target,
state = undefined
}) {
const stateLine = `\n notify:state "${state}" ;`

return `@prefix as: <${CONTEXT_ACTIVITYSTREAMS}#> .
@prefix notify: <${CONTEXT_NOTIFICATION}#> .
@prefix xsd: <${CONTEXT_XML_SCHEMA}#> .
<${eventId}> a as:${activity} ;${state && stateLine}
as:object ${object} ;
as:published "${date}"^^xsd:dateTime .`.replaceAll('\n', '\r\n')
}

function serializeToJSONLD (notification, isActivityStreams = false) {
notification['@context'] = [CONTEXT_NOTIFICATION]
if (!isActivityStreams) {
notification['@context'].unshift(CONTEXT_ACTIVITYSTREAMS)
}
return JSON.stringify(notification, null, 2)
}

function rdfTemplate (props) {
const { mediaType } = props
if (mediaType[0] === 'application/activity+json' || (mediaType[0] === 'application/ld+json' && mediaType[1].get('profile')?.toLowerCase() === 'https://www.w3.org/ns/activitystreams')) {
return serializeToJSONLD(generateJSONNotification(props), true)
}

if (mediaType[0] === 'application/ld+json') {
return serializeToJSONLD(generateJSONNotification(props))
}

if (mediaType[0] === 'text/turtle') {
return generateTurtleNotification(props)
}
}

module.exports = rdfTemplate

0 comments on commit 86f74c5

Please sign in to comment.