feat: Generalize and extend notification channel type behaviour

This commit is contained in:
Joachim Van Herwegen 2023-01-27 11:53:30 +01:00
parent 7d029a9465
commit c36f15e2da
32 changed files with 1291 additions and 624 deletions

View File

@ -3,6 +3,7 @@
"Adapter",
"AlgJwk",
"BaseActivityEmitter",
"BaseChannelType",
"BaseHttpError",
"BaseRouterHandler",
"BasicConditions",

View File

@ -5,7 +5,7 @@
"comment": "Handles the generation and serialization of notifications for WebHookSubscription2021.",
"@id": "urn:solid-server:default:WebHookNotificationHandler",
"@type": "TypedNotificationHandler",
"type": "WebHookSubscription2021",
"type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021",
"source": {
"@type": "ComposedNotificationHandler",
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },

View File

@ -11,16 +11,17 @@
"handler": {
"@type": "NotificationSubscriber",
"channelType": { "@id": "urn:solid-server:default:WebHookSubscription2021" },
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
"permissionReader": { "@id": "urn:solid-server:default:PermissionReader" },
"authorizer": { "@id": "urn:solid-server:default:Authorizer" }
"authorizer": { "@id": "urn:solid-server:default:Authorizer" },
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
}
},
{
"comment": "Contains all the metadata relevant for a WebHookSubscription2021.",
"@id": "urn:solid-server:default:WebHookSubscription2021",
"@type": "WebHookSubscription2021",
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" },
"unsubscribeRoute": { "@id": "urn:solid-server:default:WebHookUnsubscribeRoute" },
"stateHandler": {
"@type": "BaseStateHandler",

View File

@ -5,7 +5,7 @@
"comment": "Handles the generation and serialization of notifications for WebSocketSubscription2021.",
"@id": "urn:solid-server:default:WebSocket2021NotificationHandler",
"@type": "TypedNotificationHandler",
"type": "WebSocketSubscription2021",
"type": "http://www.w3.org/ns/solid/notifications#WebSocketSubscription2021",
"source": {
"@type": "ComposedNotificationHandler",
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },

View File

@ -11,9 +11,11 @@
"handler": {
"@type": "NotificationSubscriber",
"channelType": { "@id": "urn:solid-server:default:WebSocketSubscription2021" },
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
"permissionReader": { "@id": "urn:solid-server:default:PermissionReader" },
"authorizer": { "@id": "urn:solid-server:default:Authorizer" }
"authorizer": { "@id": "urn:solid-server:default:Authorizer" },
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
}
},
{
@ -26,7 +28,6 @@
"comment": "Contains all the metadata relevant for a WebSocketSubscription2021.",
"@id": "urn:solid-server:default:WebSocketSubscription2021",
"@type": "WebSocketSubscription2021",
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" },
"route": { "@id": "urn:solid-server:default:WebSocket2021Route" }
},

View File

@ -1,9 +1,9 @@
# Notifications
This section covers the architecture used to support Notifications protocol
as described in <https://solidproject.org/TR/notifications-protocol>.
This section covers the architecture used to support the Notifications protocol
as described in <https://solidproject.org/TR/2022/notifications-protocol-20221231>.
There are 3 core architectural components to this that each have separate entry points:
There are three core architectural components, that have distinct entry points:
* Exposing metadata to allow discovery of the subscription type.
* Handling subscriptions targeting a resource.
@ -19,12 +19,13 @@ as the notification subscription URL is always located in the root of the server
flowchart LR
StorageDescriptionHandler("<br>StorageDescriptionHandler")
StorageDescriptionHandler --> StorageDescriber("<strong>StorageDescriber</strong><br>ArrayUnionHandler")
StorageDescriber --> StorageDescriberArgs
StorageDescriber --> NotificationDescriber("NotificationDescriber<br>NotificationDescriber")
NotificationDescriber --> NotificationDescriberArgs
subgraph StorageDescriberArgs[" "]
subgraph NotificationDescriberArgs[" "]
direction LR
NotificationDescriber("<br>NotificationDescriber")
NotificationDescriber2("<br>NotificationDescriber")
NotificationChannelType("<br>NotificationChannelType")
NotificationChannelType2("<br>NotificationChannelType")
end
```
@ -33,14 +34,16 @@ and to handle content negotiation.
To generate the data we have multiple `StorageDescriber`s,
whose results get merged together in an `ArrayUnionHandler`.
A `NotificationChannelType` contains the specific details of a specification notification channel type,
including a JSON-LD representation of the corresponding subscription resource.
One specific instance of a `StorageDescriber` is a `NotificationSubcriber`,
that contains all the necessary presets to describe a notification subscription type.
which merges those JSON-LD descriptions into a single set of RDF quads.
When adding a new subscription type,
a new instance of such a class should be added to the `urn:solid-server:default:StorageDescriber`.
## NotificationChannel
To subscribe, a client has to send a specific JSON-LD request to the URl found during discovery.
To subscribe, a client has to send a specific JSON-LD request to the URL found during discovery.
```mermaid
flowchart LR
@ -50,9 +53,9 @@ flowchart LR
subgraph NotificationTypeHandlerArgs[" "]
direction LR
OperationRouterHandler("<br>OperationRouterHandler") --> NotificationSubscriber("<br>NotificationSubscriber")
NotificationSubscriber --> SubscriptionType("<br><i>SubscriptionType</i>")
NotificationChannelType --> NotificationChannelType("<br><i>NotificationChannelType</i>")
OperationRouterHandler2("<br>OperationRouterHandler") --> NotificationSubscriber2("<br>NotificationSubscriber")
NotificationSubscriber2 --> SubscriptionType2("<br><i>SubscriptionType</i>")
NotificationChannelType2 --> NotificationChannelType2("<br><i>NotificationChannelType</i>")
end
```
@ -60,7 +63,7 @@ Every subscription type should have a subscription URL relative to the root noti
which in our configs is set to `/.notifications/`.
For every type there is then a `OperationRouterHandler` that accepts requests to that specific URL,
after which a `NotificationSubscriber` handles all checks related to subscribing,
for which it uses a `SubscriptionType` that contains all the information necessary for a specific type.
for which it uses a `NotificationChannelType`.
If the subscription is valid and has authorization, the results will be saved in a `NotificationChannelStorage`.
## Activity
@ -99,13 +102,13 @@ To add support for [WebSocketSubscription2021](https://solidproject.org/TR/2022/
notifications,
components were added as described in the documentation above.
For discovery a `NotificationDescriber` was added with the corresponding settings.
For discovery, a `NotificationDescriber` was added with the corresponding settings.
As `SubscriptionType` there is a specific `WebSocketSubscription2021` that contains all the necessary information.
As `SubscriptionType`, there is a specific `WebSocketSubscription2021` that contains all the necessary information.
### Handling notifications
As `NotificationHandler` the following architecture is used:
As `NotificationHandler`, the following architecture is used:
```mermaid
flowchart TB
@ -137,8 +140,8 @@ and also caches the result so it can be reused by multiple subscriptions.
`urn:solid-server:default:BaseNotificationSerializer` converts the Notification to a JSON-LD representation
and handles any necessary content negotiation based on the `accept` notification feature.
A `WebSocket2021Emitter` is a specific emitter that checks the current open WebSockets
if they correspond to the subscription.
A `WebSocket2021Emitter` is a specific emitter that checks
whether the current open WebSockets correspond to the subscription.
### WebSockets
@ -163,12 +166,12 @@ flowchart TB
```
To detect and store WebSocket connections, the `WebSocket2021Listener` is added as a listener to the HTTP server.
For all WebSocket connections that get opened, it verifies if they correspond to an existing subscription.
If yes the information gets sent out to its stored `WebSocket2021Handler`.
For all WebSocket connections that get opened, it verifies whether they correspond to an existing subscription.
If yes, the information gets sent out to its stored `WebSocket2021Handler`.
In this case this is a `SequenceHandler` which contains a `WebSocket2021Storer` and a `BaseStateHandler`.
In this case, this is a `SequenceHandler`, which contains a `WebSocket2021Storer` and a `BaseStateHandler`.
The `WebSocket2021Storer` will store the WebSocket in the same map used by the `WebSocket2021Emitter`,
so that class can later on emit events as mentioned above.
so that class can emit events later on, as mentioned above.
The state handler will make sure that a notification gets sent out if the subscription has a `state` feature request,
as defined in the notification specification.

320
package-lock.json generated
View File

@ -30,6 +30,7 @@
"@types/proper-lockfile": "^4.1.2",
"@types/pump": "^1.1.1",
"@types/punycode": "^2.1.0",
"@types/rdf-validate-shacl": "^0.4.1",
"@types/sparqljs": "^3.1.3",
"@types/url-join": "^4.0.1",
"@types/uuid": "^9.0.0",
@ -65,6 +66,7 @@
"rdf-serialize": "^2.0.0",
"rdf-string": "^1.6.1",
"rdf-terms": "^1.9.0",
"rdf-validate-shacl": "^0.4.5",
"sparqlalgebrajs": "^4.0.3",
"sparqljs": "^3.5.2",
"url-join": "^4.0.1",
@ -72,8 +74,7 @@
"winston": "^3.8.1",
"winston-transport": "^4.5.0",
"ws": "^8.8.1",
"yargs": "^17.5.1",
"yup": "^0.32.11"
"yargs": "^17.5.1"
},
"bin": {
"community-solid-server": "bin/server.js"
@ -666,17 +667,6 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
"integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
"dependencies": {
"regenerator-runtime": "^0.13.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz",
@ -3804,6 +3794,52 @@
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"dev": true
},
"node_modules/@rdfjs/data-model": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.3.4.tgz",
"integrity": "sha512-iKzNcKvJotgbFDdti7GTQDCYmL7GsGldkYStiP0K8EYtN7deJu5t7U11rKTz+nR7RtesUggT+lriZ7BakFv8QQ==",
"dependencies": {
"@rdfjs/types": ">=1.0.1"
},
"bin": {
"rdfjs-data-model-test": "bin/test.js"
}
},
"node_modules/@rdfjs/dataset": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@rdfjs/dataset/-/dataset-1.1.1.tgz",
"integrity": "sha512-BNwCSvG0cz0srsG5esq6CQKJc1m8g/M0DZpLuiEp0MMpfwguXX7VeS8TCg4UUG3DV/DqEvhy83ZKSEjdsYseeA==",
"dependencies": {
"@rdfjs/data-model": "^1.2.0"
},
"bin": {
"rdfjs-dataset-test": "bin/test.js"
}
},
"node_modules/@rdfjs/namespace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/namespace/-/namespace-1.1.0.tgz",
"integrity": "sha512-utO5rtaOKxk8B90qzaQ0N+J5WrCI28DtfAY/zExCmXE7cOfC5uRI/oMKbLaVEPj2P7uArekt/T4IPATtj7Tjug==",
"dependencies": {
"@rdfjs/data-model": "^1.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@rdfjs/term-set": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/term-set/-/term-set-1.1.0.tgz",
"integrity": "sha512-QQ4yzVe1Rvae/GN9SnOhweHNpaxQtnAjeOVciP/yJ0Gfxtbphy2tM56ZsRLV04Qq5qMcSclZIe6irYyEzx/UwQ==",
"dependencies": {
"@rdfjs/to-ntriples": "^2.0.0"
}
},
"node_modules/@rdfjs/to-ntriples": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@rdfjs/to-ntriples/-/to-ntriples-2.0.0.tgz",
"integrity": "sha512-nDhpfhx6W6HKsy4HjyLp3H1nbrX1CiUCWhWQwKcYZX1s9GOjcoQTwY7GUUbVec0hzdJDQBR6gnjxtENBDt482Q=="
},
"node_modules/@rdfjs/types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.0.tgz",
@ -3990,6 +4026,14 @@
"@types/node": "*"
}
},
"node_modules/@types/clownface": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/clownface/-/clownface-1.5.1.tgz",
"integrity": "sha512-jYRGdXZu5BD6gp+Rfml9eAYovhj0Sf2ovufleMS9PEg8Un9Mc+ZbdbHt6nlutsuSk3QEqluTSzkYr1lno2FnHw==",
"dependencies": {
"rdf-js": "^4.0.2"
}
},
"node_modules/@types/connect": {
"version": "3.4.34",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
@ -4312,13 +4356,13 @@
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
"integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA=="
},
"node_modules/@types/rdf-js": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/rdf-js/-/rdf-js-4.0.2.tgz",
"integrity": "sha512-soR/+RMogGiDU1lrpuQl5ZL55/L1eq/JlR2dWx052Uh/RYs9okh3XZHFlIJXHZqjqyjEn4WdbOMfBj7vvc2WVQ==",
"deprecated": "This is a stub types definition. rdf-js provides its own type definitions, so you do not need this installed.",
"node_modules/@types/rdf-validate-shacl": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/rdf-validate-shacl/-/rdf-validate-shacl-0.4.1.tgz",
"integrity": "sha512-ol9l4scrPhYgOVNiylIGjdk9H5EzIOMV6ecue10T5IKGNlEE2ySFDEgxPPTVslmiyVO+3vV32GSQvsf+aQ0hKw==",
"dependencies": {
"rdf-js": "*"
"@types/clownface": "*",
"rdf-js": "^4.0.2"
}
},
"node_modules/@types/readable-stream": {
@ -5878,6 +5922,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clownface": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/clownface/-/clownface-1.5.1.tgz",
"integrity": "sha512-Ko8N/UFsnhEGmPlyE1bUFhbRhVgDbxqlIjcqxtLysc4dWaY0A7iCdg3savhAxs7Lheb7FCygIyRh7ADYZWVIng==",
"dependencies": {
"@rdfjs/data-model": "^1.1.0",
"@rdfjs/namespace": "^1.0.0"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
@ -11372,12 +11425,8 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
@ -11931,11 +11980,6 @@
"node": ">=8.0"
}
},
"node_modules/nanoclone": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz",
"integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA=="
},
"node_modules/nanoid": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz",
@ -12850,11 +12894,6 @@
"signal-exit": "^3.0.2"
}
},
"node_modules/property-expr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz",
"integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA=="
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
@ -13049,12 +13088,12 @@
}
},
"node_modules/rdf-literal": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.2.0.tgz",
"integrity": "sha512-N7nyfp/xzoiUuJt0xZ80BvBGkCPwWejgVDkCxWDSuooXKSows4ToW+KouYkMHLcoFzGg1Rlw2lk6btjMJg5aSA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.3.1.tgz",
"integrity": "sha512-+o/PGOfJchyay9Rjrvi/oveRJACnt2WFO3LhEvtPlsRD1tFmwVUCMU+s33FtQprMo+z1ohFrv/yfEQ6Eym4KgQ==",
"dependencies": {
"@types/rdf-js": "*",
"rdf-data-factory": "^1.0.1"
"@rdfjs/types": "*",
"rdf-data-factory": "^1.1.0"
}
},
"node_modules/rdf-object": {
@ -13163,6 +13202,32 @@
"rdf-string": "^1.6.0"
}
},
"node_modules/rdf-validate-datatype": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/rdf-validate-datatype/-/rdf-validate-datatype-0.1.5.tgz",
"integrity": "sha512-gU+cD+AT1LpFwbemuEmTDjwLyFwJDiw21XHyIofKhFnEpXODjShBuxhgDGnZqW3qIEwu/vECjOecuD60e5ngiQ==",
"dependencies": {
"@rdfjs/namespace": "^1.1.0",
"@rdfjs/to-ntriples": "^2.0.0"
},
"engines": {
"node": ">=10.4"
}
},
"node_modules/rdf-validate-shacl": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/rdf-validate-shacl/-/rdf-validate-shacl-0.4.5.tgz",
"integrity": "sha512-tGYnssuPzmsPua1dju4hEtGkT1zouvwzVTNrFhNiqj2aZFO5pQ7lvLd9Cv9H9vKAlpIdC/x0zL6btxG3PCss0w==",
"dependencies": {
"@rdfjs/dataset": "^1.1.1",
"@rdfjs/namespace": "^1.0.0",
"@rdfjs/term-set": "^1.1.0",
"clownface": "^1.4.0",
"debug": "^4.3.2",
"rdf-literal": "^1.3.0",
"rdf-validate-datatype": "^0.1.5"
}
},
"node_modules/rdfa-streaming-parser": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/rdfa-streaming-parser/-/rdfa-streaming-parser-1.5.0.tgz",
@ -13335,11 +13400,6 @@
"node": ">=4"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
},
"node_modules/regexp-tree": {
"version": "0.1.24",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz",
@ -14419,11 +14479,6 @@
"node": ">=0.6"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
},
"node_modules/touch": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz",
@ -15232,23 +15287,6 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "0.32.11",
"resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz",
"integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==",
"dependencies": {
"@babel/runtime": "^7.15.4",
"@types/lodash": "^4.14.175",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"nanoclone": "^0.2.1",
"property-expr": "^2.0.4",
"toposort": "^2.0.2"
},
"engines": {
"node": ">=10"
}
}
},
"dependencies": {
@ -15656,14 +15694,6 @@
"@babel/helper-plugin-utils": "^7.18.6"
}
},
"@babel/runtime": {
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz",
"integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"@babel/template": {
"version": "7.14.5",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz",
@ -18583,6 +18613,43 @@
}
}
},
"@rdfjs/data-model": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.3.4.tgz",
"integrity": "sha512-iKzNcKvJotgbFDdti7GTQDCYmL7GsGldkYStiP0K8EYtN7deJu5t7U11rKTz+nR7RtesUggT+lriZ7BakFv8QQ==",
"requires": {
"@rdfjs/types": ">=1.0.1"
}
},
"@rdfjs/dataset": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@rdfjs/dataset/-/dataset-1.1.1.tgz",
"integrity": "sha512-BNwCSvG0cz0srsG5esq6CQKJc1m8g/M0DZpLuiEp0MMpfwguXX7VeS8TCg4UUG3DV/DqEvhy83ZKSEjdsYseeA==",
"requires": {
"@rdfjs/data-model": "^1.2.0"
}
},
"@rdfjs/namespace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/namespace/-/namespace-1.1.0.tgz",
"integrity": "sha512-utO5rtaOKxk8B90qzaQ0N+J5WrCI28DtfAY/zExCmXE7cOfC5uRI/oMKbLaVEPj2P7uArekt/T4IPATtj7Tjug==",
"requires": {
"@rdfjs/data-model": "^1.1.0"
}
},
"@rdfjs/term-set": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/term-set/-/term-set-1.1.0.tgz",
"integrity": "sha512-QQ4yzVe1Rvae/GN9SnOhweHNpaxQtnAjeOVciP/yJ0Gfxtbphy2tM56ZsRLV04Qq5qMcSclZIe6irYyEzx/UwQ==",
"requires": {
"@rdfjs/to-ntriples": "^2.0.0"
}
},
"@rdfjs/to-ntriples": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@rdfjs/to-ntriples/-/to-ntriples-2.0.0.tgz",
"integrity": "sha512-nDhpfhx6W6HKsy4HjyLp3H1nbrX1CiUCWhWQwKcYZX1s9GOjcoQTwY7GUUbVec0hzdJDQBR6gnjxtENBDt482Q=="
},
"@rdfjs/types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.0.tgz",
@ -18757,6 +18824,14 @@
"@types/node": "*"
}
},
"@types/clownface": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/clownface/-/clownface-1.5.1.tgz",
"integrity": "sha512-jYRGdXZu5BD6gp+Rfml9eAYovhj0Sf2ovufleMS9PEg8Un9Mc+ZbdbHt6nlutsuSk3QEqluTSzkYr1lno2FnHw==",
"requires": {
"rdf-js": "^4.0.2"
}
},
"@types/connect": {
"version": "3.4.34",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
@ -19079,12 +19154,13 @@
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
"integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA=="
},
"@types/rdf-js": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/rdf-js/-/rdf-js-4.0.2.tgz",
"integrity": "sha512-soR/+RMogGiDU1lrpuQl5ZL55/L1eq/JlR2dWx052Uh/RYs9okh3XZHFlIJXHZqjqyjEn4WdbOMfBj7vvc2WVQ==",
"@types/rdf-validate-shacl": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@types/rdf-validate-shacl/-/rdf-validate-shacl-0.4.1.tgz",
"integrity": "sha512-ol9l4scrPhYgOVNiylIGjdk9H5EzIOMV6ecue10T5IKGNlEE2ySFDEgxPPTVslmiyVO+3vV32GSQvsf+aQ0hKw==",
"requires": {
"rdf-js": "*"
"@types/clownface": "*",
"rdf-js": "^4.0.2"
}
},
"@types/readable-stream": {
@ -20188,6 +20264,15 @@
"mimic-response": "^1.0.0"
}
},
"clownface": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/clownface/-/clownface-1.5.1.tgz",
"integrity": "sha512-Ko8N/UFsnhEGmPlyE1bUFhbRhVgDbxqlIjcqxtLysc4dWaY0A7iCdg3savhAxs7Lheb7FCygIyRh7ADYZWVIng==",
"requires": {
"@rdfjs/data-model": "^1.1.0",
"@rdfjs/namespace": "^1.0.0"
}
},
"cluster-key-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
@ -24367,12 +24452,8 @@
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
"lodash.clonedeep": {
"version": "4.5.0",
@ -24801,11 +24882,6 @@
"readable-stream": "^3.6.0"
}
},
"nanoclone": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz",
"integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA=="
},
"nanoid": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz",
@ -25476,11 +25552,6 @@
"signal-exit": "^3.0.2"
}
},
"property-expr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz",
"integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA=="
},
"psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
@ -25635,12 +25706,12 @@
}
},
"rdf-literal": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.2.0.tgz",
"integrity": "sha512-N7nyfp/xzoiUuJt0xZ80BvBGkCPwWejgVDkCxWDSuooXKSows4ToW+KouYkMHLcoFzGg1Rlw2lk6btjMJg5aSA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.3.1.tgz",
"integrity": "sha512-+o/PGOfJchyay9Rjrvi/oveRJACnt2WFO3LhEvtPlsRD1tFmwVUCMU+s33FtQprMo+z1ohFrv/yfEQ6Eym4KgQ==",
"requires": {
"@types/rdf-js": "*",
"rdf-data-factory": "^1.0.1"
"@rdfjs/types": "*",
"rdf-data-factory": "^1.1.0"
}
},
"rdf-object": {
@ -25749,6 +25820,29 @@
"rdf-string": "^1.6.0"
}
},
"rdf-validate-datatype": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/rdf-validate-datatype/-/rdf-validate-datatype-0.1.5.tgz",
"integrity": "sha512-gU+cD+AT1LpFwbemuEmTDjwLyFwJDiw21XHyIofKhFnEpXODjShBuxhgDGnZqW3qIEwu/vECjOecuD60e5ngiQ==",
"requires": {
"@rdfjs/namespace": "^1.1.0",
"@rdfjs/to-ntriples": "^2.0.0"
}
},
"rdf-validate-shacl": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/rdf-validate-shacl/-/rdf-validate-shacl-0.4.5.tgz",
"integrity": "sha512-tGYnssuPzmsPua1dju4hEtGkT1zouvwzVTNrFhNiqj2aZFO5pQ7lvLd9Cv9H9vKAlpIdC/x0zL6btxG3PCss0w==",
"requires": {
"@rdfjs/dataset": "^1.1.1",
"@rdfjs/namespace": "^1.0.0",
"@rdfjs/term-set": "^1.1.0",
"clownface": "^1.4.0",
"debug": "^4.3.2",
"rdf-literal": "^1.3.0",
"rdf-validate-datatype": "^0.1.5"
}
},
"rdfa-streaming-parser": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/rdfa-streaming-parser/-/rdfa-streaming-parser-1.5.0.tgz",
@ -25881,11 +25975,6 @@
"redis-errors": "^1.0.0"
}
},
"regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
},
"regexp-tree": {
"version": "0.1.24",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz",
@ -26733,11 +26822,6 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
},
"toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
},
"touch": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz",
@ -27342,20 +27426,6 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true
},
"yup": {
"version": "0.32.11",
"resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz",
"integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==",
"requires": {
"@babel/runtime": "^7.15.4",
"@types/lodash": "^4.14.175",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"nanoclone": "^0.2.1",
"property-expr": "^2.0.4",
"toposort": "^2.0.2"
}
}
}
}

View File

@ -120,6 +120,7 @@
"@types/proper-lockfile": "^4.1.2",
"@types/pump": "^1.1.1",
"@types/punycode": "^2.1.0",
"@types/rdf-validate-shacl": "^0.4.1",
"@types/sparqljs": "^3.1.3",
"@types/url-join": "^4.0.1",
"@types/uuid": "^9.0.0",
@ -155,6 +156,7 @@
"rdf-serialize": "^2.0.0",
"rdf-string": "^1.6.1",
"rdf-terms": "^1.9.0",
"rdf-validate-shacl": "^0.4.5",
"sparqlalgebrajs": "^4.0.3",
"sparqljs": "^3.5.2",
"url-join": "^4.0.1",
@ -162,8 +164,7 @@
"winston": "^3.8.1",
"winston-transport": "^4.5.0",
"ws": "^8.8.1",
"yargs": "^17.5.1",
"yup": "^0.32.11"
"yargs": "^17.5.1"
},
"devDependencies": {
"@commitlint/cli": "^17.0.3",

View File

@ -338,6 +338,7 @@ export * from './server/notifications/WebSocketSubscription2021/WebSocketSubscri
// Server/Notifications
export * from './server/notifications/ActivityEmitter';
export * from './server/notifications/BaseChannelType';
export * from './server/notifications/BaseStateHandler';
export * from './server/notifications/ComposedNotificationHandler';
export * from './server/notifications/KeyValueChannelStorage';

View File

@ -0,0 +1,241 @@
import { Readable } from 'stream';
import { KeysRdfParseJsonLd } from '@comunica/context-entries';
import { parse, toSeconds } from 'iso8601-duration';
import type { Store } from 'n3';
import type { NamedNode, Term } from 'rdf-js';
import rdfParser from 'rdf-parse';
import SHACLValidator from 'rdf-validate-shacl';
import { v4 } from 'uuid';
import type { Credentials } from '../../authentication/Credentials';
import type { AccessMap } from '../../authorization/permissions/Permissions';
import { AccessMode } from '../../authorization/permissions/Permissions';
import { ContextDocumentLoader } from '../../storage/conversion/ConversionUtil';
import { UnprocessableEntityHttpError } from '../../util/errors/UnprocessableEntityHttpError';
import { IdentifierSetMultiMap } from '../../util/map/IdentifierMap';
import { readableToQuads } from '../../util/StreamUtil';
import { msToDuration } from '../../util/StringUtil';
import { NOTIFY, RDF, XSD } from '../../util/Vocabularies';
import { CONTEXT_NOTIFICATION } from './Notification';
import type { NotificationChannel } from './NotificationChannel';
import type { NotificationChannelType } from './NotificationChannelType';
import { DEFAULT_NOTIFICATION_FEATURES } from './NotificationDescriber';
/**
* Helper type used to store information about the default features.
*/
type Feature = {
predicate: NamedNode;
key: keyof NotificationChannel;
dataType: string;
};
/**
* All the necessary fields of the default features that are possible for all Notification Channels.
*/
const featureDefinitions: Feature[] = [
{ predicate: NOTIFY.terms.accept, key: 'accept', dataType: XSD.string },
{ predicate: NOTIFY.terms.endAt, key: 'endAt', dataType: XSD.dateTime },
{ predicate: NOTIFY.terms.rate, key: 'rate', dataType: XSD.duration },
{ predicate: NOTIFY.terms.startAt, key: 'startAt', dataType: XSD.dateTime },
{ predicate: NOTIFY.terms.state, key: 'state', dataType: XSD.string },
];
// This context is slightly outdated but seems to be the only "official" source for a SHACL context.
const CONTEXT_SHACL = 'https://w3c.github.io/shacl/shacl-jsonld-context/shacl.context.ld.json';
/**
* The SHACL shape for the minimum requirements on a notification channel subscription request.
*/
export const DEFAULT_SUBSCRIPTION_SHACL = {
'@context': [ CONTEXT_SHACL ],
'@type': 'sh:NodeShape',
// Use the topic predicate to find the focus node
targetSubjectsOf: NOTIFY.topic,
closed: true,
property: [
{ path: RDF.type, minCount: 1, maxCount: 1, nodeKind: 'sh:IRI' },
{ path: NOTIFY.topic, minCount: 1, maxCount: 1, nodeKind: 'sh:IRI' },
...featureDefinitions.map((feat): unknown =>
({ path: feat.predicate.value, maxCount: 1, datatype: feat.dataType })),
],
} as const;
/**
* A {@link NotificationChannelType} that handles the base case of parsing and serializing a notification channel.
* Note that the `extractModes` call always requires Read permissions on the target resource.
*
* Uses SHACL to validate the incoming data in `initChannel`.
* Classes extending this can pass extra SHACL properties in the constructor to extend the validation check.
*
* The `completeChannel` implementation is an empty function.
*/
export abstract class BaseChannelType implements NotificationChannelType {
protected readonly type: NamedNode;
protected readonly shacl: unknown;
protected shaclQuads?: Store;
/**
* @param type - The URI of the notification channel type.
* This will be added to the SHACL shape to validate incoming subscription data.
* @param additionalShaclProperties - Any additional properties that need to be added to the default SHACL shape.
*/
protected constructor(type: NamedNode, additionalShaclProperties: unknown[] = []) {
this.type = type;
// Inject requested properties into default SHACL shape
this.shacl = {
...DEFAULT_SUBSCRIPTION_SHACL,
property: [
...DEFAULT_SUBSCRIPTION_SHACL.property,
// Add type check
{ path: RDF.type, hasValue: { '@id': type.value }},
...additionalShaclProperties,
],
};
}
/**
* Initiates the channel by first calling {@link validateSubscription} followed by {@link quadsToChannel}.
* Subclasses can override either function safely to impact the result of the function.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async initChannel(data: Store, credentials: Credentials): Promise<NotificationChannel> {
const subject = await this.validateSubscription(data);
return this.quadsToChannel(data, subject);
}
/**
* Returns an N3.js {@link Store} containing quads corresponding to the stored SHACL representation.
* Caches this result so the conversion from JSON-LD to quads only has to happen once.
*/
protected async getShaclQuads(): Promise<Store> {
if (!this.shaclQuads) {
const shaclStream = rdfParser.parse(
Readable.from(JSON.stringify(this.shacl)),
{
contentType: 'application/ld+json',
// Make sure our internal version of the context gets used
[KeysRdfParseJsonLd.documentLoader.name]: new ContextDocumentLoader({
[CONTEXT_SHACL]: '@css:templates/contexts/shacl.jsonld',
}),
},
);
this.shaclQuads = await readableToQuads(shaclStream);
}
return this.shaclQuads;
}
/**
* Validates whether the given data conforms to the stored SHACL shape.
* Will throw an {@link UnprocessableEntityHttpError} if validation fails.
* Along with the SHACL check, this also makes sure there is only one matching entry in the dataset.
*
* @param data - The data to validate.
*
* @returns The focus node that corresponds to the subject of the found notification channel description.
*/
protected async validateSubscription(data: Store): Promise<Term> {
// Need to make sure there is exactly one matching entry, which can't be done with SHACL.
// The predicate used here must be the same as is used for `targetSubjectsOf` in the SHACL shape.
const focusNodes = data.getSubjects(NOTIFY.terms.topic, null, null);
if (focusNodes.length === 0) {
throw new UnprocessableEntityHttpError('Missing topic value.');
}
if (focusNodes.length > 1) {
throw new UnprocessableEntityHttpError('Only one subscription can be done at the same time.');
}
const validator = new SHACLValidator(await this.getShaclQuads());
const report = validator.validate(data);
if (!report.conforms) {
// Use the first error to generate error message
const result = report.results[0];
const message = result.message[0];
throw new UnprocessableEntityHttpError(`${message.value} - ${result.path?.value}`);
}
// From this point on, we can assume the subject corresponds to a valid subscription request
return focusNodes[0] as NamedNode;
}
/**
* Converts a set of quads to a {@link NotificationChannel}.
* Assumes the data is valid, so this should be called after {@link validateSubscription}
*
* The values of the default features will be added to the resulting channel,
* subclasses with additional features that need to be added are responsible for parsing those quads.
*
* @param data - Data to convert.
* @param subject - The identifier of the notification channel description in the dataset.
*
* @returns The generated {@link NotificationChannel}.
*/
protected async quadsToChannel(data: Store, subject: Term): Promise<NotificationChannel> {
const topic = data.getObjects(subject, NOTIFY.terms.topic, null)[0] as NamedNode;
const type = data.getObjects(subject, RDF.terms.type, null)[0] as NamedNode;
const channel: NotificationChannel = {
id: `${v4()}:${topic.value}`,
type: type.value,
topic: topic.value,
};
// Apply the values for all present features that are enabled
for (const feature of DEFAULT_NOTIFICATION_FEATURES) {
const objects = data.getObjects(subject, feature, null);
if (objects.length === 1) {
// Will always succeed since we are iterating over a list which was built using `featureDefinitions`
const { dataType, key } = featureDefinitions.find((feat): boolean => feat.predicate.value === feature)!;
let val: string | number = objects[0].value;
if (dataType === XSD.dateTime) {
val = Date.parse(val);
} else if (dataType === XSD.duration) {
val = toSeconds(parse(val)) * 1000;
}
// Need to convince TS that we can assign `string | number` to this key
(channel as Record<typeof key, string | number>)[key] = val;
}
}
return channel;
}
/**
* Converts the given channel to a JSON-LD description.
* All fields found in the channel, except `lastEmit`, will be part of the result subject,
* so subclasses should remove any fields that should not be exposed.
*/
public async toJsonLd(channel: NotificationChannel): Promise<Record<string, unknown>> {
const result: Record<string, unknown> = {
'@context': [
CONTEXT_NOTIFICATION,
],
...channel,
};
// No need to expose this field
delete result.lastEmit;
// Convert all the epoch values back to the expected date/rate format
for (const { key, dataType } of featureDefinitions) {
const value = channel[key];
if (value) {
if (dataType === XSD.dateTime) {
result[key] = new Date(value).toISOString();
} else if (dataType === XSD.duration) {
result[key] = msToDuration(value as number);
}
}
}
return result;
}
public async extractModes(channel: NotificationChannel): Promise<AccessMap> {
return new IdentifierSetMultiMap<AccessMode>([[{ path: channel.topic }, AccessMode.read ]]);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async completeChannel(channel: NotificationChannel): Promise<void> {
// Do nothing
}
}

View File

@ -1,10 +1,9 @@
import { v4 } from 'uuid';
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { getLoggerFor } from '../../logging/LogUtil';
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
import { InternalServerError } from '../../util/errors/InternalServerError';
import type { ReadWriteLocker } from '../../util/locking/ReadWriteLocker';
import type { NotificationChannel, NotificationChannelJson } from './NotificationChannel';
import type { NotificationChannel } from './NotificationChannel';
import type { NotificationChannelStorage } from './NotificationChannelStorage';
type StorageValue = string | string[] | NotificationChannel;
@ -25,20 +24,6 @@ export class KeyValueChannelStorage implements NotificationChannelStorage {
this.locker = locker;
}
public create(channel: NotificationChannelJson, features: Record<string, unknown>): NotificationChannel {
return {
id: `${channel.type}:${v4()}:${channel.topic}`,
topic: channel.topic,
type: channel.type,
startAt: channel.startAt,
endAt: channel.endAt,
accept: channel.accept,
rate: channel.rate,
state: channel.state,
...features,
};
}
public async get(id: string): Promise<NotificationChannel | undefined> {
const channel = await this.storage.get(id);
if (channel && this.isChannel(channel)) {

View File

@ -1,46 +1,59 @@
import { parse, toSeconds } from 'iso8601-duration';
import type { InferType } from 'yup';
import { array, number, object, string } from 'yup';
import { CONTEXT_NOTIFICATION } from './Notification';
/**
* A JSON parsing schema that can be used to parse a notification channel sent during subscription.
* Specific notification channels can extend this schema with their own custom keys.
* Internal representation of a notification channel.
* Most of the fields are those defined in
* https://solid.github.io/notifications/protocol#notification-channel-data-model
*
* We only support notification channels with a single topic.
*/
export const NOTIFICATION_CHANNEL_SCHEMA = object({
'@context': array(string()).ensure().required().test({
name: 'RequireNotificationContext',
message: `The ${CONTEXT_NOTIFICATION} context is required in the notification channel JSON-LD body.`,
test: (context): boolean => Boolean(context?.includes(CONTEXT_NOTIFICATION)),
}),
type: string().required(),
topic: string().required(),
state: string().optional(),
startAt: number().transform((value, original): number | undefined =>
// Convert the date string to milliseconds
Date.parse(original)).optional(),
endAt: number().transform((value, original): number | undefined =>
// Convert the date string to milliseconds
Date.parse(original)).optional(),
rate: number().transform((value, original): number | undefined =>
// Convert the rate string to milliseconds
toSeconds(parse(original)) * 1000).optional(),
accept: string().optional(),
});
export type NotificationChannelJson = InferType<typeof NOTIFICATION_CHANNEL_SCHEMA>;
/**
* The info provided for a notification channel during a subscription.
* `features` can contain custom values relevant for a specific channel type.
*/
export type NotificationChannel = {
export interface NotificationChannel {
/**
* The unique identifier of the channel.
*/
id: string;
topic: string;
/**
* The channel type.
*/
type: string;
startAt?: number;
endAt?: number;
accept?: string;
rate?: number;
/**
* The resource this channel sends notifications about.
*/
topic: string;
/**
* The state parameter sent by the receiver.
* This is used to send a notification when the channel is established and the topic resource has a different state.
*/
state?: string;
/**
* When the channel should start sending notifications, in milliseconds since epoch.
*/
startAt?: number;
/**
* When the channel should stop existing, in milliseconds since epoch.
*/
endAt?: number;
/**
* The minimal time required between notifications, in milliseconds.
*/
rate?: number;
/**
* The media type in which the receiver expects the notifications.
*/
accept?: string;
/**
* The resource receivers can use to establish a connection and receive notifications.
*/
receiveFrom?: string;
/**
* The resource on the receiver where notifications can be sent.
*/
sendTo?: string;
/**
* Can be used to identify the sender.
*/
sender?: string;
/**
* Internal value that we use to track when this channel last sent a notification.
*/
lastEmit?: number;
};
}

View File

@ -1,5 +1,5 @@
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import type { NotificationChannel, NotificationChannelJson } from './NotificationChannel';
import type { NotificationChannel } from './NotificationChannel';
/**
* Stores all the information necessary to keep track of notification channels.
@ -9,15 +9,7 @@ import type { NotificationChannel, NotificationChannelJson } from './Notificatio
*/
export interface NotificationChannelStorage {
/**
* Creates channel corresponding to the given channel and features.
* This does not store the generated channel in the storage.
* @param channel - Notification channel to generate channel of.
* @param features - Features to add to the channel
*/
create: (channel: NotificationChannelJson, features: Record<string, unknown>) => NotificationChannel;
/**
* Returns the channel for the requested notification channel.
* Returns the requested channel.
* `undefined` if no match was found or if the notification channel expired.
* @param id - The identifier of the notification channel.
*/

View File

@ -1,42 +1,41 @@
import type { InferType } from 'yup';
import type { Store } from 'n3';
import type { Credentials } from '../../authentication/Credentials';
import type { AccessMap } from '../../authorization/permissions/Permissions';
import type { Representation } from '../../http/representation/Representation';
import type { NOTIFICATION_CHANNEL_SCHEMA, NotificationChannel } from './NotificationChannel';
export interface NotificationChannelResponse {
response: Representation;
channel: NotificationChannel;
}
import type { NotificationChannel } from './NotificationChannel';
/**
* A specific channel type as defined at
* https://solidproject.org/TR/2022/notifications-protocol-20221231#notification-channel-types.
*
* All functions that take a {@link NotificationChannel} as input
* only need to support channels generated by an `initChannel` on the same class.
*/
export interface NotificationChannelType<
TSub extends typeof NOTIFICATION_CHANNEL_SCHEMA = typeof NOTIFICATION_CHANNEL_SCHEMA> {
export interface NotificationChannelType {
/**
* The expected type value in the JSON-LD body of requests subscribing for this notification channel type.
* Validate and convert the input quads into a {@link NotificationChannel}.
* @param data - The input quads.
* @param credentials - The credentials of the agent doing the request.
*/
readonly type: string;
initChannel: (data: Store, credentials: Credentials) => Promise<NotificationChannel>;
/**
* An extension of {@link NOTIFICATION_CHANNEL_SCHEMA}
* that can be used to parse and validate an incoming subscription request with a notification channel body.
* Converts a {@link NotificationChannel} to a serialized JSON-LD representation.
* @param channel - The notification channel to serialize.
*/
readonly schema: TSub;
toJsonLd: (channel: NotificationChannel) => Promise<Record<string, unknown>>;
/**
* Determines which modes are required to allow the given notification channel.
* @param channel - The notification channel to verify.
*
* @returns The required modes.
*/
extractModes: (json: InferType<TSub>) => Promise<AccessMap>;
extractModes: (channel: NotificationChannel) => Promise<AccessMap>;
/**
* Registers the given notification channel.
* @param channel - The notification channel to register.
* @param credentials - The credentials of the client trying to subscribe.
*
* @returns A {@link Representation} to return as a response and the generated {@link NotificationChannel}.
* This function will be called after the serialized channel is sent back as a response,
* allowing for any final actions that need to happen.
* @param channel - The notification channel that is completed.
*/
subscribe: (json: InferType<TSub>, credentials: Credentials) => Promise<NotificationChannelResponse>;
completeChannel: (channel: NotificationChannel) => Promise<void>;
}

View File

@ -4,16 +4,17 @@ import type { Authorizer } from '../../authorization/Authorizer';
import type { PermissionReader } from '../../authorization/PermissionReader';
import { OkResponseDescription } from '../../http/output/response/OkResponseDescription';
import type { ResponseDescription } from '../../http/output/response/ResponseDescription';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import { getLoggerFor } from '../../logging/LogUtil';
import { APPLICATION_LD_JSON } from '../../util/ContentTypes';
import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter';
import { APPLICATION_LD_JSON, INTERNAL_QUADS } from '../../util/ContentTypes';
import { createErrorMessage } from '../../util/errors/ErrorUtil';
import { UnprocessableEntityHttpError } from '../../util/errors/UnprocessableEntityHttpError';
import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError';
import { readableToString } from '../../util/StreamUtil';
import type { HttpRequest } from '../HttpRequest';
import { endOfStream, readableToQuads } from '../../util/StreamUtil';
import type { OperationHttpHandlerInput } from '../OperationHttpHandler';
import { OperationHttpHandler } from '../OperationHttpHandler';
import type { NotificationChannelJson } from './NotificationChannel';
import type { NotificationChannel } from './NotificationChannel';
import type { NotificationChannelStorage } from './NotificationChannelStorage';
import type { NotificationChannelType } from './NotificationChannelType';
export interface NotificationSubscriberArgs {
@ -21,6 +22,10 @@ export interface NotificationSubscriberArgs {
* The {@link NotificationChannelType} with all the necessary information.
*/
channelType: NotificationChannelType;
/**
* {@link RepresentationConverter} used to convert input data into RDF.
*/
converter: RepresentationConverter;
/**
* Used to extract the credentials from the request.
*/
@ -33,6 +38,10 @@ export interface NotificationSubscriberArgs {
* Used to determine if the request has the necessary permissions.
*/
authorizer: Authorizer;
/**
* Storage used to store the channels.
*/
storage: NotificationChannelStorage;
/**
* Overrides the expiration feature of channels, by making sure they always expire after the `maxDuration` value.
* If the expiration of the channel is shorter than `maxDuration`, the original value will be kept.
@ -46,34 +55,42 @@ export interface NotificationSubscriberArgs {
*
* Uses the information from the provided {@link NotificationChannelType} to validate the input
* and verify the request has the required permissions available.
* If successful the generated channel will be stored in a {@link NotificationChannelStorage}.
*/
export class NotificationSubscriber extends OperationHttpHandler {
protected logger = getLoggerFor(this);
private readonly channelType: NotificationChannelType;
private readonly converter: RepresentationConverter;
private readonly credentialsExtractor: CredentialsExtractor;
private readonly permissionReader: PermissionReader;
private readonly authorizer: Authorizer;
private readonly storage: NotificationChannelStorage;
private readonly maxDuration: number;
public constructor(args: NotificationSubscriberArgs) {
super();
this.channelType = args.channelType;
this.converter = args.converter;
this.credentialsExtractor = args.credentialsExtractor;
this.permissionReader = args.permissionReader;
this.authorizer = args.authorizer;
this.storage = args.storage;
this.maxDuration = (args.maxDuration ?? 0) * 60 * 1000;
}
public async handle({ operation, request }: OperationHttpHandlerInput): Promise<ResponseDescription> {
if (operation.body.metadata.contentType !== APPLICATION_LD_JSON) {
throw new UnsupportedMediaTypeHttpError('Subscribe bodies need to be application/ld+json.');
}
const credentials = await this.credentialsExtractor.handleSafe(request);
this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);
let channel: NotificationChannelJson;
let channel: NotificationChannel;
try {
const json = JSON.parse(await readableToString(operation.body.data));
channel = await this.channelType.schema.validate(json);
const quadStream = await this.converter.handleSafe({
identifier: operation.target,
representation: operation.body,
preferences: { type: { [INTERNAL_QUADS]: 1 }},
});
channel = await this.channelType.initChannel(await readableToQuads(quadStream.data), credentials);
} catch (error: unknown) {
throw new UnprocessableEntityHttpError(`Unable to process notification channel: ${createErrorMessage(error)}`);
}
@ -86,17 +103,27 @@ export class NotificationSubscriber extends OperationHttpHandler {
}
// Verify if the client is allowed to subscribe
const credentials = await this.authorize(request, channel);
await this.authorize(credentials, channel);
const { response } = await this.channelType.subscribe(channel, credentials);
// Store the channel once it has been authorized
await this.storage.add(channel);
// Generate the response JSON-LD
const jsonld = await this.channelType.toJsonLd(channel);
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
// Complete the channel once the response has been sent out
endOfStream(response.data)
.then((): Promise<void> => this.channelType.completeChannel(channel))
.catch((error): void => {
this.logger.error(`There was an issue completing notification channel ${channel.id}: ${
createErrorMessage(error)}`);
});
return new OkResponseDescription(response.metadata, response.data);
}
private async authorize(request: HttpRequest, channel: NotificationChannelJson): Promise<Credentials> {
const credentials = await this.credentialsExtractor.handleSafe(request);
this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);
private async authorize(credentials: Credentials, channel: NotificationChannel): Promise<void> {
const requestedModes = await this.channelType.extractModes(channel);
this.logger.debug(`Retrieved required modes: ${[ ...requestedModes.entrySets() ]}`);
@ -104,8 +131,6 @@ export class NotificationSubscriber extends OperationHttpHandler {
this.logger.debug(`Available permissions are ${[ ...availablePermissions.entries() ]}`);
await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions });
this.logger.verbose(`Authorization succeeded, creating notification channel`);
return credentials;
this.logger.debug(`Authorization succeeded, creating notification channel`);
}
}

View File

@ -1,31 +1,15 @@
import type { InferType } from 'yup';
import { string } from 'yup';
import type { Store } from 'n3';
import type { Credentials } from '../../../authentication/Credentials';
import type { AccessMap } from '../../../authorization/permissions/Permissions';
import { AccessMode } from '../../../authorization/permissions/Permissions';
import { BasicRepresentation } from '../../../http/representation/BasicRepresentation';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { getLoggerFor } from '../../../logging/LogUtil';
import { APPLICATION_LD_JSON } from '../../../util/ContentTypes';
import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap';
import { endOfStream } from '../../../util/StreamUtil';
import { CONTEXT_NOTIFICATION } from '../Notification';
import { NOTIFY } from '../../../util/Vocabularies';
import { BaseChannelType } from '../BaseChannelType';
import type { NotificationChannel } from '../NotificationChannel';
import { NOTIFICATION_CHANNEL_SCHEMA } from '../NotificationChannel';
import type { NotificationChannelStorage } from '../NotificationChannelStorage';
import type { NotificationChannelResponse, NotificationChannelType } from '../NotificationChannelType';
import type { StateHandler } from '../StateHandler';
import { generateWebHookUnsubscribeUrl } from './WebHook2021Util';
const type = 'WebHookSubscription2021';
const schema = NOTIFICATION_CHANNEL_SCHEMA.shape({
type: string().required().oneOf([ type ]),
// Not using `.url()` validator since it does not support localhost URLs
target: string().required(),
});
/**
* A {@link NotificationChannel} containing the necessary fields for a WebHookSubscription2021 channel.
*/
@ -33,7 +17,7 @@ export interface WebHookSubscription2021Channel extends NotificationChannel {
/**
* The "WebHookSubscription2021" type.
*/
type: typeof type;
type: typeof NOTIFY.WebHookSubscription2021;
/**
* Where the notifications have to be sent.
*/
@ -50,7 +34,7 @@ export interface WebHookSubscription2021Channel extends NotificationChannel {
}
export function isWebHook2021Channel(channel: NotificationChannel): channel is WebHookSubscription2021Channel {
return channel.type === type;
return channel.type === NOTIFY.WebHookSubscription2021;
}
/**
@ -61,29 +45,27 @@ export function isWebHook2021Channel(channel: NotificationChannel): channel is W
*
* Also handles the `state` feature if present.
*/
export class WebHookSubscription2021 implements NotificationChannelType<typeof schema> {
export class WebHookSubscription2021 extends BaseChannelType {
protected readonly logger = getLoggerFor(this);
private readonly storage: NotificationChannelStorage;
private readonly unsubscribePath: string;
private readonly stateHandler: StateHandler;
public readonly type = type;
public readonly schema = schema;
public constructor(storage: NotificationChannelStorage, unsubscribeRoute: InteractionRoute,
stateHandler: StateHandler) {
this.storage = storage;
public constructor(unsubscribeRoute: InteractionRoute, stateHandler: StateHandler) {
super(NOTIFY.terms.WebHookSubscription2021,
// Need to remember to remove `target` from the vocabulary again once this is updated to webhooks 2023,
// as it is not actually part of the vocabulary.
// Technically we should also require that this node is a named node,
// but that would require clients to send `target: { '@id': 'http://example.com/target' }`,
// which would make this more annoying so we are lenient here.
// Could change in the future once this field is updated and part of the context.
[{ path: NOTIFY.target, minCount: 1, maxCount: 1 }]);
this.unsubscribePath = unsubscribeRoute.getPath();
this.stateHandler = stateHandler;
}
public async extractModes(json: InferType<typeof schema>): Promise<AccessMap> {
return new IdentifierSetMultiMap<AccessMode>([[{ path: json.topic }, AccessMode.read ]]);
}
public async subscribe(json: InferType<typeof schema>, credentials: Credentials):
Promise<NotificationChannelResponse> {
public async initChannel(data: Store, credentials: Credentials): Promise<WebHookSubscription2021Channel> {
// The WebID is used to verify who can unsubscribe
const webId = credentials.agent?.webId;
if (!webId) {
@ -92,27 +74,36 @@ export class WebHookSubscription2021 implements NotificationChannelType<typeof s
);
}
const channel = this.storage.create(json, { target: json.target, webId });
await this.storage.add(channel);
const subject = await this.validateSubscription(data);
const channel = await this.quadsToChannel(data, subject);
const target = data.getObjects(subject, NOTIFY.terms.target, null)[0];
const jsonld = {
'@context': [ CONTEXT_NOTIFICATION ],
type: this.type,
target: json.target,
return {
...channel,
type: NOTIFY.WebHookSubscription2021,
webId,
target: target.value,
// eslint-disable-next-line @typescript-eslint/naming-convention
unsubscribe_endpoint: generateWebHookUnsubscribeUrl(this.unsubscribePath, channel.id),
};
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
}
// We want to send the state notification, if there is one,
// right after we send the response for subscribing.
// We do this by waiting for the response to be closed.
endOfStream(response.data)
.then((): Promise<void> => this.stateHandler.handleSafe({ channel }))
.catch((error): void => {
this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`);
});
public async toJsonLd(channel: NotificationChannel): Promise<Record<string, unknown>> {
const json = await super.toJsonLd(channel);
return { response, channel };
// We don't want to expose the WebID that initialized the notification channel.
// This is not really specified either way in the spec so this might change in the future.
delete json.webId;
return json;
}
public async completeChannel(channel: NotificationChannel): Promise<void> {
try {
// Send the state notification, if there is one
await this.stateHandler.handleSafe({ channel });
} catch (error: unknown) {
this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`);
}
}
}

View File

@ -1,22 +1,29 @@
import { string } from 'yup';
import type { AccessMap } from '../../../authorization/permissions/Permissions';
import { AccessMode } from '../../../authorization/permissions/Permissions';
import { BasicRepresentation } from '../../../http/representation/BasicRepresentation';
import type { Store } from 'n3';
import type { Credentials } from '../../../authentication/Credentials';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { getLoggerFor } from '../../../logging/LogUtil';
import { APPLICATION_LD_JSON } from '../../../util/ContentTypes';
import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap';
import { CONTEXT_NOTIFICATION } from '../Notification';
import type { NotificationChannelJson } from '../NotificationChannel';
import { NOTIFICATION_CHANNEL_SCHEMA } from '../NotificationChannel';
import type { NotificationChannelStorage } from '../NotificationChannelStorage';
import type { NotificationChannelResponse, NotificationChannelType } from '../NotificationChannelType';
import { NOTIFY } from '../../../util/Vocabularies';
import { BaseChannelType } from '../BaseChannelType';
import type { NotificationChannel } from '../NotificationChannel';
import { generateWebSocketUrl } from './WebSocket2021Util';
const type = 'WebSocketSubscription2021';
const schema = NOTIFICATION_CHANNEL_SCHEMA.shape({
type: string().required().oneOf([ type ]),
});
/**
* A {@link NotificationChannel} containing the necessary fields for a WebSocketSubscription2021 channel.
*/
export interface WebSocketSubscription2021Channel extends NotificationChannel {
/**
* The "notify:WebSocketSubscription2021" type.
*/
type: typeof NOTIFY.WebSocketSubscription2021;
/**
* The WebSocket through which the channel will send notifications.
*/
source: string;
}
export function isWebSocket2021Channel(channel: NotificationChannel): channel is WebSocketSubscription2021Channel {
return channel.type === NOTIFY.WebSocketSubscription2021;
}
/**
* The notification channel type WebSocketSubscription2021 as described in
@ -24,35 +31,22 @@ const schema = NOTIFICATION_CHANNEL_SCHEMA.shape({
*
* Requires read permissions on a resource to be able to receive notifications.
*/
export class WebSocketSubscription2021 implements NotificationChannelType<typeof schema> {
export class WebSocketSubscription2021 extends BaseChannelType {
protected readonly logger = getLoggerFor(this);
private readonly storage: NotificationChannelStorage;
private readonly path: string;
public readonly type = type;
public readonly schema = schema;
public constructor(storage: NotificationChannelStorage, route: InteractionRoute) {
this.storage = storage;
public constructor(route: InteractionRoute) {
super(NOTIFY.terms.WebSocketSubscription2021);
this.path = route.getPath();
}
public async extractModes(json: NotificationChannelJson): Promise<AccessMap> {
return new IdentifierSetMultiMap<AccessMode>([[{ path: json.topic }, AccessMode.read ]]);
}
public async subscribe(json: NotificationChannelJson): Promise<NotificationChannelResponse> {
const channel = this.storage.create(json, {});
await this.storage.add(channel);
const jsonld = {
'@context': [ CONTEXT_NOTIFICATION ],
type: this.type,
public async initChannel(data: Store, credentials: Credentials): Promise<WebSocketSubscription2021Channel> {
const channel = await super.initChannel(data, credentials);
return {
...channel,
type: NOTIFY.WebSocketSubscription2021,
source: generateWebSocketUrl(this.path, channel.id),
};
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
return { response, channel };
}
}

View File

@ -28,3 +28,38 @@ export function sanitizeUrlPart(urlPart: string): string {
export function isValidFileName(name: string): boolean {
return /^[\w.-]+$/u.test(name);
}
/**
* Converts milliseconds to an ISO 8601 duration string.
* The only categories used are days, hours, minutes, and seconds,
* because months have no fixed size in milliseconds.
* @param ms - The duration in ms to convert.
*/
export function msToDuration(ms: number): string {
let totalSeconds = ms / 1000;
const days = Math.floor(totalSeconds / (60 * 60 * 24));
totalSeconds -= days * 60 * 60 * 24;
const hours = Math.floor(totalSeconds / (60 * 60));
totalSeconds -= hours * 60 * 60;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds - (minutes * 60);
const stringParts: string[] = [ 'P' ];
if (days > 0) {
stringParts.push(`${days}D`);
}
if (hours > 0 || minutes > 0 || seconds > 0) {
stringParts.push('T');
}
if (hours > 0) {
stringParts.push(`${hours}H`);
}
if (minutes > 0) {
stringParts.push(`${minutes}M`);
}
if (seconds > 0) {
stringParts.push(`${seconds}S`);
}
return stringParts.join('');
}

View File

@ -200,10 +200,13 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications
'startAt',
'state',
'subscription',
'target',
'topic',
'webhookAuth',
'webid',
'WebHookSubscription2021',
'WebSocketSubscription2021',
);
export const OIDC = createVocabulary('http://www.w3.org/ns/solid/oidc#',
@ -276,7 +279,9 @@ export const VCARD = createVocabulary('http://www.w3.org/2006/vcard/ns#',
export const XSD = createVocabulary('http://www.w3.org/2001/XMLSchema#',
'dateTime',
'duration',
'integer',
'string',
);
// Alias for commonly used types

View File

@ -0,0 +1,177 @@
{
"@context": {
"@vocab": "http://www.w3.org/ns/shacl#",
"sh": "http://www.w3.org/ns/shacl#",
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"rdfs": "http://www.w3.org/2000/01/rdf-schema#",
"xsd": "http://www.w3.org/2001/XMLSchema#",
"comment": "rdfs:comment",
"target": {
"@type": "@id"
},
"targetClass": {
"@type": "@id"
},
"targetObjectsOf": {
"@type": "@id"
},
"targetSubjectsOf": {
"@type": "@id"
},
"severity": {
"@type": "@id"
},
"result": {
"@type": "@id"
},
"detail": {
"@type": "@id"
},
"resultPath": {
"@type": "@id"
},
"resultSeverity": {
"@type": "@id"
},
"sourceShape": {
"@type": "@id"
},
"sourceConstraintComponent": {
"@type": "@id"
},
"prefixes": {
"@type": "@id"
},
"declare": {
"@type": "@id"
},
"namespace": {
"@type": "xsd:anyURI"
},
"shapesGraph": {
"@type": "@id"
},
"path": {
"@type": "@id"
},
"inversePath": {
"@type": "@id"
},
"alternativePath": {
"@type": "@id",
"@container": "@list"
},
"zeroOrMorePath": {
"@type": "@id"
},
"oneOrMorePath": {
"@type": "@id"
},
"zeroOrOnePath": {
"@type": "@id"
},
"parameter": {
"@type": "@id"
},
"validator": {
"@type": "@id"
},
"nodeValidator": {
"@type": "@id"
},
"propertyValidator": {
"@type": "@id"
},
"and": {
"@type": "@id",
"@container": "@list"
},
"class": {
"@type": "@id"
},
"ignoredProperties": {
"@type": "@id",
"@container": "@list"
},
"datatype": {
"@type": "@id"
},
"disjoint": {
"@type": "@id"
},
"equals": {
"@type": "@id"
},
"in": {
"@container": "@list"
},
"languageIn": {
"@container": "@list"
},
"lessThan": {
"@type": "@id"
},
"lessThanOrEquals": {
"@type": "@id"
},
"node": {
"@type": "@id"
},
"nodeKind": {
"@type": "@id"
},
"not": {
"@type": "@id"
},
"or": {
"@type": "@id",
"@container": "@list"
},
"property": {
"@type": "@id"
},
"qualifiedValueShape": {
"@type": "@id"
},
"xone": {
"@type": "@id",
"@container": "@list"
},
"sparql": {
"@type": "@id"
},
"derivedValues": {
"@type": "@id"
},
"group": {
"@type": "@id"
},
"returnType": {
"@type": "@id"
},
"resultAnnotation": {
"@type": "@id"
},
"annotationProperty": {
"@type": "@id"
},
"maxCount": {
"@type": "xsd:integer"
},
"maxLength": {
"@type": "xsd:integer"
},
"minCount": {
"@type": "xsd:integer"
},
"minLength": {
"@type": "xsd:integer"
},
"qualifiedMaxCount": {
"@type": "xsd:integer"
},
"qualifiedMinCount": {
"@type": "xsd:integer"
}
}
}

View File

@ -16,7 +16,8 @@ import {
getPresetConfigPath,
getTestConfigPath,
getTestFolder,
instantiateFromConfig, removeFolder,
instantiateFromConfig,
removeFolder,
} from './Config';
import quad = DataFactory.quad;
import namedNode = DataFactory.namedNode;
@ -26,7 +27,7 @@ const baseUrl = `http://localhost:${port}/`;
const clientPort = getPort('WebHookSubscription2021-client');
const target = `http://localhost:${clientPort}/`;
const webId = 'http://example.com/card/#me';
const notificationType = 'WebHookSubscription2021';
const notificationType = NOTIFY.WebHookSubscription2021;
const rootFilePath = getTestFolder('WebHookSubscription2021');
const stores: [string, any][] = [
@ -109,7 +110,7 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n
});
it('supports subscribing.', async(): Promise<void> => {
await subscribe(notificationType, webId, subscriptionUrl, topic, { target });
await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target });
});
it('emits Created events.', async(): Promise<void> => {
@ -168,7 +169,7 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n
});
});
await subscribe(notificationType, webId, subscriptionUrl, topic, { target, state: 'abc' });
await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target, state: 'abc' });
// Will resolve even though the resource did not change since subscribing
const { request, response } = await clientPromise;

View File

@ -14,14 +14,15 @@ import {
getPresetConfigPath,
getTestConfigPath,
getTestFolder,
instantiateFromConfig, removeFolder,
instantiateFromConfig,
removeFolder,
} from './Config';
import quad = DataFactory.quad;
import namedNode = DataFactory.namedNode;
const port = getPort('WebSocketSubscription2021');
const baseUrl = `http://localhost:${port}/`;
const notificationType = 'WebSocketSubscription2021';
const notificationType = NOTIFY.WebSocketSubscription2021;
const rootFilePath = getTestFolder('WebSocketSubscription2021');
const stores: [string, any][] = [
@ -166,7 +167,7 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
const channel = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
type: NOTIFY.WebSocketSubscription2021,
topic: restricted,
};
@ -212,7 +213,8 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
});
it('removes expired channels.', async(): Promise<void> => {
const { source } = await subscribe(notificationType, webId, subscriptionUrl, topic, { endAt: 1 }) as any;
const { source } =
await subscribe(notificationType, webId, subscriptionUrl, topic, { endAt: '1988-03-09T14:48:00.000Z' }) as any;
const socket = new WebSocket(source);
const messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));

View File

@ -0,0 +1,190 @@
import { DataFactory, Store } from 'n3';
import type { Credentials } from '../../../../src/authentication/Credentials';
import { AccessMode } from '../../../../src/authorization/permissions/Permissions';
import { BaseChannelType } from '../../../../src/server/notifications/BaseChannelType';
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
import { DEFAULT_NOTIFICATION_FEATURES } from '../../../../src/server/notifications/NotificationDescriber';
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap';
import { NOTIFY, RDF, XSD } from '../../../../src/util/Vocabularies';
import namedNode = DataFactory.namedNode;
import quad = DataFactory.quad;
import blankNode = DataFactory.blankNode;
import literal = DataFactory.literal;
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
const dummyType = namedNode('http://example.com/DummyType');
class DummyChannelType extends BaseChannelType {
public constructor(properties?: unknown[]) {
super(
dummyType,
properties,
);
}
}
describe('A BaseChannelType', (): void => {
const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource';
const credentials: Credentials = {};
const channelType = new DummyChannelType();
describe('#initChannel', (): void => {
let data: Store;
const subject = blankNode();
beforeEach(async(): Promise<void> => {
data = new Store();
data.addQuad(quad(subject, RDF.terms.type, dummyType));
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource')));
});
it('converts the quads to a channel with an identifier.', async(): Promise<void> => {
await expect(channelType.initChannel(data, credentials)).resolves.toEqual({
id,
type: dummyType.value,
topic: 'https://storage.example/resource',
});
});
it('requires exactly 1 topic.', async(): Promise<void> => {
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource2')));
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
data.removeQuads(data.getQuads(subject, NOTIFY.terms.topic, null, null));
expect(data.size).toBe(1);
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
// Data is correct again now
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource')));
await expect(channelType.initChannel(data, credentials)).resolves.toBeDefined();
// Also make sure we can't have 2 different subjects with 1 topic each
data.addQuad(quad(blankNode(), NOTIFY.terms.topic, namedNode('https://storage.example/resource2')));
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
});
it('requires the correct type.', async(): Promise<void> => {
data = new Store();
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource')));
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
data.addQuad(quad(subject, RDF.terms.type, namedNode('http://example.com/wrongType')));
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
data.addQuad(quad(subject, RDF.terms.type, dummyType));
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
data.removeQuads(data.getQuads(subject, RDF.terms.type, namedNode('http://example.com/wrongType'), null));
data.addQuad(quad(subject, RDF.terms.type, dummyType));
await expect(channelType.initChannel(data, credentials)).resolves.toBeDefined();
});
it('converts the start date to a number.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
data.addQuad(quad(subject, NOTIFY.terms.startAt, literal(date, XSD.terms.dateTime)));
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({
startAt: ms,
}));
});
it('converts the end date to a number.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
data.addQuad(quad(subject, NOTIFY.terms.endAt, literal(date, XSD.terms.dateTime)));
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({
endAt: ms,
}));
});
it('converts the rate to a number.', async(): Promise<void> => {
data.addQuad(quad(subject, NOTIFY.terms.rate, literal('PT10S', XSD.terms.duration)));
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({
rate: 10 * 1000,
}));
});
it('requires correct datatypes on the features.', async(): Promise<void> => {
for (const feature of DEFAULT_NOTIFICATION_FEATURES) {
const badData = new Store(data.getQuads(null, null, null, null));
// No feature accepts an integer
badData.addQuad(quad(subject, namedNode(feature), literal(123456, XSD.terms.integer)));
await expect(channelType.initChannel(badData, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
}
});
it('requires that features occur at most once.', async(): Promise<void> => {
const values = {
[NOTIFY.startAt]: [
literal('1988-03-09T14:48:00.000Z', XSD.terms.dateTime),
literal('2023-03-09T14:48:00.000Z', XSD.terms.dateTime),
],
[NOTIFY.endAt]: [
literal('1988-03-09T14:48:00.000Z', XSD.terms.dateTime),
literal('2023-03-09T14:48:00.000Z', XSD.terms.dateTime),
],
[NOTIFY.rate]: [ literal('PT10S', XSD.terms.duration), literal('PT11S', XSD.terms.duration) ],
[NOTIFY.accept]: [ literal('text/turtle'), literal('application/ld+json') ],
[NOTIFY.state]: [ literal('123456'), literal('654321') ],
};
for (const [ predicate, objects ] of Object.entries(values)) {
const badData = new Store(data.getQuads(null, null, null, null));
badData.addQuad(quad(subject, namedNode(predicate), objects[0]));
// One entry is fine
await expect(channelType.initChannel(badData, credentials)).resolves.toBeDefined();
badData.addQuad(quad(subject, namedNode(predicate), objects[1]));
await expect(channelType.initChannel(badData, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
}
});
});
it('can convert a notification channel to a JSON-LD representation.', async(): Promise<void> => {
const startDate = '1988-03-09T14:48:00.000Z';
const endDate = '2022-03-09T14:48:00.000Z';
const channel: NotificationChannel = {
id,
type: 'DummyType',
topic: 'https://storage.example/resource',
state: 'state',
startAt: Date.parse(startDate),
endAt: Date.parse(endDate),
rate: 10 * 1000,
accept: 'text/turtle',
lastEmit: 123456789,
};
await expect(channelType.toJsonLd(channel)).resolves.toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
id: channel.id,
type: channel.type,
topic: channel.topic,
state: channel.state,
startAt: startDate,
endAt: endDate,
rate: 'PT10S',
accept: channel.accept,
});
});
it('requires read permissions on the topic.', async(): Promise<void> => {
const channel: NotificationChannel = {
id,
type: 'DummyType',
topic: 'https://storage.example/resource',
};
await expect(channelType.extractModes(channel)).resolves
.toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]]));
});
it('does nothing when completing the channel.', async(): Promise<void> => {
const channel: NotificationChannel = {
id,
type: 'DummyType',
topic: 'https://storage.example/resource',
};
await expect(channelType.completeChannel(channel)).resolves.toBeUndefined();
});
});

View File

@ -3,10 +3,7 @@ import type { ResourceIdentifier } from '../../../../src/http/representation/Res
import type { Logger } from '../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../src/logging/LogUtil';
import { KeyValueChannelStorage } from '../../../../src/server/notifications/KeyValueChannelStorage';
import type {
NotificationChannel,
NotificationChannelJson,
} from '../../../../src/server/notifications/NotificationChannel';
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker';
import resetAllMocks = jest.resetAllMocks;
@ -21,12 +18,6 @@ describe('A KeyValueChannelStorage', (): void => {
const logger = getLoggerFor('mock');
const topic = 'http://example.com/foo';
const identifier = { path: topic };
const json = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
topic,
} as NotificationChannelJson;
const features = {};
let channel: NotificationChannel;
let internalMap: Map<string, any>;
let internalStorage: KeyValueStorage<string, any>;
@ -53,12 +44,6 @@ describe('A KeyValueChannelStorage', (): void => {
storage = new KeyValueChannelStorage(internalStorage, locker);
});
describe('#create', (): void => {
it('creates channel based on a notification channel.', async(): Promise<void> => {
expect(storage.create(json, features)).toEqual(channel);
});
});
describe('#get', (): void => {
it('returns undefined if there is no match.', async(): Promise<void> => {
await expect(storage.get('notexists')).resolves.toBeUndefined();

View File

@ -1,78 +0,0 @@
import { NOTIFICATION_CHANNEL_SCHEMA } from '../../../../src/server/notifications/NotificationChannel';
describe('A NotificationChannel', (): void => {
const validChannel = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'NotificationChannelType',
topic: 'http://example.com/foo',
};
it('requires a minimal set of values.', async(): Promise<void> => {
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(validChannel)).resolves.toBe(true);
});
it('requires the notification context header to be present.', async(): Promise<void> => {
let channel: unknown = {
type: 'NotificationChannelType',
topic: 'http://example.com/foo',
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(false);
channel = {
'@context': [ 'wrongContext' ],
type: 'NotificationChannelType',
topic: 'http://example.com/foo',
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(false);
channel = {
'@context': [ 'contextA', 'https://www.w3.org/ns/solid/notification/v1', 'contextB' ],
type: 'NotificationChannelType',
topic: 'http://example.com/foo',
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(true);
channel = {
'@context': 'https://www.w3.org/ns/solid/notification/v1',
type: 'NotificationChannelType',
topic: 'http://example.com/foo',
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(true);
});
it('converts the start date to a number.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
const channel: unknown = {
...validChannel,
startAt: date,
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({
startAt: ms,
}));
});
it('converts the end date to a number.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
const channel: unknown = {
...validChannel,
endAt: date,
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({
endAt: ms,
}));
});
it('converts the rate to a number.', async(): Promise<void> => {
const channel: unknown = {
...validChannel,
rate: 'PT10S',
};
await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({
rate: 10 * 1000,
}));
});
});

View File

@ -6,50 +6,67 @@ import { AccessMode } from '../../../../src/authorization/permissions/Permission
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { Logger } from '../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../src/logging/LogUtil';
import type { HttpRequest } from '../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../src/server/HttpResponse';
import { NOTIFICATION_CHANNEL_SCHEMA } from '../../../../src/server/notifications/NotificationChannel';
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
import type { NotificationChannelStorage } from '../../../../src/server/notifications/NotificationChannelStorage';
import type { NotificationChannelType } from '../../../../src/server/notifications/NotificationChannelType';
import { NotificationSubscriber } from '../../../../src/server/notifications/NotificationSubscriber';
import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter';
import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError';
import { IdentifierMap, IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap';
import { guardedStreamFrom } from '../../../../src/util/StreamUtil';
import { readableToString } from '../../../../src/util/StreamUtil';
import { flushPromises } from '../../../util/Util';
jest.mock('../../../../src/logging/LogUtil', (): any => {
const logger: Logger =
{ debug: jest.fn(), error: jest.fn() } as any;
return { getLoggerFor: (): Logger => logger };
});
describe('A NotificationSubscriber', (): void => {
let channel: any;
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
let operation: Operation;
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
let channel: NotificationChannel;
let channelType: jest.Mocked<NotificationChannelType>;
let converter: jest.Mocked<RepresentationConverter>;
let credentialsExtractor: jest.Mocked<CredentialsExtractor>;
let permissionReader: jest.Mocked<PermissionReader>;
let authorizer: jest.Mocked<Authorizer>;
let storage: jest.Mocked<NotificationChannelStorage>;
let subscriber: NotificationSubscriber;
beforeEach(async(): Promise<void> => {
channel = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'NotificationChannelType',
topic: topic.path,
};
operation = {
method: 'POST',
target: { path: 'http://example.com/.notifications/websockets/' },
body: new BasicRepresentation(JSON.stringify(channel), 'application/ld+json'),
body: new BasicRepresentation(),
preferences: {},
};
channelType = {
channel = {
type: 'NotificationChannelType',
schema: NOTIFICATION_CHANNEL_SCHEMA,
topic: topic.path,
id: '123456',
};
channelType = {
initChannel: jest.fn().mockResolvedValue(channel),
toJsonLd: jest.fn().mockResolvedValue({}),
extractModes: jest.fn(async(subscription): Promise<AccessMap> =>
new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]) as AccessMap),
subscribe: jest.fn().mockResolvedValue({ response: new BasicRepresentation(), channel: {}}),
completeChannel: jest.fn(),
};
converter = {
handleSafe: jest.fn().mockResolvedValue(new BasicRepresentation([], INTERNAL_QUADS)),
} as any;
credentialsExtractor = {
handleSafe: jest.fn().mockResolvedValue({ public: {}}),
} as any;
@ -62,38 +79,40 @@ describe('A NotificationSubscriber', (): void => {
handleSafe: jest.fn(),
} as any;
subscriber = new NotificationSubscriber({ channelType, credentialsExtractor, permissionReader, authorizer });
});
storage = {
add: jest.fn(),
} as any;
it('requires the request to be JSON-LD.', async(): Promise<void> => {
operation.body.metadata.contentType = 'text/turtle';
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnsupportedMediaTypeHttpError);
subscriber = new NotificationSubscriber(
{ channelType, converter, credentialsExtractor, permissionReader, authorizer, storage },
);
});
it('errors if the request can not be parsed correctly.', async(): Promise<void> => {
operation.body.data = guardedStreamFrom('not json');
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError);
// Type is missing
operation.body.data = guardedStreamFrom(JSON.stringify({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
topic,
}));
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError);
converter.handleSafe.mockRejectedValueOnce(new Error('bad data'));
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('bad data');
expect(storage.add).toHaveBeenCalledTimes(0);
});
it('returns the representation generated by the subscribe call.', async(): Promise<void> => {
it('errors if the channel type rejects the input.', async(): Promise<void> => {
channelType.initChannel.mockRejectedValueOnce(new Error('bad data'));
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError);
expect(storage.add).toHaveBeenCalledTimes(0);
});
it('returns the JSON generated by the channel type.', async(): Promise<void> => {
const description = await subscriber.handle({ operation, request, response });
expect(description.statusCode).toBe(200);
const subscribeResult = await channelType.subscribe.mock.results[0].value;
expect(description.data).toBe(subscribeResult.response.data);
expect(description.metadata).toBe(subscribeResult.response.metadata);
expect(JSON.parse(await readableToString(description.data!))).toEqual({});
expect(description.metadata?.contentType).toBe('application/ld+json');
expect(storage.add).toHaveBeenCalledTimes(1);
expect(storage.add).toHaveBeenLastCalledWith(channel);
});
it('errors on requests the Authorizer rejects.', async(): Promise<void> => {
authorizer.handleSafe.mockRejectedValue(new Error('not allowed'));
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('not allowed');
expect(storage.add).toHaveBeenCalledTimes(0);
});
it('updates the channel expiration if a max is defined.', async(): Promise<void> => {
@ -102,35 +121,65 @@ describe('A NotificationSubscriber', (): void => {
subscriber = new NotificationSubscriber({
channelType,
converter,
credentialsExtractor,
permissionReader,
authorizer,
storage,
maxDuration: 60,
});
await subscriber.handle({ operation, request, response });
expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
endAt: Date.now() + (60 * 60 * 1000),
}), { public: {}});
operation.body.data = guardedStreamFrom(JSON.stringify({
expect(storage.add).toHaveBeenCalledTimes(1);
expect(storage.add).toHaveBeenLastCalledWith({
...channel,
endAt: new Date(Date.now() + 99999999999999).toISOString(),
}));
await subscriber.handle({ operation, request, response });
expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
endAt: Date.now() + (60 * 60 * 1000),
}), { public: {}});
});
operation.body.data = guardedStreamFrom(JSON.stringify({
...channel,
endAt: new Date(Date.now() + 5).toISOString(),
}));
converter.handleSafe.mockResolvedValue(new BasicRepresentation());
channelType.initChannel.mockResolvedValueOnce({ ...channel, endAt: Date.now() + 99999999999999 });
await subscriber.handle({ operation, request, response });
expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
expect(storage.add).toHaveBeenCalledTimes(2);
expect(storage.add).toHaveBeenLastCalledWith({
...channel,
endAt: Date.now() + (60 * 60 * 1000),
});
converter.handleSafe.mockResolvedValue(new BasicRepresentation());
channelType.initChannel.mockResolvedValueOnce({ ...channel, endAt: Date.now() + 5 });
await subscriber.handle({ operation, request, response });
expect(storage.add).toHaveBeenCalledTimes(3);
expect(storage.add).toHaveBeenLastCalledWith({
...channel,
endAt: Date.now() + 5,
}), { public: {}});
});
jest.useRealTimers();
});
it('calls the completeChannel function after sending the response.', async(): Promise<void> => {
const description = await subscriber.handle({ operation, request, response });
// Read out data to end stream correctly
await readableToString(description.data!);
await flushPromises();
expect(channelType.completeChannel).toHaveBeenCalledTimes(1);
});
it('logs an error if the completeChannel functions throws.', async(): Promise<void> => {
const logger = getLoggerFor('mock');
channelType.completeChannel.mockRejectedValue(new Error('notification error'));
const description = await subscriber.handle({ operation, request, response });
// Read out data to end stream correctly
await readableToString(description.data!);
await flushPromises();
expect(channelType.completeChannel).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error)
.toHaveBeenLastCalledWith(`There was an issue completing notification channel ${channel.id}: notification error`);
});
});

View File

@ -16,6 +16,7 @@ import type {
import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError';
import { matchesAuthorizationScheme } from '../../../../../src/util/HeaderUtil';
import { trimTrailingSlashes } from '../../../../../src/util/PathUtil';
import { NOTIFY } from '../../../../../src/util/Vocabularies';
jest.mock('cross-fetch');
@ -43,7 +44,7 @@ describe('A WebHookEmitter', (): void => {
const channel: WebHookSubscription2021Channel = {
id: 'id',
topic: 'http://example.com/foo',
type: 'WebHookSubscription2021',
type: NOTIFY.WebHookSubscription2021,
target: 'http://example.org/somewhere-else',
webId: webIdRoute.getPath(),
// eslint-disable-next-line @typescript-eslint/naming-convention

View File

@ -1,24 +1,25 @@
import type { InferType } from 'yup';
import { DataFactory, Store } from 'n3';
import type { Credentials } from '../../../../../src/authentication/Credentials';
import { AccessMode } from '../../../../../src/authorization/permissions/Permissions';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import type { Logger } from '../../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
import { CONTEXT_NOTIFICATION } from '../../../../../src/server/notifications/Notification';
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
import type {
NotificationChannelStorage,
} from '../../../../../src/server/notifications/NotificationChannelStorage';
import type { StateHandler } from '../../../../../src/server/notifications/StateHandler';
import type {
WebHookSubscription2021Channel,
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
import {
isWebHook2021Channel,
WebHookSubscription2021,
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap';
import { joinUrl } from '../../../../../src/util/PathUtil';
import { readableToString, readJsonStream } from '../../../../../src/util/StreamUtil';
import { flushPromises } from '../../../../util/Util';
import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
import quad = DataFactory.quad;
import blankNode = DataFactory.blankNode;
import namedNode = DataFactory.namedNode;
jest.mock('../../../../../src/logging/LogUtil', (): any => {
const logger: Logger =
@ -26,93 +27,75 @@ jest.mock('../../../../../src/logging/LogUtil', (): any => {
return { getLoggerFor: (): Logger => logger };
});
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
describe('A WebHookSubscription2021', (): void => {
const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }};
const target = 'http://example.org/somewhere-else';
let json: InferType<WebHookSubscription2021['schema']>;
const topic = 'https://storage.example/resource';
const subject = blankNode();
let data: Store;
let channel: WebHookSubscription2021Channel;
const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe');
let storage: jest.Mocked<NotificationChannelStorage>;
let stateHandler: jest.Mocked<StateHandler>;
let channelType: WebHookSubscription2021;
beforeEach(async(): Promise<void> => {
json = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebHookSubscription2021',
data = new Store();
data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebHookSubscription2021));
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode(topic)));
data.addQuad(quad(subject, NOTIFY.terms.target, namedNode(target)));
const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource';
channel = {
id,
type: NOTIFY.WebHookSubscription2021,
topic: 'https://storage.example/resource',
target,
state: undefined,
startAt: undefined,
endAt: undefined,
accept: undefined,
rate: undefined,
webId: 'http://example.org/alice',
// eslint-disable-next-line @typescript-eslint/naming-convention
unsubscribe_endpoint: joinUrl(unsubscribeRoute.getPath(), encodeURIComponent(id)),
};
storage = {
create: jest.fn((features: Record<string, unknown>): NotificationChannel => ({
id: '123',
topic: 'http://example.com/foo',
type: 'WebHookSubscription2021',
...features,
})),
add: jest.fn(),
} as any;
stateHandler = {
handleSafe: jest.fn(),
} as any;
channelType = new WebHookSubscription2021(storage, unsubscribeRoute, stateHandler);
channelType = new WebHookSubscription2021(unsubscribeRoute, stateHandler);
});
it('exposes a utility function to verify if a channel is a webhook channel.', async(): Promise<void> => {
const channel = storage.create(json, {});
expect(isWebHook2021Channel(channel)).toBe(true);
channel.type = 'something else';
(channel as NotificationChannel).type = 'something else';
expect(isWebHook2021Channel(channel)).toBe(false);
});
it('has the correct type.', async(): Promise<void> => {
expect(channelType.type).toBe('WebHookSubscription2021');
});
it('correctly parses notification channel bodies.', async(): Promise<void> => {
await expect(channelType.schema.isValid(json)).resolves.toBe(true);
json.type = 'something else';
await expect(channelType.schema.isValid(json)).resolves.toBe(false);
});
it('requires Read permissions on the topic.', async(): Promise<void> => {
await expect(channelType.extractModes(json)).resolves
.toEqual(new IdentifierSetMultiMap([[{ path: json.topic }, AccessMode.read ]]));
});
it('stores the channel and returns a valid response when subscribing.', async(): Promise<void> => {
const { response } = await channelType.subscribe(json, credentials);
expect(response.metadata.contentType).toBe('application/ld+json');
await expect(readJsonStream(response.data)).resolves.toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebHookSubscription2021',
target,
// eslint-disable-next-line @typescript-eslint/naming-convention
unsubscribe_endpoint: joinUrl(unsubscribeRoute.getPath(), '123'),
});
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(channel);
});
it('errors if the credentials do not contain a WebID.', async(): Promise<void> => {
await expect(channelType.subscribe(json, {})).rejects
await expect(channelType.initChannel(data, {})).rejects
.toThrow('A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.');
});
it('calls the state handler once the response has been read.', async(): Promise<void> => {
const { response, channel } = await channelType.subscribe(json, credentials);
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(0);
// Read out data to end stream correctly
await readableToString(response.data);
it('removes the WebID when converting back to JSON-LD.', async(): Promise<void> => {
await expect(channelType.toJsonLd(channel)).resolves.toEqual({
'@context': [
CONTEXT_NOTIFICATION,
],
id: channel.id,
type: NOTIFY.WebHookSubscription2021,
target,
topic,
// eslint-disable-next-line @typescript-eslint/naming-convention
unsubscribe_endpoint: channel.unsubscribe_endpoint,
});
});
it('calls the state handler once the channel is completed.', async(): Promise<void> => {
await channelType.completeChannel(channel);
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(stateHandler.handleSafe).toHaveBeenLastCalledWith({ channel });
});
@ -121,14 +104,7 @@ describe('A WebHookSubscription2021', (): void => {
const logger = getLoggerFor('mock');
stateHandler.handleSafe.mockRejectedValue(new Error('notification error'));
const { response } = await channelType.subscribe(json, credentials);
expect(logger.error).toHaveBeenCalledTimes(0);
// Read out data to end stream correctly
await readableToString(response.data);
await flushPromises();
await channelType.completeChannel(channel);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith('Error emitting state notification: notification error');
});

View File

@ -11,6 +11,7 @@ import {
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookUnsubscriber';
import { ForbiddenHttpError } from '../../../../../src/util/errors/ForbiddenHttpError';
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
import { NOTIFY } from '../../../../../src/util/Vocabularies';
describe('A WebHookUnsubscriber', (): void => {
const request: HttpRequest = {} as any;
@ -34,7 +35,7 @@ describe('A WebHookUnsubscriber', (): void => {
} as any;
storage = {
get: jest.fn().mockResolvedValue({ type: 'WebHookSubscription2021', webId }),
get: jest.fn().mockResolvedValue({ type: NOTIFY.WebHookSubscription2021, webId }),
delete: jest.fn(),
} as any;

View File

@ -1,70 +1,58 @@
import { AccessMode } from '../../../../../src/authorization/permissions/Permissions';
import { DataFactory, Store } from 'n3';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import type { NotificationChannelJson } from '../../../../../src/server/notifications/NotificationChannel';
import type { NotificationChannelStorage } from '../../../../../src/server/notifications/NotificationChannelStorage';
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
import {
generateWebSocketUrl,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Util';
import type {
WebSocketSubscription2021Channel,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021';
import {
isWebSocket2021Channel,
WebSocketSubscription2021,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021';
import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap';
import { readJsonStream } from '../../../../../src/util/StreamUtil';
import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
import quad = DataFactory.quad;
import blankNode = DataFactory.blankNode;
import namedNode = DataFactory.namedNode;
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
describe('A WebSocketSubscription2021', (): void => {
let channel: NotificationChannelJson;
let storage: jest.Mocked<NotificationChannelStorage>;
let data: Store;
let channel: WebSocketSubscription2021Channel;
const subject = blankNode();
const topic = 'https://storage.example/resource';
const route = new AbsolutePathInteractionRoute('http://example.com/foo');
let channelType: WebSocketSubscription2021;
beforeEach(async(): Promise<void> => {
data = new Store();
data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebSocketSubscription2021));
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode(topic)));
const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource';
channel = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
topic: 'https://storage.example/resource',
state: undefined,
startAt: undefined,
endAt: undefined,
accept: undefined,
rate: undefined,
id,
type: NOTIFY.WebSocketSubscription2021,
topic,
source: generateWebSocketUrl(route.getPath(), id),
};
storage = {
create: jest.fn().mockReturnValue({
id: '123',
topic: 'http://example.com/foo',
type: 'WebSocketSubscription2021',
lastEmit: 0,
features: {},
}),
add: jest.fn(),
} as any;
channelType = new WebSocketSubscription2021(storage, route);
channelType = new WebSocketSubscription2021(route);
});
it('has the correct type.', async(): Promise<void> => {
expect(channelType.type).toBe('WebSocketSubscription2021');
it('exposes a utility function to verify if a channel is a websocket channel.', async(): Promise<void> => {
expect(isWebSocket2021Channel(channel)).toBe(true);
(channel as NotificationChannel).type = 'something else';
expect(isWebSocket2021Channel(channel)).toBe(false);
});
it('correctly parses notification channel bodies.', async(): Promise<void> => {
await expect(channelType.schema.isValid(channel)).resolves.toBe(true);
channel.type = 'something else';
await expect(channelType.schema.isValid(channel)).resolves.toBe(false);
});
it('requires Read permissions on the topic.', async(): Promise<void> => {
await expect(channelType.extractModes(channel)).resolves
.toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]]));
});
it('stores the channel and returns a valid response when subscribing.', async(): Promise<void> => {
const { response } = await channelType.subscribe(channel);
expect(response.metadata.contentType).toBe('application/ld+json');
await expect(readJsonStream(response.data)).resolves.toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
source: expect.stringMatching(/^ws:\/\/example.com\/foo\?auth=.+/u),
});
await expect(channelType.initChannel(data, {})).resolves.toEqual(channel);
});
});

View File

@ -1,7 +1,7 @@
import {
sanitizeUrlPart,
splitCommaSeparated,
isValidFileName,
isValidFileName, msToDuration,
} from '../../../src/util/StringUtil';
describe('HeaderUtil', (): void => {
@ -31,4 +31,21 @@ describe('HeaderUtil', (): void => {
expect(isValidFileName('$%^*')).toBeFalsy();
});
});
describe('#msToDuration', (): void => {
it('converts ms to a duration string.', async(): Promise<void> => {
const ms = ((2 * 24 * 60 * 60) + (10 * 60 * 60) + (5 * 60) + 50.25) * 1000;
expect(msToDuration(ms)).toBe('P2DT10H5M50.25S');
});
it('ignores 0 values.', async(): Promise<void> => {
const ms = ((2 * 24 * 60 * 60) + 50.25) * 1000;
expect(msToDuration(ms)).toBe('P2DT50.25S');
});
it('excludes the T if there is no time segment.', async(): Promise<void> => {
const ms = ((2 * 24 * 60 * 60)) * 1000;
expect(msToDuration(ms)).toBe('P2D');
});
});
});

View File

@ -2,9 +2,9 @@ import { fetch } from 'cross-fetch';
/**
* Subscribes to a notification channel.
* @param type - The type of the notification channel. E.g. "WebSocketSubscription2021".
* @param type - The type of the notification channel, e.g., "NOTIFY.WebHookSubscription2021".
* @param webId - The WebID to spoof in the authorization header. This assumes the config uses the debug auth import.
* @param subscriptionUrl - The subscription URL where the request needs to be sent to.
* @param subscriptionUrl - The subscription URL to which the request needs to be sent.
* @param topic - The topic to subscribe to.
* @param features - Any extra fields that need to be added to the subscription body.
*/