mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Generalize and extend notification channel type behaviour
This commit is contained in:
parent
7d029a9465
commit
c36f15e2da
@ -3,6 +3,7 @@
|
||||
"Adapter",
|
||||
"AlgJwk",
|
||||
"BaseActivityEmitter",
|
||||
"BaseChannelType",
|
||||
"BaseHttpError",
|
||||
"BaseRouterHandler",
|
||||
"BasicConditions",
|
||||
|
@ -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" },
|
||||
|
@ -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",
|
||||
|
@ -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" },
|
||||
|
@ -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" }
|
||||
},
|
||||
|
||||
|
@ -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
320
package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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';
|
||||
|
241
src/server/notifications/BaseChannelType.ts
Normal file
241
src/server/notifications/BaseChannelType.ts
Normal 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
|
||||
}
|
||||
}
|
@ -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)) {
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
@ -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('');
|
||||
}
|
||||
|
@ -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
|
||||
|
177
templates/contexts/shacl.jsonld
Normal file
177
templates/contexts/shacl.jsonld
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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));
|
||||
|
190
test/unit/server/notifications/BaseChannelType.test.ts
Normal file
190
test/unit/server/notifications/BaseChannelType.test.ts
Normal 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();
|
||||
});
|
||||
});
|
@ -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();
|
||||
|
@ -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,
|
||||
}));
|
||||
});
|
||||
});
|
@ -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`);
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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.
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user