mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for the Notification specification
This commit is contained in:
parent
be7af277bb
commit
cbc07c6ef3
@ -24,6 +24,7 @@
|
||||
"RegExp",
|
||||
"Server",
|
||||
"Shorthand",
|
||||
"SubscriptionType",
|
||||
"Template",
|
||||
"TemplateEngine",
|
||||
"ValuePreferencesArg",
|
||||
|
@ -1,6 +1,7 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||
"import": [
|
||||
"css:config/http/handler/handlers/notifications.json",
|
||||
"css:config/http/handler/handlers/oidc.json",
|
||||
"css:config/http/handler/handlers/storage-description.json"
|
||||
],
|
||||
@ -17,6 +18,7 @@
|
||||
{ "@id": "urn:solid-server:default:StaticAssetHandler" },
|
||||
{ "@id": "urn:solid-server:default:SetupHandler" },
|
||||
{ "@id": "urn:solid-server:default:OidcHandler" },
|
||||
{ "@id": "urn:solid-server:default:NotificationHttpHandler" },
|
||||
{ "@id": "urn:solid-server:default:StorageDescriptionHandler" },
|
||||
{ "@id": "urn:solid-server:default:AuthResourceHttpHandler" },
|
||||
{ "@id": "urn:solid-server:default:IdentityProviderHandler" },
|
||||
|
27
config/http/handler/handlers/notifications.json
Normal file
27
config/http/handler/handlers/notifications.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"@id": "urn:solid-server:default:NotificationHttpHandler",
|
||||
"@type": "RouterHandler",
|
||||
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" },
|
||||
"allowedPathNames": [ "^/.notifications/" ],
|
||||
"handler": { "@id": "urn:solid-server:default:NotificationParsingHandler" }
|
||||
},
|
||||
{
|
||||
"@id": "urn:solid-server:default:NotificationParsingHandler",
|
||||
"@type": "ParsingHttpHandler",
|
||||
"requestParser": { "@id": "urn:solid-server:default:RequestParser" },
|
||||
"metadataCollector": { "@id": "urn:solid-server:default:OperationMetadataCollector" },
|
||||
"errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
|
||||
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
|
||||
"operationHandler": {
|
||||
"comment": "New notification subscription types should be added here to allow subscriptions.",
|
||||
"@id": "urn:solid-server:default:NotificationTypeHandler",
|
||||
"@type": "WaterfallHandler",
|
||||
"handlers": [ ]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
19
config/http/notifications/base/description.json
Normal file
19
config/http/notifications/base/description.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "New notification subscription types should add a handler containing their descriptions so they can be discovered.",
|
||||
"@id": "urn:solid-server:default:StorageDescriber",
|
||||
"@type": "ArrayUnionHandler",
|
||||
"handlers": [ ]
|
||||
},
|
||||
|
||||
{
|
||||
"comment": "The root URL of all Notification subscription routes.",
|
||||
"@id": "urn:solid-server:default:NotificationRoute",
|
||||
"@type": "RelativePathInteractionRoute",
|
||||
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||
"relativePath": "/.notifications/"
|
||||
},
|
||||
]
|
||||
}
|
35
config/http/notifications/base/handler.json
Normal file
35
config/http/notifications/base/handler.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "Generates the Notification objects and caches them based on the topic.",
|
||||
"@id": "urn:solid-server:default:BaseNotificationGenerator",
|
||||
"@type": "CachedHandler",
|
||||
"field": "topic",
|
||||
"source": {
|
||||
"@type": "StateNotificationGenerator",
|
||||
"resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" },
|
||||
"source": {
|
||||
"@type": "WaterfallHandler",
|
||||
"handlers": [
|
||||
{ "@type": "DeleteNotificationGenerator" },
|
||||
{
|
||||
"@type": "ActivityNotificationGenerator",
|
||||
"store": {
|
||||
"@id": "urn:solid-server:default:ResourceStore"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"comment": "Serializes the notification objects.",
|
||||
"@id": "urn:solid-server:default:BaseNotificationSerializer",
|
||||
"@type": "ConvertingNotificationSerializer",
|
||||
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
|
||||
"source": { "@type": "JsonLdNotificationSerializer" }
|
||||
}
|
||||
]
|
||||
}
|
26
config/http/notifications/base/listener.json
Normal file
26
config/http/notifications/base/listener.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "Listens to the activities emitted by the MonitoringStore.",
|
||||
"@id": "urn:solid-server:default:ListeningActivityHandler",
|
||||
"@type": "ListeningActivityHandler",
|
||||
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" },
|
||||
"emitter": { "@id": "urn:solid-server:default:ResourceStore" },
|
||||
"handler": {
|
||||
"comment": "New notification types should add a handler here to emit events.",
|
||||
"@id": "urn:solid-server:default:NotificationHandler",
|
||||
"@type": "WaterfallHandler",
|
||||
"handlers": [
|
||||
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "The ListeningActivityHandler is added to the list of Initializers so Components.js finds and instantiates it.",
|
||||
"@id": "urn:solid-server:default:PrimaryParallelInitializer",
|
||||
"@type": "ParallelHandler",
|
||||
"handlers": [ { "@id": "urn:solid-server:default:ListeningActivityHandler" } ]
|
||||
}
|
||||
]
|
||||
}
|
16
config/http/notifications/base/storage.json
Normal file
16
config/http/notifications/base/storage.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "Storage to be used to keep track of subscriptions.",
|
||||
"@id": "urn:solid-server:default:SubscriptionStorage",
|
||||
"@type": "KeyValueSubscriptionStorage",
|
||||
"locker": { "@id": "urn:solid-server:default:ResourceLocker" },
|
||||
"storage": {
|
||||
"@type": "EncodingPathStorage",
|
||||
"relativePath": "/notifications/",
|
||||
"source": { "@id": "urn:solid-server:default:KeyValueStorage" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -4,7 +4,13 @@
|
||||
{
|
||||
"comment": "Converts many RDF serialization to Quad objects.",
|
||||
"@id": "urn:solid-server:default:RdfToQuadConverter",
|
||||
"@type": "RdfToQuadConverter"
|
||||
"@type": "RdfToQuadConverter",
|
||||
"contexts": [
|
||||
{
|
||||
"RdfToQuadConverter:_contexts_key": "https://www.w3.org/ns/solid/notification/v1",
|
||||
"RdfToQuadConverter:_contexts_value": "@css:templates/contexts/notification.jsonld"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
145
package-lock.json
generated
145
package-lock.json
generated
@ -48,6 +48,7 @@
|
||||
"fs-extra": "^10.1.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"ioredis": "^5.2.2",
|
||||
"iso8601-duration": "^2.1.1",
|
||||
"jose": "^4.8.3",
|
||||
"jsonld-context-parser": "^2.1.5",
|
||||
"lodash.orderby": "^4.6.0",
|
||||
@ -62,6 +63,7 @@
|
||||
"rdf-dereference": "^2.0.0",
|
||||
"rdf-parse": "^2.1.0",
|
||||
"rdf-serialize": "^2.0.0",
|
||||
"rdf-string": "^1.6.1",
|
||||
"rdf-terms": "^1.9.0",
|
||||
"sparqlalgebrajs": "^4.0.3",
|
||||
"sparqljs": "^3.5.2",
|
||||
@ -70,7 +72,8 @@
|
||||
"winston": "^3.8.1",
|
||||
"winston-transport": "^4.5.0",
|
||||
"ws": "^8.8.1",
|
||||
"yargs": "^17.5.1"
|
||||
"yargs": "^17.5.1",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"bin": {
|
||||
"community-solid-server": "bin/server.js"
|
||||
@ -663,6 +666,17 @@
|
||||
"@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",
|
||||
@ -4167,9 +4181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.14.170",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.170.tgz",
|
||||
"integrity": "sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q=="
|
||||
"version": "4.14.186",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.186.tgz",
|
||||
"integrity": "sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw=="
|
||||
},
|
||||
"node_modules/@types/lodash.clonedeep": {
|
||||
"version": "4.5.6",
|
||||
@ -10129,6 +10143,11 @@
|
||||
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/iso8601-duration": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/iso8601-duration/-/iso8601-duration-2.1.1.tgz",
|
||||
"integrity": "sha512-VGGpW30/R57FpG1J7RqqKBAaK7lIiudlZkQ5tRoO9hNlKYQNnhs60DQpXlPFBmp6I+kJ61PHkI3f/T7cR4wfbw=="
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
|
||||
@ -11373,8 +11392,12 @@
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
"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=="
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
@ -11928,6 +11951,11 @@
|
||||
"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",
|
||||
@ -12842,6 +12870,11 @@
|
||||
"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",
|
||||
@ -13123,9 +13156,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rdf-string": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.6.0.tgz",
|
||||
"integrity": "sha512-6vQVlEobIHralPtx8V9vtgxA+fwnzZjZv6lRz8dfymILZF6Fl3QJwyRaOAvYaUQc1JMmshGI/wlYlaxin2AldQ==",
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.6.1.tgz",
|
||||
"integrity": "sha512-EDNVQs9jDgm4hkY8TZLhQI3rYUduecMRxLu3szldAdS2CHEo8aFqlHcZDFgBcaJN2XJWoRJU8YQT85wmb8gPEA==",
|
||||
"dependencies": {
|
||||
"@rdfjs/types": "*",
|
||||
"rdf-data-factory": "^1.1.0"
|
||||
@ -13322,6 +13355,11 @@
|
||||
"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",
|
||||
@ -14396,6 +14434,11 @@
|
||||
"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",
|
||||
@ -15204,6 +15247,23 @@
|
||||
"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": {
|
||||
@ -15611,6 +15671,14 @@
|
||||
"@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",
|
||||
@ -18895,9 +18963,9 @@
|
||||
}
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.170",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.170.tgz",
|
||||
"integrity": "sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q=="
|
||||
"version": "4.14.186",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.186.tgz",
|
||||
"integrity": "sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw=="
|
||||
},
|
||||
"@types/lodash.clonedeep": {
|
||||
"version": "4.5.6",
|
||||
@ -23333,6 +23401,11 @@
|
||||
"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
|
||||
"dev": true
|
||||
},
|
||||
"iso8601-duration": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/iso8601-duration/-/iso8601-duration-2.1.1.tgz",
|
||||
"integrity": "sha512-VGGpW30/R57FpG1J7RqqKBAaK7lIiudlZkQ5tRoO9hNlKYQNnhs60DQpXlPFBmp6I+kJ61PHkI3f/T7cR4wfbw=="
|
||||
},
|
||||
"istanbul-lib-coverage": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
|
||||
@ -24322,8 +24395,12 @@
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
"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=="
|
||||
},
|
||||
"lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
@ -24752,6 +24829,11 @@
|
||||
"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",
|
||||
@ -25422,6 +25504,11 @@
|
||||
"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",
|
||||
@ -25663,9 +25750,9 @@
|
||||
}
|
||||
},
|
||||
"rdf-string": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.6.0.tgz",
|
||||
"integrity": "sha512-6vQVlEobIHralPtx8V9vtgxA+fwnzZjZv6lRz8dfymILZF6Fl3QJwyRaOAvYaUQc1JMmshGI/wlYlaxin2AldQ==",
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.6.1.tgz",
|
||||
"integrity": "sha512-EDNVQs9jDgm4hkY8TZLhQI3rYUduecMRxLu3szldAdS2CHEo8aFqlHcZDFgBcaJN2XJWoRJU8YQT85wmb8gPEA==",
|
||||
"requires": {
|
||||
"@rdfjs/types": "*",
|
||||
"rdf-data-factory": "^1.1.0"
|
||||
@ -25822,6 +25909,11 @@
|
||||
"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",
|
||||
@ -26664,6 +26756,11 @@
|
||||
"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",
|
||||
@ -27268,6 +27365,20 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -138,6 +138,7 @@
|
||||
"fs-extra": "^10.1.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"ioredis": "^5.2.2",
|
||||
"iso8601-duration": "^2.1.1",
|
||||
"jose": "^4.8.3",
|
||||
"jsonld-context-parser": "^2.1.5",
|
||||
"lodash.orderby": "^4.6.0",
|
||||
@ -152,6 +153,7 @@
|
||||
"rdf-dereference": "^2.0.0",
|
||||
"rdf-parse": "^2.1.0",
|
||||
"rdf-serialize": "^2.0.0",
|
||||
"rdf-string": "^1.6.1",
|
||||
"rdf-terms": "^1.9.0",
|
||||
"sparqlalgebrajs": "^4.0.3",
|
||||
"sparqljs": "^3.5.2",
|
||||
@ -160,7 +162,8 @@
|
||||
"winston": "^3.8.1",
|
||||
"winston-transport": "^4.5.0",
|
||||
"ws": "^8.8.1",
|
||||
"yargs": "^17.5.1"
|
||||
"yargs": "^17.5.1",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.0.3",
|
||||
|
23
src/index.ts
23
src/index.ts
@ -307,8 +307,31 @@ export * from './server/middleware/HeaderHandler';
|
||||
export * from './server/middleware/StaticAssetHandler';
|
||||
export * from './server/middleware/WebSocketAdvertiser';
|
||||
|
||||
// Server/Notifications/Generate
|
||||
export * from './server/notifications/generate/ActivityNotificationGenerator';
|
||||
export * from './server/notifications/generate/DeleteNotificationGenerator';
|
||||
export * from './server/notifications/generate/NotificationGenerator';
|
||||
export * from './server/notifications/generate/StateNotificationGenerator';
|
||||
|
||||
// Server/Notifications/Serialize
|
||||
export * from './server/notifications/serialize/ConvertingNotificationSerializer';
|
||||
export * from './server/notifications/serialize/JsonLdNotificationSerializer';
|
||||
export * from './server/notifications/serialize/NotificationSerializer';
|
||||
// Server/Notifications
|
||||
export * from './server/notifications/ActivityEmitter';
|
||||
export * from './server/notifications/BaseStateHandler';
|
||||
export * from './server/notifications/ComposedNotificationHandler';
|
||||
export * from './server/notifications/KeyValueSubscriptionStorage';
|
||||
export * from './server/notifications/ListeningActivityHandler';
|
||||
export * from './server/notifications/NotificationDescriber';
|
||||
export * from './server/notifications/NotificationEmitter';
|
||||
export * from './server/notifications/NotificationHandler';
|
||||
export * from './server/notifications/NotificationSubscriber';
|
||||
export * from './server/notifications/StateHandler';
|
||||
export * from './server/notifications/Subscription';
|
||||
export * from './server/notifications/SubscriptionStorage';
|
||||
export * from './server/notifications/SubscriptionType';
|
||||
export * from './server/notifications/TypedNotificationHandler';
|
||||
|
||||
// Server/Util
|
||||
export * from './server/util/BaseRouterHandler';
|
||||
|
38
src/server/notifications/BaseStateHandler.ts
Normal file
38
src/server/notifications/BaseStateHandler.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { getLoggerFor } from '../../logging/LogUtil';
|
||||
import { createErrorMessage } from '../../util/errors/ErrorUtil';
|
||||
import type { NotificationHandler } from './NotificationHandler';
|
||||
import { StateHandler } from './StateHandler';
|
||||
import type { SubscriptionInfo, SubscriptionStorage } from './SubscriptionStorage';
|
||||
|
||||
/**
|
||||
* Handles the `state` feature by calling a {@link NotificationHandler}
|
||||
* in case the {@link SubscriptionInfo} has a `state` value.
|
||||
*
|
||||
* Deletes the `state` parameter from the info afterwards.
|
||||
*/
|
||||
export class BaseStateHandler extends StateHandler {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly handler: NotificationHandler;
|
||||
private readonly storage: SubscriptionStorage;
|
||||
|
||||
public constructor(handler: NotificationHandler, storage: SubscriptionStorage) {
|
||||
super();
|
||||
this.handler = handler;
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
public async handle({ info }: { info: SubscriptionInfo }): Promise<void> {
|
||||
if (info.state) {
|
||||
const topic = { path: info.topic };
|
||||
try {
|
||||
await this.handler.handleSafe({ info, topic });
|
||||
// Remove the state once the relevant notification has been sent
|
||||
delete info.state;
|
||||
await this.storage.update(info);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Problem emitting state notification: ${createErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
47
src/server/notifications/ComposedNotificationHandler.ts
Normal file
47
src/server/notifications/ComposedNotificationHandler.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import type { NotificationGenerator } from './generate/NotificationGenerator';
|
||||
import type { NotificationEmitter } from './NotificationEmitter';
|
||||
import type { NotificationHandlerInput } from './NotificationHandler';
|
||||
import { NotificationHandler } from './NotificationHandler';
|
||||
import type { NotificationSerializer } from './serialize/NotificationSerializer';
|
||||
|
||||
export interface ComposedNotificationHandlerArgs {
|
||||
generator: NotificationGenerator;
|
||||
serializer: NotificationSerializer;
|
||||
emitter: NotificationEmitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates, serializes and emits a {@link Notification} using a {@link NotificationGenerator},
|
||||
* {@link NotificationSerializer} and {@link NotificationEmitter}.
|
||||
*
|
||||
* Will not emit an event in case it has the same state as the subscription info.
|
||||
*/
|
||||
export class ComposedNotificationHandler extends NotificationHandler {
|
||||
private readonly generator: NotificationGenerator;
|
||||
private readonly serializer: NotificationSerializer;
|
||||
private readonly emitter: NotificationEmitter;
|
||||
|
||||
public constructor(args: ComposedNotificationHandlerArgs) {
|
||||
super();
|
||||
this.generator = args.generator;
|
||||
this.serializer = args.serializer;
|
||||
this.emitter = args.emitter;
|
||||
}
|
||||
|
||||
public async canHandle(input: NotificationHandlerInput): Promise<void> {
|
||||
await this.generator.canHandle(input);
|
||||
}
|
||||
|
||||
public async handle(input: NotificationHandlerInput): Promise<void> {
|
||||
const notification = await this.generator.handle(input);
|
||||
|
||||
const { state } = input.info;
|
||||
// In case the state matches there is no need to send the notification
|
||||
if (typeof state === 'string' && state === notification.state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const representation = await this.serializer.handleSafe({ info: input.info, notification });
|
||||
await this.emitter.handleSafe({ info: input.info, representation });
|
||||
}
|
||||
}
|
133
src/server/notifications/KeyValueSubscriptionStorage.ts
Normal file
133
src/server/notifications/KeyValueSubscriptionStorage.ts
Normal file
@ -0,0 +1,133 @@
|
||||
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 { Subscription } from './Subscription';
|
||||
import type { SubscriptionInfo, SubscriptionStorage } from './SubscriptionStorage';
|
||||
|
||||
type StorageValue<T> = string | string[] | SubscriptionInfo<T>;
|
||||
|
||||
/**
|
||||
* Stores all the {@link SubscriptionInfo} in a {@link KeyValueStorage}.
|
||||
*
|
||||
* Uses a {@link ReadWriteLocker} to prevent internal race conditions.
|
||||
*/
|
||||
export class KeyValueSubscriptionStorage<T extends Record<string, unknown>> implements SubscriptionStorage<T> {
|
||||
protected logger = getLoggerFor(this);
|
||||
|
||||
private readonly storage: KeyValueStorage<string, StorageValue<T>>;
|
||||
private readonly locker: ReadWriteLocker;
|
||||
|
||||
public constructor(storage: KeyValueStorage<string, StorageValue<T>>, locker: ReadWriteLocker) {
|
||||
this.storage = storage;
|
||||
this.locker = locker;
|
||||
}
|
||||
|
||||
public create(subscription: Subscription, features: T): SubscriptionInfo<T> {
|
||||
return {
|
||||
id: `${subscription.type}:${v4()}:${subscription.topic}`,
|
||||
topic: subscription.topic,
|
||||
type: subscription.type,
|
||||
lastEmit: 0,
|
||||
expiration: subscription.expiration,
|
||||
accept: subscription.accept,
|
||||
rate: subscription.rate,
|
||||
state: subscription.state,
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<SubscriptionInfo<T> | undefined> {
|
||||
const info = await this.storage.get(id);
|
||||
if (info && this.isSubscriptionInfo(info)) {
|
||||
if (typeof info.expiration === 'number' && info.expiration < Date.now()) {
|
||||
this.logger.info(`Subscription ${id} has expired.`);
|
||||
await this.locker.withWriteLock(this.getLockKey(id), async(): Promise<void> => {
|
||||
await this.deleteInfo(info);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
public async getAll(topic: ResourceIdentifier): Promise<string[]> {
|
||||
const infos = await this.storage.get(topic.path);
|
||||
if (Array.isArray(infos)) {
|
||||
return infos;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
public async add(info: SubscriptionInfo<T>): Promise<void> {
|
||||
const target = { path: info.topic };
|
||||
return this.locker.withWriteLock(this.getLockKey(target), async(): Promise<void> => {
|
||||
const infos = await this.getAll(target);
|
||||
await this.storage.set(info.id, info);
|
||||
infos.push(info.id);
|
||||
await this.storage.set(info.topic, infos);
|
||||
});
|
||||
}
|
||||
|
||||
public async update(info: SubscriptionInfo<T>): Promise<void> {
|
||||
return this.locker.withWriteLock(this.getLockKey(info.id), async(): Promise<void> => {
|
||||
const oldInfo = await this.storage.get(info.id);
|
||||
|
||||
if (oldInfo) {
|
||||
if (!this.isSubscriptionInfo(oldInfo)) {
|
||||
throw new InternalServerError(`Trying to update ${info.id} which is not a SubscriptionInfo.`);
|
||||
}
|
||||
if (info.topic !== oldInfo.topic) {
|
||||
throw new InternalServerError(`Trying to change the topic of subscription ${info.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.storage.set(info.id, info);
|
||||
});
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<void> {
|
||||
return this.locker.withWriteLock(this.getLockKey(id), async(): Promise<void> => {
|
||||
const info = await this.get(id);
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
await this.deleteInfo(info);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for deleting a specific {@link SubscriptionInfo} object.
|
||||
* Does not create a lock on the subscription ID so should be wrapped in such a lock.
|
||||
*/
|
||||
private async deleteInfo(info: SubscriptionInfo): Promise<void> {
|
||||
await this.locker.withWriteLock(this.getLockKey(info.topic), async(): Promise<void> => {
|
||||
const infos = await this.getAll({ path: info.topic });
|
||||
const idx = infos.indexOf(info.id);
|
||||
// If idx < 0 we have an inconsistency
|
||||
if (idx < 0) {
|
||||
this.logger.error(`Subscription info ${info.id} was not found in the list of info targeting ${info.topic}.`);
|
||||
this.logger.error('This should not happen and indicates a data consistency issue.');
|
||||
} else {
|
||||
infos.splice(idx, 1);
|
||||
if (infos.length > 0) {
|
||||
await this.storage.set(info.topic, infos);
|
||||
} else {
|
||||
await this.storage.delete(info.topic);
|
||||
}
|
||||
}
|
||||
await this.storage.delete(info.id);
|
||||
});
|
||||
}
|
||||
|
||||
private isSubscriptionInfo(value: StorageValue<T>): value is SubscriptionInfo<T> {
|
||||
return Boolean((value as SubscriptionInfo).id);
|
||||
}
|
||||
|
||||
private getLockKey(identifier: ResourceIdentifier | string): ResourceIdentifier {
|
||||
return { path: `${typeof identifier === 'string' ? identifier : identifier.path}.notification-storage` };
|
||||
}
|
||||
}
|
59
src/server/notifications/ListeningActivityHandler.ts
Normal file
59
src/server/notifications/ListeningActivityHandler.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import { getLoggerFor } from '../../logging/LogUtil';
|
||||
import { createErrorMessage } from '../../util/errors/ErrorUtil';
|
||||
import { StaticHandler } from '../../util/handlers/StaticHandler';
|
||||
import type { AS, VocabularyTerm } from '../../util/Vocabularies';
|
||||
import type { ActivityEmitter } from './ActivityEmitter';
|
||||
import type { NotificationHandler } from './NotificationHandler';
|
||||
import type { SubscriptionStorage } from './SubscriptionStorage';
|
||||
|
||||
/**
|
||||
* Listens to an {@link ActivityEmitter} and calls the stored {@link NotificationHandler}s in case of an event
|
||||
* for every matching Subscription found.
|
||||
*
|
||||
* Takes the `rate` feature into account so only subscriptions that want a new notification will receive one.
|
||||
*
|
||||
* Extends {@link StaticHandler} so it can be more easily injected into a Components.js configuration.
|
||||
* No class takes this one as input, so to make sure Components.js instantiates it,
|
||||
* it needs to be added somewhere where its presence has no impact, such as the list of initializers.
|
||||
*/
|
||||
export class ListeningActivityHandler extends StaticHandler {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly storage: SubscriptionStorage;
|
||||
private readonly handler: NotificationHandler;
|
||||
|
||||
public constructor(storage: SubscriptionStorage, emitter: ActivityEmitter, handler: NotificationHandler) {
|
||||
super();
|
||||
this.storage = storage;
|
||||
this.handler = handler;
|
||||
|
||||
emitter.on('changed', (topic, activity): void => {
|
||||
this.emit(topic, activity).catch((error): void => {
|
||||
this.logger.error(`Something went wrong emitting notifications: ${createErrorMessage(error)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async emit(topic: ResourceIdentifier, activity: VocabularyTerm<typeof AS>): Promise<void> {
|
||||
const subscriptionIds = await this.storage.getAll(topic);
|
||||
|
||||
for (const id of subscriptionIds) {
|
||||
const info = await this.storage.get(id);
|
||||
if (!info) {
|
||||
// Subscription has expired
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.rate && info.rate > Date.now() - info.lastEmit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// No need to wait on this to resolve before going to the next subscription.
|
||||
// Prevent failed notification from blocking other notifications.
|
||||
this.handler.handleSafe({ info, activity, topic }).catch((error): void => {
|
||||
this.logger.error(`Error trying to handle notification for ${id}: ${createErrorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
22
src/server/notifications/Notification.ts
Normal file
22
src/server/notifications/Notification.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export const CONTEXT_ACTIVITYSTREAMS = 'https://www.w3.org/ns/activitystreams';
|
||||
export const CONTEXT_NOTIFICATION = 'https://www.w3.org/ns/solid/notification/v1';
|
||||
|
||||
/**
|
||||
* The minimal expected fields for a Notification
|
||||
* as defined in https://solidproject.org/TR/notifications-protocol#notification-data-model.
|
||||
*/
|
||||
export interface Notification {
|
||||
'@context': [
|
||||
typeof CONTEXT_ACTIVITYSTREAMS,
|
||||
typeof CONTEXT_NOTIFICATION,
|
||||
...string[],
|
||||
];
|
||||
id: string;
|
||||
type: string[];
|
||||
object: {
|
||||
id: string;
|
||||
type: string[];
|
||||
};
|
||||
state?: string;
|
||||
published: string;
|
||||
}
|
52
src/server/notifications/NotificationDescriber.ts
Normal file
52
src/server/notifications/NotificationDescriber.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type { NamedNode, Quad } from '@rdfjs/types';
|
||||
import { DataFactory } from 'n3';
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import type { InteractionRoute } from '../../identity/interaction/routing/InteractionRoute';
|
||||
import { NOTIFY, RDF } from '../../util/Vocabularies';
|
||||
import { StorageDescriber } from '../description/StorageDescriber';
|
||||
const { namedNode, quad } = DataFactory;
|
||||
|
||||
const DEFAULT_FEATURES = [
|
||||
NOTIFY.accept,
|
||||
NOTIFY.expiration,
|
||||
NOTIFY.rate,
|
||||
NOTIFY.state,
|
||||
];
|
||||
|
||||
/**
|
||||
* Outputs quads describing how to access a specific Notificaion Subscription type and its features,
|
||||
* as described in https://solidproject.org/TR/notifications-protocol#discovery.
|
||||
*/
|
||||
export class NotificationDescriber extends StorageDescriber {
|
||||
private readonly path: NamedNode;
|
||||
private readonly relative: string;
|
||||
private readonly type: NamedNode;
|
||||
private readonly features: NamedNode[];
|
||||
|
||||
/**
|
||||
* @param route - The route describing where the subscription target is.
|
||||
* @param relative - Will be appended to the input path to generate a named node corresponding to the description.
|
||||
* E.g., "#websocketNotification".
|
||||
* @param type - The rdf:type of the subscription type.
|
||||
* @param features - Which features are enabled for this subscription type. Defaults to accept/expiration/rate/state.
|
||||
*/
|
||||
public constructor(route: InteractionRoute, relative: string, type: string, features: string[] = DEFAULT_FEATURES) {
|
||||
super();
|
||||
this.path = namedNode(route.getPath());
|
||||
this.relative = relative;
|
||||
this.type = namedNode(type);
|
||||
this.features = features.map(namedNode);
|
||||
}
|
||||
|
||||
public async handle(input: ResourceIdentifier): Promise<Quad[]> {
|
||||
const subject = namedNode(input.path);
|
||||
const subscription = namedNode(`${input.path}${this.relative}`);
|
||||
|
||||
return [
|
||||
quad(subject, NOTIFY.terms.notificationChannel, subscription),
|
||||
quad(subscription, RDF.terms.type, this.type),
|
||||
quad(subscription, NOTIFY.terms.subscription, this.path),
|
||||
...this.features.map((feature): Quad => quad(subscription, NOTIFY.terms.feature, feature)),
|
||||
];
|
||||
}
|
||||
}
|
13
src/server/notifications/NotificationEmitter.ts
Normal file
13
src/server/notifications/NotificationEmitter.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Representation } from '../../http/representation/Representation';
|
||||
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
||||
import type { SubscriptionInfo } from './SubscriptionStorage';
|
||||
|
||||
export interface NotificationEmitterInput {
|
||||
representation: Representation;
|
||||
info: SubscriptionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a serialized Notification to the subscription defined by the info.
|
||||
*/
|
||||
export abstract class NotificationEmitter extends AsyncHandler<NotificationEmitterInput> {}
|
15
src/server/notifications/NotificationHandler.ts
Normal file
15
src/server/notifications/NotificationHandler.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
||||
import type { AS, VocabularyTerm } from '../../util/Vocabularies';
|
||||
import type { SubscriptionInfo } from './SubscriptionStorage';
|
||||
|
||||
export interface NotificationHandlerInput {
|
||||
topic: ResourceIdentifier;
|
||||
info: SubscriptionInfo;
|
||||
activity?: VocabularyTerm<typeof AS>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure an activity gets emitted to the relevant subscription based on the given info.
|
||||
*/
|
||||
export abstract class NotificationHandler extends AsyncHandler<NotificationHandlerInput> {}
|
108
src/server/notifications/NotificationSubscriber.ts
Normal file
108
src/server/notifications/NotificationSubscriber.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import type { CredentialsExtractor } from '../../authentication/CredentialsExtractor';
|
||||
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 { getLoggerFor } from '../../logging/LogUtil';
|
||||
import { APPLICATION_LD_JSON } 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 type { OperationHttpHandlerInput } from '../OperationHttpHandler';
|
||||
import { OperationHttpHandler } from '../OperationHttpHandler';
|
||||
import type { Subscription } from './Subscription';
|
||||
import type { SubscriptionType } from './SubscriptionType';
|
||||
|
||||
export interface NotificationSubscriberArgs {
|
||||
/**
|
||||
* The {@link SubscriptionType} with all the necessary information.
|
||||
*/
|
||||
subscriptionType: SubscriptionType;
|
||||
/**
|
||||
* Used to extract the credentials from the request.
|
||||
*/
|
||||
credentialsExtractor: CredentialsExtractor;
|
||||
/**
|
||||
* Used to determine which permissions the found credentials have.
|
||||
*/
|
||||
permissionReader: PermissionReader;
|
||||
/**
|
||||
* Used to determine if the request has the necessary permissions.
|
||||
*/
|
||||
authorizer: Authorizer;
|
||||
/**
|
||||
* Overrides the expiration feature of subscriptions by making sure they always expire after the `maxDuration` value.
|
||||
* In case the expiration of the subscription is shorter than `maxDuration` the original value will be kept.
|
||||
* Value is set in minutes. 0 is infinite.
|
||||
*/
|
||||
maxDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles notification subscriptions.
|
||||
*
|
||||
* Uses the information from the provided {@link SubscriptionType} to validate the input
|
||||
* and verify the request has the required permissions available.
|
||||
*/
|
||||
export class NotificationSubscriber extends OperationHttpHandler {
|
||||
protected logger = getLoggerFor(this);
|
||||
|
||||
private readonly subscriptionType: SubscriptionType;
|
||||
private readonly credentialsExtractor: CredentialsExtractor;
|
||||
private readonly permissionReader: PermissionReader;
|
||||
private readonly authorizer: Authorizer;
|
||||
private readonly maxDuration: number;
|
||||
|
||||
public constructor(args: NotificationSubscriberArgs) {
|
||||
super();
|
||||
this.subscriptionType = args.subscriptionType;
|
||||
this.credentialsExtractor = args.credentialsExtractor;
|
||||
this.permissionReader = args.permissionReader;
|
||||
this.authorizer = args.authorizer;
|
||||
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.');
|
||||
}
|
||||
|
||||
let subscription: Subscription;
|
||||
try {
|
||||
const json = JSON.parse(await readableToString(operation.body.data));
|
||||
subscription = await this.subscriptionType.schema.validate(json);
|
||||
} catch (error: unknown) {
|
||||
throw new UnprocessableEntityHttpError(`Unable to process subscription: ${createErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
if (this.maxDuration) {
|
||||
const duration = (subscription.expiration ?? Number.POSITIVE_INFINITY) - Date.now();
|
||||
if (duration > this.maxDuration) {
|
||||
subscription.expiration = Date.now() + this.maxDuration;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify if the client is allowed to subscribe
|
||||
await this.authorize(request, subscription);
|
||||
|
||||
const { response } = await this.subscriptionType.subscribe(subscription);
|
||||
|
||||
return new OkResponseDescription(response.metadata, response.data);
|
||||
}
|
||||
|
||||
private async authorize(request: HttpRequest, subscription: Subscription): Promise<void> {
|
||||
const credentials = await this.credentialsExtractor.handleSafe(request);
|
||||
this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);
|
||||
|
||||
const requestedModes = await this.subscriptionType.extractModes(subscription);
|
||||
this.logger.debug(`Retrieved required modes: ${[ ...requestedModes.entrySets() ]}`);
|
||||
|
||||
const availablePermissions = await this.permissionReader.handleSafe({ credentials, requestedModes });
|
||||
this.logger.debug(`Available permissions are ${[ ...availablePermissions.entries() ]}`);
|
||||
|
||||
await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions });
|
||||
this.logger.verbose(`Authorization succeeded, creating subscription`);
|
||||
}
|
||||
}
|
11
src/server/notifications/StateHandler.ts
Normal file
11
src/server/notifications/StateHandler.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
||||
import type { SubscriptionInfo } from './SubscriptionStorage';
|
||||
|
||||
/**
|
||||
* Handles the `state` feature of notifications.
|
||||
* Every implementation of a specific subscription type should make sure an instance of this class
|
||||
* gets called when a `state` notification can be sent out.
|
||||
*
|
||||
* Implementations of this class should handle all subscriptions and filter out those that need a `state` notification.
|
||||
*/
|
||||
export abstract class StateHandler extends AsyncHandler<{ info: SubscriptionInfo }> {}
|
27
src/server/notifications/Subscription.ts
Normal file
27
src/server/notifications/Subscription.ts
Normal file
@ -0,0 +1,27 @@
|
||||
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 subscription input.
|
||||
* Specific subscription types can extend this schema with their own custom keys.
|
||||
*/
|
||||
export const SUBSCRIBE_SCHEMA = object({
|
||||
'@context': array(string()).ensure().required().test({
|
||||
name: 'RequireNotificationContext',
|
||||
message: `The ${CONTEXT_NOTIFICATION} context is required in the subscription JSON-LD body.`,
|
||||
test: (context): boolean => Boolean(context?.includes(CONTEXT_NOTIFICATION)),
|
||||
}),
|
||||
type: string().required(),
|
||||
topic: string().required(),
|
||||
state: string().optional(),
|
||||
expiration: 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 Subscription = InferType<typeof SUBSCRIBE_SCHEMA>;
|
66
src/server/notifications/SubscriptionStorage.ts
Normal file
66
src/server/notifications/SubscriptionStorage.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
|
||||
import type { Subscription } from './Subscription';
|
||||
|
||||
/**
|
||||
* The info provided during a subscription.
|
||||
* `features` can contain custom values relevant for a specific subscription type.
|
||||
*/
|
||||
export type SubscriptionInfo<T = Record<string, unknown>> = {
|
||||
id: string;
|
||||
topic: string;
|
||||
type: string;
|
||||
expiration?: number;
|
||||
accept?: string;
|
||||
rate?: number;
|
||||
state?: string;
|
||||
lastEmit: number;
|
||||
features: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stores all the information necessary to keep track of notification subscriptions.
|
||||
* Besides the standard subscription info it also stores features specific to a certain subscription type.
|
||||
*
|
||||
* This storage assumes that a subscription can only have a single identifier as its topic.
|
||||
*/
|
||||
export interface SubscriptionStorage<T extends Record<string, unknown> = Record<string, unknown>> {
|
||||
/**
|
||||
* Creates info corresponding to the given subscription and features.
|
||||
* This does not store the generated info in the storage.
|
||||
* @param subscription - Subscription to generate info of.
|
||||
* @param features - Features to add to the info
|
||||
*/
|
||||
create: (subscription: Subscription, features: T) => SubscriptionInfo<T>;
|
||||
|
||||
/**
|
||||
* Returns the info for the requested subscription.
|
||||
* `undefined` if no match was found or if the subscription expired.
|
||||
* @param id - The identifier of the subscription.
|
||||
*/
|
||||
get: (id: string) => Promise<SubscriptionInfo<T> | undefined>;
|
||||
|
||||
/**
|
||||
* Returns the identifiers of all subscription entries that have the given identifier as their topic.
|
||||
* @param topic - The identifier that is the topic.
|
||||
*/
|
||||
getAll: (topic: ResourceIdentifier) => Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Adds the given info to the storage.
|
||||
* @param info - Info to add.
|
||||
*/
|
||||
add: (info: SubscriptionInfo<T>) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates the given subscription info.
|
||||
* The `id` and the `topic` can not be updated.
|
||||
* @param info - The info to update.
|
||||
*/
|
||||
update: (info: SubscriptionInfo<T>) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Deletes the given subscription from the storage.
|
||||
* @param id - The identifier of the subscription
|
||||
*/
|
||||
delete: (id: string) => Promise<void>;
|
||||
}
|
39
src/server/notifications/SubscriptionType.ts
Normal file
39
src/server/notifications/SubscriptionType.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { InferType } from 'yup';
|
||||
import type { AccessMap } from '../../authorization/permissions/Permissions';
|
||||
import type { Representation } from '../../http/representation/Representation';
|
||||
import type { SUBSCRIBE_SCHEMA } from './Subscription';
|
||||
import type { SubscriptionInfo } from './SubscriptionStorage';
|
||||
|
||||
export interface SubscriptionResponse<TFeat extends Record<string, unknown> = Record<string, unknown>> {
|
||||
response: Representation;
|
||||
info: SubscriptionInfo<TFeat>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A specific subscription type as defined at https://solidproject.org/TR/notifications-protocol#subscription-types.
|
||||
*/
|
||||
export interface SubscriptionType<TSub extends typeof SUBSCRIBE_SCHEMA = typeof SUBSCRIBE_SCHEMA,
|
||||
TFeat extends Record<string, unknown> = Record<string, unknown>> {
|
||||
/**
|
||||
* The expected type value in the JSON-LD body of requests subscribing for this subscription type.
|
||||
*/
|
||||
readonly type: string;
|
||||
/**
|
||||
* An extension of {@link SUBSCRIBE_SCHEMA} that can be used to parse and valide an incoming subscription request.
|
||||
*/
|
||||
readonly schema: TSub;
|
||||
/**
|
||||
* Determines which modes are required to allow the given subscription.
|
||||
* @param subscription - The subscription to verify.
|
||||
*
|
||||
* @returns The required modes.
|
||||
*/
|
||||
extractModes: (subscription: InferType<TSub>) => Promise<AccessMap>;
|
||||
/**
|
||||
* Registers the given subscription.
|
||||
* @param subscription - The subscription to register.
|
||||
*
|
||||
* @returns A {@link Representation} to return as a response and the generated {@link SubscriptionInfo}.
|
||||
*/
|
||||
subscribe: (subscription: InferType<TSub>) => Promise<SubscriptionResponse<TFeat>>;
|
||||
}
|
28
src/server/notifications/TypedNotificationHandler.ts
Normal file
28
src/server/notifications/TypedNotificationHandler.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
|
||||
import type { NotificationHandlerInput } from './NotificationHandler';
|
||||
import { NotificationHandler } from './NotificationHandler';
|
||||
|
||||
/**
|
||||
* A {@link NotificationHandler} that only accepts input for a specific subscription type.
|
||||
*/
|
||||
export class TypedNotificationHandler extends NotificationHandler {
|
||||
private readonly type: string;
|
||||
private readonly source: NotificationHandler;
|
||||
|
||||
public constructor(type: string, source: NotificationHandler) {
|
||||
super();
|
||||
this.type = type;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public async canHandle(input: NotificationHandlerInput): Promise<void> {
|
||||
if (input.info.type !== this.type) {
|
||||
throw new NotImplementedHttpError(`Only ${this.type} subscriptions are supported.`);
|
||||
}
|
||||
await this.source.canHandle(input);
|
||||
}
|
||||
|
||||
public async handle(input: NotificationHandlerInput): Promise<void> {
|
||||
await this.source.handle(input);
|
||||
}
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
import { getETag } from '../../../storage/Conditions';
|
||||
import type { ResourceStore } from '../../../storage/ResourceStore';
|
||||
import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError';
|
||||
import { AS, RDF } from '../../../util/Vocabularies';
|
||||
import type { Notification } from '../Notification';
|
||||
import { CONTEXT_ACTIVITYSTREAMS, CONTEXT_NOTIFICATION } from '../Notification';
|
||||
import type { NotificationHandlerInput } from '../NotificationHandler';
|
||||
import { NotificationGenerator } from './NotificationGenerator';
|
||||
|
||||
/**
|
||||
* A {@link NotificationGenerator} that creates a {@link Notification} by using the provided activity as type.
|
||||
* Requests metadata of the topic from the {@link ResourceStore} to fill in the details.
|
||||
*/
|
||||
export class ActivityNotificationGenerator extends NotificationGenerator {
|
||||
private readonly store: ResourceStore;
|
||||
|
||||
public constructor(store: ResourceStore) {
|
||||
super();
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
public async canHandle({ activity }: NotificationHandlerInput): Promise<void> {
|
||||
if (!activity) {
|
||||
throw new NotImplementedHttpError(`Only defined activities are supported.`);
|
||||
}
|
||||
}
|
||||
|
||||
public async handle({ topic, activity }: NotificationHandlerInput): Promise<Notification> {
|
||||
const representation = await this.store.getRepresentation(topic, {});
|
||||
representation.data.destroy();
|
||||
|
||||
const state = getETag(representation.metadata);
|
||||
|
||||
return {
|
||||
'@context': [
|
||||
CONTEXT_ACTIVITYSTREAMS,
|
||||
CONTEXT_NOTIFICATION,
|
||||
],
|
||||
id: `urn:${Date.now()}:${topic.path}`,
|
||||
type: [ activity!.value.slice(AS.namespace.length) ],
|
||||
object: {
|
||||
id: topic.path,
|
||||
type: representation.metadata.getAll(RDF.terms.type).map((term): string => term.value),
|
||||
},
|
||||
state,
|
||||
published: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { NotImplementedHttpError } from '../../../util/errors/NotImplementedHttpError';
|
||||
import { AS } from '../../../util/Vocabularies';
|
||||
import type { Notification } from '../Notification';
|
||||
import { CONTEXT_ACTIVITYSTREAMS, CONTEXT_NOTIFICATION } from '../Notification';
|
||||
import type { NotificationHandlerInput } from '../NotificationHandler';
|
||||
import { NotificationGenerator } from './NotificationGenerator';
|
||||
|
||||
/**
|
||||
* Generates a {@link Notification} for a resource that was deleted.
|
||||
* This differs from other activity notifications in that there is no state and no resource metadata
|
||||
* since the resource no longer exists.
|
||||
*/
|
||||
export class DeleteNotificationGenerator extends NotificationGenerator {
|
||||
public async canHandle({ activity }: NotificationHandlerInput): Promise<void> {
|
||||
if (!activity?.equals(AS.terms.Delete)) {
|
||||
throw new NotImplementedHttpError(`Only Delete activity updates are supported.`);
|
||||
}
|
||||
}
|
||||
|
||||
public async handle({ topic }: NotificationHandlerInput): Promise<Notification> {
|
||||
return {
|
||||
'@context': [
|
||||
CONTEXT_ACTIVITYSTREAMS,
|
||||
CONTEXT_NOTIFICATION,
|
||||
],
|
||||
id: `urn:${Date.now()}:${topic.path}`,
|
||||
type: [ 'Delete' ],
|
||||
object: {
|
||||
id: topic.path,
|
||||
type: [],
|
||||
},
|
||||
published: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
|
||||
import type { Notification } from '../Notification';
|
||||
import type { NotificationHandlerInput } from '../NotificationHandler';
|
||||
|
||||
/**
|
||||
* Creates a {@link Notification} based on the provided input.
|
||||
*/
|
||||
export abstract class NotificationGenerator extends AsyncHandler<NotificationHandlerInput, Notification> { }
|
@ -0,0 +1,29 @@
|
||||
import type { ResourceSet } from '../../../storage/ResourceSet';
|
||||
import { AS } from '../../../util/Vocabularies';
|
||||
import type { Notification } from '../Notification';
|
||||
import type { NotificationHandlerInput } from '../NotificationHandler';
|
||||
import { NotificationGenerator } from './NotificationGenerator';
|
||||
|
||||
/**
|
||||
* Determines the most relevant activity for a {@link Notification} in case none was provided.
|
||||
* This is relevant for the `state` feature where a subscription needs to know the current state of a resource.
|
||||
*/
|
||||
export class StateNotificationGenerator extends NotificationGenerator {
|
||||
private readonly source: NotificationGenerator;
|
||||
private readonly resourceSet: ResourceSet;
|
||||
|
||||
public constructor(source: NotificationGenerator, resourceSet: ResourceSet) {
|
||||
super();
|
||||
this.source = source;
|
||||
this.resourceSet = resourceSet;
|
||||
}
|
||||
|
||||
public async handle(input: NotificationHandlerInput): Promise<Notification> {
|
||||
if (input.activity) {
|
||||
return this.source.handleSafe(input);
|
||||
}
|
||||
|
||||
const activity = await this.resourceSet.hasResource(input.topic) ? AS.terms.Update : AS.terms.Delete;
|
||||
return this.source.handleSafe({ ...input, activity });
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import type { Representation } from '../../../http/representation/Representation';
|
||||
import type { RepresentationPreferences } from '../../../http/representation/RepresentationPreferences';
|
||||
import type { RepresentationConverter } from '../../../storage/conversion/RepresentationConverter';
|
||||
import type { NotificationSerializerInput } from './NotificationSerializer';
|
||||
import { NotificationSerializer } from './NotificationSerializer';
|
||||
|
||||
/**
|
||||
* Converts a serialization based on the provided `accept` feature value.
|
||||
* In case none was provided no conversion takes place.
|
||||
*/
|
||||
export class ConvertingNotificationSerializer extends NotificationSerializer {
|
||||
private readonly source: NotificationSerializer;
|
||||
private readonly converter: RepresentationConverter;
|
||||
|
||||
public constructor(source: NotificationSerializer, converter: RepresentationConverter) {
|
||||
super();
|
||||
this.source = source;
|
||||
this.converter = converter;
|
||||
}
|
||||
|
||||
public async canHandle(input: NotificationSerializerInput): Promise<void> {
|
||||
await this.source.canHandle(input);
|
||||
}
|
||||
|
||||
public async handle(input: NotificationSerializerInput): Promise<Representation> {
|
||||
const representation = await this.source.handle(input);
|
||||
|
||||
const type = input.info.accept;
|
||||
|
||||
if (!type) {
|
||||
return representation;
|
||||
}
|
||||
|
||||
const preferences: RepresentationPreferences = { type: { [type]: 1 }};
|
||||
return this.converter.handleSafe({ representation, preferences, identifier: { path: input.notification.id }});
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import { BasicRepresentation } from '../../../http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../../http/representation/Representation';
|
||||
import { APPLICATION_LD_JSON } from '../../../util/ContentTypes';
|
||||
import type { NotificationSerializerInput } from './NotificationSerializer';
|
||||
import { NotificationSerializer } from './NotificationSerializer';
|
||||
|
||||
/**
|
||||
* Serializes a Notification into a JSON-LD string.
|
||||
*/
|
||||
export class JsonLdNotificationSerializer extends NotificationSerializer {
|
||||
public async handle({ notification }: NotificationSerializerInput): Promise<Representation> {
|
||||
return new BasicRepresentation(JSON.stringify(notification), APPLICATION_LD_JSON);
|
||||
}
|
||||
}
|
17
src/server/notifications/serialize/NotificationSerializer.ts
Normal file
17
src/server/notifications/serialize/NotificationSerializer.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import type { Representation } from '../../../http/representation/Representation';
|
||||
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
|
||||
import type { Notification } from '../Notification';
|
||||
import type { SubscriptionInfo } from '../SubscriptionStorage';
|
||||
|
||||
export interface NotificationSerializerInput {
|
||||
notification: Notification;
|
||||
info: SubscriptionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a {@link Notification} into a {@link Representation} that can be transmitted.
|
||||
*
|
||||
* The reason this is a separate class in between a generator and emitter,
|
||||
* is so a specific subscription type can add extra metadata to the Representation if needed.
|
||||
*/
|
||||
export abstract class NotificationSerializer extends AsyncHandler<NotificationSerializerInput, Representation> { }
|
@ -1,5 +1,6 @@
|
||||
// Well-known content types
|
||||
export const APPLICATION_JSON = 'application/json';
|
||||
export const APPLICATION_LD_JSON = 'application/ld+json';
|
||||
export const APPLICATION_OCTET_STREAM = 'application/octet-stream';
|
||||
export const APPLICATION_SPARQL_UPDATE = 'application/sparql-update';
|
||||
export const APPLICATION_X_WWW_FORM_URLENCODED = 'application/x-www-form-urlencoded';
|
||||
|
@ -191,6 +191,16 @@ export const MA = createVocabulary('http://www.w3.org/ns/ma-ont#',
|
||||
'format',
|
||||
);
|
||||
|
||||
export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications#',
|
||||
'accept',
|
||||
'expiration',
|
||||
'feature',
|
||||
'notificationChannel',
|
||||
'rate',
|
||||
'state',
|
||||
'subscription',
|
||||
);
|
||||
|
||||
export const OIDC = createVocabulary('http://www.w3.org/ns/solid/oidc#',
|
||||
'redirect_uris',
|
||||
);
|
||||
|
21
templates/contexts/notification.jsonld
Normal file
21
templates/contexts/notification.jsonld
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"@context": {
|
||||
"id": "@id",
|
||||
"type": "@type",
|
||||
"notify": "http://www.w3.org/ns/solid/notifications#",
|
||||
"WebSocketSubscription2021": "notify:WebSocketSubscription2021",
|
||||
"features": {
|
||||
"@id": "notify:features",
|
||||
"@type": "@id"
|
||||
},
|
||||
"notificationChannel": {
|
||||
"@id": "notify:notificationChannel",
|
||||
"@type": "@id"
|
||||
},
|
||||
"state": "notify:state",
|
||||
"subscription": {
|
||||
"@id": "notify:subscription",
|
||||
"@type": "@id"
|
||||
}
|
||||
}
|
||||
}
|
50
test/unit/server/notifications/BaseStateHandler.test.ts
Normal file
50
test/unit/server/notifications/BaseStateHandler.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { BaseStateHandler } from '../../../../src/server/notifications/BaseStateHandler';
|
||||
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
|
||||
import type { SubscriptionInfo, SubscriptionStorage } from '../../../../src/server/notifications/SubscriptionStorage';
|
||||
|
||||
describe('A BaseStateHandler', (): void => {
|
||||
let info: SubscriptionInfo;
|
||||
let notificationHandler: jest.Mocked<NotificationHandler>;
|
||||
let storage: jest.Mocked<SubscriptionStorage>;
|
||||
let handler: BaseStateHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
info = {
|
||||
id: 'id',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
state: '123',
|
||||
};
|
||||
|
||||
notificationHandler = {
|
||||
handleSafe: jest.fn(),
|
||||
} as any;
|
||||
|
||||
storage = {
|
||||
update: jest.fn(),
|
||||
} as any;
|
||||
|
||||
handler = new BaseStateHandler(notificationHandler, storage);
|
||||
});
|
||||
|
||||
it('calls the handler if there is a trigger.', async(): Promise<void> => {
|
||||
await expect(handler.handleSafe({ info })).resolves.toBeUndefined();
|
||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
// Note that jest stores a reference to the input object so we can't see that the state value was still there
|
||||
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ topic: { path: info.topic }, info });
|
||||
expect(info.state).toBeUndefined();
|
||||
expect(storage.update).toHaveBeenCalledTimes(1);
|
||||
expect(storage.update).toHaveBeenLastCalledWith(info);
|
||||
});
|
||||
|
||||
it('does not delete the state parameter if something goes wrong.', async(): Promise<void> => {
|
||||
notificationHandler.handleSafe.mockRejectedValue(new Error('bad input'));
|
||||
await expect(handler.handleSafe({ info })).resolves.toBeUndefined();
|
||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ topic: { path: info.topic }, info });
|
||||
expect(info.state).toBe('123');
|
||||
expect(storage.update).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
@ -0,0 +1,82 @@
|
||||
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
|
||||
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
||||
import { ComposedNotificationHandler } from '../../../../src/server/notifications/ComposedNotificationHandler';
|
||||
import type { NotificationGenerator } from '../../../../src/server/notifications/generate/NotificationGenerator';
|
||||
import type { Notification } from '../../../../src/server/notifications/Notification';
|
||||
import type { NotificationEmitter } from '../../../../src/server/notifications/NotificationEmitter';
|
||||
import type { NotificationSerializer } from '../../../../src/server/notifications/serialize/NotificationSerializer';
|
||||
import type { SubscriptionInfo } from '../../../../src/server/notifications/SubscriptionStorage';
|
||||
|
||||
describe('A ComposedNotificationHandler', (): void => {
|
||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
const notification: Notification = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://www.w3.org/ns/solid/notification/v1',
|
||||
],
|
||||
id: `urn:123:http://example.com/foo`,
|
||||
type: [ 'Update' ],
|
||||
object: {
|
||||
id: 'http://example.com/foo',
|
||||
type: [],
|
||||
},
|
||||
published: '123',
|
||||
state: '123',
|
||||
};
|
||||
let info: SubscriptionInfo;
|
||||
const representation = new BasicRepresentation();
|
||||
let generator: jest.Mocked<NotificationGenerator>;
|
||||
let serializer: jest.Mocked<NotificationSerializer>;
|
||||
let emitter: jest.Mocked<NotificationEmitter>;
|
||||
let handler: ComposedNotificationHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
info = {
|
||||
id: 'id',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
|
||||
generator = {
|
||||
canHandle: jest.fn(),
|
||||
handle: jest.fn().mockResolvedValue(notification),
|
||||
} as any;
|
||||
|
||||
serializer = {
|
||||
handleSafe: jest.fn().mockResolvedValue(representation),
|
||||
} as any;
|
||||
|
||||
emitter = {
|
||||
handleSafe: jest.fn(),
|
||||
} as any;
|
||||
|
||||
handler = new ComposedNotificationHandler({ generator, serializer, emitter });
|
||||
});
|
||||
|
||||
it('can only handle input supported by the generator.', async(): Promise<void> => {
|
||||
await expect(handler.canHandle({ info, topic })).resolves.toBeUndefined();
|
||||
generator.canHandle.mockRejectedValue(new Error('bad input'));
|
||||
await expect(handler.canHandle({ info, topic })).rejects.toThrow('bad input');
|
||||
});
|
||||
|
||||
it('calls the three wrapped classes in order.', async(): Promise<void> => {
|
||||
await expect(handler.handle({ info, topic })).resolves.toBeUndefined();
|
||||
expect(generator.handle).toHaveBeenCalledTimes(1);
|
||||
expect(generator.handle).toHaveBeenLastCalledWith({ info, topic });
|
||||
expect(serializer.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(serializer.handleSafe).toHaveBeenLastCalledWith({ info, notification });
|
||||
expect(emitter.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.handleSafe).toHaveBeenLastCalledWith({ info, representation });
|
||||
});
|
||||
|
||||
it('does not emit the notification if its state matches the info state.', async(): Promise<void> => {
|
||||
info.state = notification.state;
|
||||
await expect(handler.handle({ info, topic })).resolves.toBeUndefined();
|
||||
expect(generator.handle).toHaveBeenCalledTimes(1);
|
||||
expect(generator.handle).toHaveBeenLastCalledWith({ info, topic });
|
||||
expect(serializer.handleSafe).toHaveBeenCalledTimes(0);
|
||||
expect(emitter.handleSafe).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
@ -0,0 +1,173 @@
|
||||
import { v4 } from 'uuid';
|
||||
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
||||
import type { Logger } from '../../../../src/logging/Logger';
|
||||
import { getLoggerFor } from '../../../../src/logging/LogUtil';
|
||||
import { KeyValueSubscriptionStorage } from '../../../../src/server/notifications/KeyValueSubscriptionStorage';
|
||||
import type { Subscription } from '../../../../src/server/notifications/Subscription';
|
||||
import type { SubscriptionInfo } from '../../../../src/server/notifications/SubscriptionStorage';
|
||||
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
|
||||
import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker';
|
||||
import resetAllMocks = jest.resetAllMocks;
|
||||
|
||||
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
|
||||
jest.mock('../../../../src/logging/LogUtil', (): any => {
|
||||
const logger: Logger = { info: jest.fn(), error: jest.fn() } as any;
|
||||
return { getLoggerFor: (): Logger => logger };
|
||||
});
|
||||
|
||||
describe('A KeyValueSubscriptionStorage', (): void => {
|
||||
const logger = getLoggerFor('mock');
|
||||
const topic = 'http://example.com/foo';
|
||||
const identifier = { path: topic };
|
||||
const subscription = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'WebSocketSubscription2021',
|
||||
topic,
|
||||
} as Subscription;
|
||||
const features = { aa: 'bb' };
|
||||
let info: SubscriptionInfo<Record<string, string>>;
|
||||
let internalMap: Map<string, any>;
|
||||
let internalStorage: KeyValueStorage<string, any>;
|
||||
let locker: ReadWriteLocker;
|
||||
let storage: KeyValueSubscriptionStorage<Record<string, string>>;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
resetAllMocks();
|
||||
info = {
|
||||
id: `WebSocketSubscription2021:${v4()}:http://example.com/foo`,
|
||||
topic,
|
||||
type: 'WebSocketSubscription2021',
|
||||
features,
|
||||
lastEmit: 0,
|
||||
};
|
||||
|
||||
internalMap = new Map();
|
||||
internalStorage = internalMap as any;
|
||||
|
||||
locker = {
|
||||
withWriteLock: jest.fn(async <T,>(id: ResourceIdentifier, whileLocked: () => T | Promise<T>):
|
||||
Promise<T> => whileLocked()),
|
||||
withReadLock: jest.fn(),
|
||||
};
|
||||
|
||||
storage = new KeyValueSubscriptionStorage(internalStorage, locker);
|
||||
});
|
||||
|
||||
describe('#create', (): void => {
|
||||
it('creates info based on a subscription.', async(): Promise<void> => {
|
||||
expect(storage.create(subscription, features)).toEqual(info);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#get', (): void => {
|
||||
it('returns undefined if there is no match.', async(): Promise<void> => {
|
||||
await expect(storage.get('notexists')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the matching info.', async(): Promise<void> => {
|
||||
await storage.add(info);
|
||||
await expect(storage.get(info.id)).resolves.toEqual(info);
|
||||
});
|
||||
|
||||
it('deletes expired info.', async(): Promise<void> => {
|
||||
info.expiration = 0;
|
||||
await storage.add(info);
|
||||
await expect(storage.get(info.id)).resolves.toBeUndefined();
|
||||
expect(internalMap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getAll', (): void => {
|
||||
it('returns an empty array if there is no match.', async(): Promise<void> => {
|
||||
await expect(storage.getAll(identifier)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the identifiers of all the matching infos.', async(): Promise<void> => {
|
||||
await storage.add(info);
|
||||
await expect(storage.getAll(identifier)).resolves.toEqual([ info.id ]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#add', (): void => {
|
||||
it('adds the info and adds its id to the topic collection.', async(): Promise<void> => {
|
||||
await expect(storage.add(info)).resolves.toBeUndefined();
|
||||
expect(internalMap.size).toBe(2);
|
||||
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
||||
[ info.id ],
|
||||
info,
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('#update', (): void => {
|
||||
it('changes the info.', async(): Promise<void> => {
|
||||
await storage.add(info);
|
||||
const newInfo = {
|
||||
...info,
|
||||
state: '123456',
|
||||
};
|
||||
await expect(storage.update(newInfo)).resolves.toBeUndefined();
|
||||
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
||||
[ info.id ],
|
||||
newInfo,
|
||||
]));
|
||||
});
|
||||
|
||||
it('rejects update requests that change the topic.', async(): Promise<void> => {
|
||||
await storage.add(info);
|
||||
const newInfo = {
|
||||
...info,
|
||||
topic: 'http://example.com/other',
|
||||
};
|
||||
await expect(storage.update(newInfo)).rejects.toThrow(`Trying to change the topic of subscription ${info.id}`);
|
||||
});
|
||||
|
||||
it('rejects update request targeting a non-info value.', async(): Promise<void> => {
|
||||
await storage.add(info);
|
||||
// Looking for the key so this test doesn't depend on the internal keys used
|
||||
const id = [ ...internalMap.entries() ].find((entry): boolean => Array.isArray(entry[1]))![0];
|
||||
const newInfo = {
|
||||
...info,
|
||||
id,
|
||||
};
|
||||
await expect(storage.update(newInfo)).rejects.toThrow(`Trying to update ${id} which is not a SubscriptionInfo.`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#delete', (): void => {
|
||||
it('removes the info and its reference.', async(): Promise<void> => {
|
||||
const info2 = {
|
||||
...info,
|
||||
id: 'differentId',
|
||||
};
|
||||
await storage.add(info);
|
||||
await storage.add(info2);
|
||||
expect(internalMap.size).toBe(3);
|
||||
await expect(storage.delete(info.id)).resolves.toBeUndefined();
|
||||
expect(internalMap.size).toBe(2);
|
||||
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
||||
[ info2.id ],
|
||||
info2,
|
||||
]));
|
||||
});
|
||||
|
||||
it('removes the references for an identifier if the array is empty.', async(): Promise<void> => {
|
||||
await storage.add(info);
|
||||
await expect(storage.delete(info.id)).resolves.toBeUndefined();
|
||||
expect(internalMap.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does nothing if the target does not exist.', async(): Promise<void> => {
|
||||
await expect(storage.delete(info.id)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('logs an error if the target can not be found in the list of references.', async(): Promise<void> => {
|
||||
await storage.add(info);
|
||||
// Looking for the key so this test doesn't depend on the internal keys used
|
||||
const id = [ ...internalMap.entries() ].find((entry): boolean => Array.isArray(entry[1]))![0];
|
||||
internalMap.set(id, []);
|
||||
await expect(storage.delete(info.id)).resolves.toBeUndefined();
|
||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
108
test/unit/server/notifications/ListeningActivityHandler.test.ts
Normal file
108
test/unit/server/notifications/ListeningActivityHandler.test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
||||
import type { Logger } from '../../../../src/logging/Logger';
|
||||
import { getLoggerFor } from '../../../../src/logging/LogUtil';
|
||||
import type { ActivityEmitter } from '../../../../src/server/notifications/ActivityEmitter';
|
||||
import { ListeningActivityHandler } from '../../../../src/server/notifications/ListeningActivityHandler';
|
||||
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
|
||||
import type { SubscriptionInfo, SubscriptionStorage } from '../../../../src/server/notifications/SubscriptionStorage';
|
||||
import { AS } from '../../../../src/util/Vocabularies';
|
||||
import { flushPromises } from '../../../util/Util';
|
||||
|
||||
jest.mock('../../../../src/logging/LogUtil', (): any => {
|
||||
const logger: Logger = { error: jest.fn() } as any;
|
||||
return { getLoggerFor: (): Logger => logger };
|
||||
});
|
||||
|
||||
describe('A ListeningActivityHandler', (): void => {
|
||||
const logger: jest.Mocked<Logger> = getLoggerFor('mock') as any;
|
||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
const activity = AS.terms.Update;
|
||||
let info: SubscriptionInfo;
|
||||
let storage: jest.Mocked<SubscriptionStorage>;
|
||||
let emitter: ActivityEmitter;
|
||||
let notificationHandler: jest.Mocked<NotificationHandler>;
|
||||
let handler: ListeningActivityHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
jest.clearAllMocks();
|
||||
info = {
|
||||
id: 'id',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
|
||||
storage = {
|
||||
getAll: jest.fn().mockResolvedValue([ info.id ]),
|
||||
get: jest.fn().mockResolvedValue(info),
|
||||
} as any;
|
||||
|
||||
emitter = new EventEmitter() as any;
|
||||
|
||||
notificationHandler = {
|
||||
handleSafe: jest.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
handler = new ListeningActivityHandler(storage, emitter, notificationHandler);
|
||||
});
|
||||
|
||||
it('calls the NotificationHandler if there is an event.', async(): Promise<void> => {
|
||||
emitter.emit('changed', topic, activity);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ info, activity, topic });
|
||||
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not emit an event on subscriptions if their rate does not yet allow it.', async(): Promise<void> => {
|
||||
info.rate = 100000;
|
||||
info.lastEmit = Date.now();
|
||||
|
||||
emitter.emit('changed', topic, activity);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(0);
|
||||
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not stop if one subscription causes an error.', async(): Promise<void> => {
|
||||
storage.getAll.mockResolvedValue([ info.id, info.id ]);
|
||||
notificationHandler.handleSafe.mockRejectedValueOnce(new Error('bad input'));
|
||||
|
||||
emitter.emit('changed', topic, activity);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(2);
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenLastCalledWith(`Error trying to handle notification for ${info.id}: bad input`);
|
||||
});
|
||||
|
||||
it('logs an error if something goes wrong handling the event.', async(): Promise<void> => {
|
||||
storage.getAll.mockRejectedValue(new Error('bad event'));
|
||||
|
||||
emitter.emit('changed', topic, activity);
|
||||
|
||||
await flushPromises();
|
||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(0);
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenLastCalledWith(`Something went wrong emitting notifications: bad event`);
|
||||
});
|
||||
|
||||
it('ignores undefined subscriptions.', async(): Promise<void> => {
|
||||
storage.get.mockResolvedValue(undefined);
|
||||
|
||||
emitter.emit('changed', topic, activity);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(0);
|
||||
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
35
test/unit/server/notifications/NotificationDescriber.test.ts
Normal file
35
test/unit/server/notifications/NotificationDescriber.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import 'jest-rdf';
|
||||
import { DataFactory } from 'n3';
|
||||
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
||||
import {
|
||||
AbsolutePathInteractionRoute,
|
||||
} from '../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||
import { NotificationDescriber } from '../../../../src/server/notifications/NotificationDescriber';
|
||||
import { NOTIFY, RDF } from '../../../../src/util/Vocabularies';
|
||||
const { namedNode, quad } = DataFactory;
|
||||
|
||||
describe('A NotificationDescriber', (): void => {
|
||||
const identifier: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
const route = new AbsolutePathInteractionRoute('http://example.com/.notifications/websockets/');
|
||||
const relative = '#websocketNotification';
|
||||
const type = 'http://www.w3.org/ns/solid/notifications#WebSocketSubscription2021';
|
||||
let describer: NotificationDescriber;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
describer = new NotificationDescriber(route, relative, type);
|
||||
});
|
||||
|
||||
it('outputs the expected quads.', async(): Promise<void> => {
|
||||
const subscription = namedNode('http://example.com/foo#websocketNotification');
|
||||
const quads = await describer.handle(identifier);
|
||||
expect(quads).toBeRdfIsomorphic([
|
||||
quad(namedNode(identifier.path), NOTIFY.terms.notificationChannel, subscription),
|
||||
quad(subscription, RDF.terms.type, namedNode(type)),
|
||||
quad(subscription, NOTIFY.terms.subscription, namedNode('http://example.com/.notifications/websockets/')),
|
||||
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.accept),
|
||||
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.expiration),
|
||||
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.rate),
|
||||
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.state),
|
||||
]);
|
||||
});
|
||||
});
|
136
test/unit/server/notifications/NotificationSubscriber.test.ts
Normal file
136
test/unit/server/notifications/NotificationSubscriber.test.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import type { CredentialsExtractor } from '../../../../src/authentication/CredentialsExtractor';
|
||||
import type { Authorizer } from '../../../../src/authorization/Authorizer';
|
||||
import type { PermissionReader } from '../../../../src/authorization/PermissionReader';
|
||||
import type { AccessMap } from '../../../../src/authorization/permissions/Permissions';
|
||||
import { AccessMode } from '../../../../src/authorization/permissions/Permissions';
|
||||
import type { Operation } from '../../../../src/http/Operation';
|
||||
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
|
||||
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
||||
import type { HttpRequest } from '../../../../src/server/HttpRequest';
|
||||
import type { HttpResponse } from '../../../../src/server/HttpResponse';
|
||||
import { NotificationSubscriber } from '../../../../src/server/notifications/NotificationSubscriber';
|
||||
import { SUBSCRIBE_SCHEMA } from '../../../../src/server/notifications/Subscription';
|
||||
import type { SubscriptionType } from '../../../../src/server/notifications/SubscriptionType';
|
||||
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';
|
||||
|
||||
describe('A NotificationSubscriber', (): void => {
|
||||
let subscriptionBody: any;
|
||||
const request: HttpRequest = {} as any;
|
||||
const response: HttpResponse = {} as any;
|
||||
let operation: Operation;
|
||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
let subscriptionType: jest.Mocked<SubscriptionType>;
|
||||
let credentialsExtractor: jest.Mocked<CredentialsExtractor>;
|
||||
let permissionReader: jest.Mocked<PermissionReader>;
|
||||
let authorizer: jest.Mocked<Authorizer>;
|
||||
let subscriber: NotificationSubscriber;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
subscriptionBody = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'SubscriptionType',
|
||||
topic: topic.path,
|
||||
};
|
||||
|
||||
operation = {
|
||||
method: 'POST',
|
||||
target: { path: 'http://example.com/.notifications/websockets/' },
|
||||
body: new BasicRepresentation(JSON.stringify(subscriptionBody), 'application/ld+json'),
|
||||
preferences: {},
|
||||
};
|
||||
|
||||
subscriptionType = {
|
||||
type: 'SubscriptionType',
|
||||
schema: SUBSCRIBE_SCHEMA,
|
||||
extractModes: jest.fn(async(subscription): Promise<AccessMap> =>
|
||||
new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]) as AccessMap),
|
||||
subscribe: jest.fn().mockResolvedValue({ response: new BasicRepresentation(), info: {}}),
|
||||
};
|
||||
|
||||
credentialsExtractor = {
|
||||
handleSafe: jest.fn().mockResolvedValue({ public: {}}),
|
||||
} as any;
|
||||
|
||||
permissionReader = {
|
||||
handleSafe: jest.fn().mockResolvedValue(new IdentifierMap([[ topic, AccessMode.read ]])),
|
||||
} as any;
|
||||
|
||||
authorizer = {
|
||||
handleSafe: jest.fn(),
|
||||
} as any;
|
||||
|
||||
subscriber = new NotificationSubscriber({ subscriptionType, credentialsExtractor, permissionReader, authorizer });
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('returns the representation generated by the subscribe call.', async(): Promise<void> => {
|
||||
const description = await subscriber.handle({ operation, request, response });
|
||||
expect(description.statusCode).toBe(200);
|
||||
const subscribeResult = await subscriptionType.subscribe.mock.results[0].value;
|
||||
expect(description.data).toBe(subscribeResult.response.data);
|
||||
expect(description.metadata).toBe(subscribeResult.response.metadata);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('updates the subscription expiration if a max is defined.', async(): Promise<void> => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime();
|
||||
|
||||
subscriber = new NotificationSubscriber({
|
||||
subscriptionType,
|
||||
credentialsExtractor,
|
||||
permissionReader,
|
||||
authorizer,
|
||||
maxDuration: 60,
|
||||
});
|
||||
|
||||
await subscriber.handle({ operation, request, response });
|
||||
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
expiration: Date.now() + (60 * 60 * 1000),
|
||||
}));
|
||||
|
||||
operation.body.data = guardedStreamFrom(JSON.stringify({
|
||||
...subscriptionBody,
|
||||
expiration: new Date(Date.now() + 99999999999999).toISOString(),
|
||||
}));
|
||||
await subscriber.handle({ operation, request, response });
|
||||
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
expiration: Date.now() + (60 * 60 * 1000),
|
||||
}));
|
||||
|
||||
operation.body.data = guardedStreamFrom(JSON.stringify({
|
||||
...subscriptionBody,
|
||||
expiration: new Date(Date.now() + 5).toISOString(),
|
||||
}));
|
||||
await subscriber.handle({ operation, request, response });
|
||||
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
expiration: Date.now() + 5,
|
||||
}));
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
65
test/unit/server/notifications/Subscription.test.ts
Normal file
65
test/unit/server/notifications/Subscription.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { SUBSCRIBE_SCHEMA } from '../../../../src/server/notifications/Subscription';
|
||||
|
||||
describe('A Subscription', (): void => {
|
||||
const validSubscription = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'SubscriptionType',
|
||||
topic: 'http://example.com/foo',
|
||||
};
|
||||
|
||||
it('requires a minimal set of values.', async(): Promise<void> => {
|
||||
await expect(SUBSCRIBE_SCHEMA.isValid(validSubscription)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('requires the notification context header to be present.', async(): Promise<void> => {
|
||||
let subscription: unknown = {
|
||||
type: 'SubscriptionType',
|
||||
topic: 'http://example.com/foo',
|
||||
};
|
||||
await expect(SUBSCRIBE_SCHEMA.isValid(subscription)).resolves.toBe(false);
|
||||
|
||||
subscription = {
|
||||
'@context': [ 'wrongContext' ],
|
||||
type: 'SubscriptionType',
|
||||
topic: 'http://example.com/foo',
|
||||
};
|
||||
await expect(SUBSCRIBE_SCHEMA.isValid(subscription)).resolves.toBe(false);
|
||||
|
||||
subscription = {
|
||||
'@context': [ 'contextA', 'https://www.w3.org/ns/solid/notification/v1', 'contextB' ],
|
||||
type: 'SubscriptionType',
|
||||
topic: 'http://example.com/foo',
|
||||
};
|
||||
await expect(SUBSCRIBE_SCHEMA.isValid(subscription)).resolves.toBe(true);
|
||||
|
||||
subscription = {
|
||||
'@context': 'https://www.w3.org/ns/solid/notification/v1',
|
||||
type: 'SubscriptionType',
|
||||
topic: 'http://example.com/foo',
|
||||
};
|
||||
await expect(SUBSCRIBE_SCHEMA.isValid(subscription)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('converts the expiration date to a number.', async(): Promise<void> => {
|
||||
const date = '1988-03-09T14:48:00.000Z';
|
||||
const ms = Date.parse(date);
|
||||
|
||||
const subscription: unknown = {
|
||||
...validSubscription,
|
||||
expiration: date,
|
||||
};
|
||||
await expect(SUBSCRIBE_SCHEMA.validate(subscription)).resolves.toEqual(expect.objectContaining({
|
||||
expiration: ms,
|
||||
}));
|
||||
});
|
||||
|
||||
it('converts the rate to a number.', async(): Promise<void> => {
|
||||
const subscription: unknown = {
|
||||
...validSubscription,
|
||||
rate: 'PT10S',
|
||||
};
|
||||
await expect(SUBSCRIBE_SCHEMA.validate(subscription)).resolves.toEqual(expect.objectContaining({
|
||||
rate: 10 * 1000,
|
||||
}));
|
||||
});
|
||||
});
|
@ -0,0 +1,49 @@
|
||||
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
||||
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
|
||||
import type { SubscriptionInfo } from '../../../../src/server/notifications/SubscriptionStorage';
|
||||
import { TypedNotificationHandler } from '../../../../src/server/notifications/TypedNotificationHandler';
|
||||
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
|
||||
|
||||
describe('A TypedNotificationHandler', (): void => {
|
||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
const info: SubscriptionInfo = {
|
||||
id: 'id',
|
||||
topic: topic.path,
|
||||
type: 'SubscriptionType',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
let source: jest.Mocked<NotificationHandler>;
|
||||
let handler: TypedNotificationHandler;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
source = {
|
||||
canHandle: jest.fn(),
|
||||
handle: jest.fn(),
|
||||
handleSafe: jest.fn(),
|
||||
};
|
||||
|
||||
handler = new TypedNotificationHandler(info.type, source);
|
||||
});
|
||||
|
||||
it('requires the input info to have the correct type.', async(): Promise<void> => {
|
||||
await expect(handler.canHandle({ info, topic })).resolves.toBeUndefined();
|
||||
|
||||
const wrongInfo = {
|
||||
...info,
|
||||
type: 'somethingElse',
|
||||
};
|
||||
await expect(handler.canHandle({ info: wrongInfo, topic })).rejects.toThrow(NotImplementedHttpError);
|
||||
});
|
||||
|
||||
it('rejects input the source handler can not handle.', async(): Promise<void> => {
|
||||
source.canHandle.mockRejectedValue(new Error('bad input'));
|
||||
await expect(handler.canHandle({ info, topic })).rejects.toThrow('bad input');
|
||||
});
|
||||
|
||||
it('calls the source handle function.', async(): Promise<void> => {
|
||||
await expect(handler.handle({ info, topic })).resolves.toBeUndefined();
|
||||
expect(source.handle).toHaveBeenCalledTimes(1);
|
||||
expect(source.handle).toHaveBeenLastCalledWith({ info, topic });
|
||||
});
|
||||
});
|
@ -0,0 +1,67 @@
|
||||
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
|
||||
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
|
||||
import {
|
||||
ActivityNotificationGenerator,
|
||||
} from '../../../../../src/server/notifications/generate/ActivityNotificationGenerator';
|
||||
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import type { ResourceStore } from '../../../../../src/storage/ResourceStore';
|
||||
import { AS, DC, LDP, RDF } from '../../../../../src/util/Vocabularies';
|
||||
|
||||
describe('An ActivityNotificationGenerator', (): void => {
|
||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
const info: SubscriptionInfo = {
|
||||
id: 'id',
|
||||
topic: topic.path,
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
const activity = AS.terms.Update;
|
||||
const metadata = new RepresentationMetadata({
|
||||
[RDF.type]: LDP.terms.Resource,
|
||||
// Needed for ETag
|
||||
[DC.modified]: new Date().toISOString(),
|
||||
});
|
||||
let store: jest.Mocked<ResourceStore>;
|
||||
let generator: ActivityNotificationGenerator;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
store = {
|
||||
getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', metadata)),
|
||||
} as any;
|
||||
|
||||
generator = new ActivityNotificationGenerator(store);
|
||||
});
|
||||
|
||||
it('only handles defined activities.', async(): Promise<void> => {
|
||||
await expect(generator.canHandle({ topic, info })).rejects.toThrow('Only defined activities are supported.');
|
||||
await expect(generator.canHandle({ topic, info, activity })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('generates a notification.', async(): Promise<void> => {
|
||||
const date = '1988-03-09T14:48:00.000Z';
|
||||
const ms = Date.parse(date);
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(ms);
|
||||
|
||||
await expect(generator.handle({ topic, info, activity })).resolves.toEqual({
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://www.w3.org/ns/solid/notification/v1',
|
||||
],
|
||||
id: `urn:${ms}:http://example.com/foo`,
|
||||
type: [ 'Update' ],
|
||||
object: {
|
||||
id: 'http://example.com/foo',
|
||||
type: [
|
||||
LDP.Resource,
|
||||
],
|
||||
},
|
||||
state: expect.stringMatching(/"\d+"/u),
|
||||
published: date,
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
@ -0,0 +1,49 @@
|
||||
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
|
||||
import {
|
||||
DeleteNotificationGenerator,
|
||||
} from '../../../../../src/server/notifications/generate/DeleteNotificationGenerator';
|
||||
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import { AS } from '../../../../../src/util/Vocabularies';
|
||||
|
||||
describe('A DeleteNotificationGenerator', (): void => {
|
||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
const info: SubscriptionInfo = {
|
||||
id: 'id',
|
||||
topic: topic.path,
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
const activity = AS.terms.Delete;
|
||||
const generator = new DeleteNotificationGenerator();
|
||||
|
||||
it('can only handle input with the Delete activity.', async(): Promise<void> => {
|
||||
await expect(generator.canHandle({ topic, info })).rejects.toThrow('Only Delete activity updates are supported.');
|
||||
await expect(generator.canHandle({ topic, info, activity: AS.terms.Update }))
|
||||
.rejects.toThrow('Only Delete activity updates are supported.');
|
||||
await expect(generator.canHandle({ topic, info, activity })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('generates a Delete notification.', async(): Promise<void> => {
|
||||
const date = '1988-03-09T14:48:00.000Z';
|
||||
const ms = Date.parse(date);
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(ms);
|
||||
|
||||
await expect(generator.handle({ topic, info, activity })).resolves.toEqual({
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://www.w3.org/ns/solid/notification/v1',
|
||||
],
|
||||
id: `urn:${ms}:http://example.com/foo`,
|
||||
type: [ 'Delete' ],
|
||||
object: {
|
||||
id: 'http://example.com/foo',
|
||||
type: [],
|
||||
},
|
||||
published: date,
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
@ -0,0 +1,71 @@
|
||||
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
|
||||
import type { NotificationGenerator } from '../../../../../src/server/notifications/generate/NotificationGenerator';
|
||||
import {
|
||||
StateNotificationGenerator,
|
||||
} from '../../../../../src/server/notifications/generate/StateNotificationGenerator';
|
||||
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
||||
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import type { ResourceSet } from '../../../../../src/storage/ResourceSet';
|
||||
import { AS } from '../../../../../src/util/Vocabularies';
|
||||
|
||||
describe('A StateNotificationGenerator', (): void => {
|
||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
const info: SubscriptionInfo = {
|
||||
id: 'id',
|
||||
topic: topic.path,
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
const notification: Notification = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://www.w3.org/ns/solid/notification/v1',
|
||||
],
|
||||
id: `urn:123:http://example.com/foo`,
|
||||
type: [ 'Update' ],
|
||||
object: {
|
||||
id: 'http://example.com/foo',
|
||||
type: [],
|
||||
},
|
||||
published: '123',
|
||||
};
|
||||
let source: jest.Mocked<NotificationGenerator>;
|
||||
let resourceSet: jest.Mocked<ResourceSet>;
|
||||
let generator: StateNotificationGenerator;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
source = {
|
||||
handleSafe: jest.fn().mockResolvedValue(notification),
|
||||
} as any;
|
||||
|
||||
resourceSet = {
|
||||
hasResource: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
generator = new StateNotificationGenerator(source, resourceSet);
|
||||
});
|
||||
|
||||
it('returns the source notification if there is an activity.', async(): Promise<void> => {
|
||||
await expect(generator.handle({ topic, info, activity: AS.terms.Update })).resolves.toBe(notification);
|
||||
expect(source.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, info, activity: AS.terms.Update });
|
||||
expect(resourceSet.hasResource).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('calls the source with an Update notification if the topic exists.', async(): Promise<void> => {
|
||||
resourceSet.hasResource.mockResolvedValue(true);
|
||||
await expect(generator.handle({ topic, info })).resolves.toBe(notification);
|
||||
expect(source.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, info, activity: AS.terms.Update });
|
||||
expect(resourceSet.hasResource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls the source with a Delete notification if the topic does not exist.', async(): Promise<void> => {
|
||||
resourceSet.hasResource.mockResolvedValue(false);
|
||||
await expect(generator.handle({ topic, info })).resolves.toBe(notification);
|
||||
expect(source.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, info, activity: AS.terms.Delete });
|
||||
expect(resourceSet.hasResource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
@ -0,0 +1,75 @@
|
||||
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../../../../src/http/representation/Representation';
|
||||
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
||||
import {
|
||||
ConvertingNotificationSerializer,
|
||||
} from '../../../../../src/server/notifications/serialize/ConvertingNotificationSerializer';
|
||||
import type { NotificationSerializer } from '../../../../../src/server/notifications/serialize/NotificationSerializer';
|
||||
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import type { RepresentationConverter } from '../../../../../src/storage/conversion/RepresentationConverter';
|
||||
|
||||
describe('A ConvertingNotificationSerializer', (): void => {
|
||||
let info: SubscriptionInfo;
|
||||
const notification: Notification = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://www.w3.org/ns/solid/notification/v1',
|
||||
],
|
||||
id: `urn:123:http://example.com/foo`,
|
||||
type: [ 'Update' ],
|
||||
object: {
|
||||
id: 'http://example.com/foo',
|
||||
type: [],
|
||||
},
|
||||
published: '123',
|
||||
};
|
||||
let representation: Representation;
|
||||
let source: jest.Mocked<NotificationSerializer>;
|
||||
let converter: jest.Mocked<RepresentationConverter>;
|
||||
let serializer: ConvertingNotificationSerializer;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
info = {
|
||||
id: 'id',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
|
||||
representation = new BasicRepresentation();
|
||||
|
||||
source = {
|
||||
canHandle: jest.fn(),
|
||||
handle: jest.fn().mockResolvedValue(representation),
|
||||
} as any;
|
||||
|
||||
converter = {
|
||||
handleSafe: jest.fn(({ representation: rep }): Representation => rep),
|
||||
} as any;
|
||||
|
||||
serializer = new ConvertingNotificationSerializer(source, converter);
|
||||
});
|
||||
|
||||
it('can handle input its source can handle.', async(): Promise<void> => {
|
||||
await expect(serializer.canHandle({ info, notification })).resolves.toBeUndefined();
|
||||
source.canHandle.mockRejectedValue(new Error('bad input'));
|
||||
await expect(serializer.canHandle({ info, notification })).rejects.toThrow('bad input');
|
||||
});
|
||||
|
||||
it('returns the source result if there is no accept value.', async(): Promise<void> => {
|
||||
await expect(serializer.handle({ info, notification })).resolves.toBe(representation);
|
||||
expect(converter.handleSafe).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('converts the source result if there is an accept value.', async(): Promise<void> => {
|
||||
info.accept = 'text/turtle';
|
||||
await expect(serializer.handle({ info, notification })).resolves.toBe(representation);
|
||||
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(converter.handleSafe).toHaveBeenLastCalledWith({
|
||||
representation,
|
||||
preferences: { type: { 'text/turtle': 1 }},
|
||||
identifier: { path: notification.id },
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,37 @@
|
||||
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
||||
import {
|
||||
JsonLdNotificationSerializer,
|
||||
} from '../../../../../src/server/notifications/serialize/JsonLdNotificationSerializer';
|
||||
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import { readableToString } from '../../../../../src/util/StreamUtil';
|
||||
|
||||
describe('A JsonLdNotificationSerializer', (): void => {
|
||||
const info: SubscriptionInfo = {
|
||||
id: 'id',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
const notification: Notification = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://www.w3.org/ns/solid/notification/v1',
|
||||
],
|
||||
id: `urn:123:http://example.com/foo`,
|
||||
type: [ 'Update' ],
|
||||
object: {
|
||||
id: 'http://example.com/foo',
|
||||
type: [],
|
||||
},
|
||||
published: '123',
|
||||
};
|
||||
|
||||
const serializer = new JsonLdNotificationSerializer();
|
||||
|
||||
it('converts notifications into JSON-LD.', async(): Promise<void> => {
|
||||
const representation = await serializer.handle({ notification, info });
|
||||
expect(representation.metadata.contentType).toBe('application/ld+json');
|
||||
expect(JSON.parse(await readableToString(representation.data))).toEqual(notification);
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user