├── .babelrc ├── .browserslistrc ├── .eslintrc ├── .gitignore ├── .huskyrc ├── .travis.yml ├── .travis_build_pages ├── LICENSE.md ├── README.md ├── browser ├── crypto.js ├── process.js └── setImmediate.js ├── demo ├── .eslintrc ├── profile.html ├── profile.js └── user.html ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── RootPath.js ├── SolidUpdateEngine.js ├── exports │ ├── comunica.js │ └── rdflib.js ├── handlers │ ├── ActivityHandler.js │ ├── CreateActivityHandler.js │ ├── DeleteActivityHandler.js │ ├── FindActivityHandler.js │ ├── PutHandler.js │ ├── SolidDeleteFunctionHandler.js │ ├── SourcePathHandler.js │ ├── UserPathHandler.js │ ├── activity-triples.sparql │ ├── activity.sparql │ └── activity.ttl ├── resolvers │ ├── ContextResolver.js │ └── SubjectPathResolver.js └── util.js ├── test ├── .eslintrc ├── __mocks__ │ ├── solid-auth-client.js │ └── uuid.js ├── assets │ └── http │ │ ├── System-test-PUT_1873382719 │ │ └── PUTting-a-Turtle-document_871210938 │ │ │ └── recording.har │ │ ├── System-test-blank-nodes_2374737181 │ │ └── Alice-s-friends-with-one-expression_87134690 │ │ │ └── recording.har │ │ └── System-test-profile_4125270327 │ │ └── main-profile-fields_2102771806 │ │ └── recording.har ├── system │ ├── blanks-test.js │ ├── profile-test.js │ └── put-test.js ├── unit │ ├── SolidUpdateEngine-test.js │ ├── exports │ │ ├── comunica-test.js │ │ └── rdflib-test.js │ ├── handlers │ │ ├── ActivityHandler-test.js │ │ ├── CreateActivityHandler-test.js │ │ ├── DeleteActivityHandler-test.js │ │ ├── FindActivityHandler-test.js │ │ ├── PutHandler-test.js │ │ ├── SolidDeleteFunctionHandler-test.js │ │ └── SourcePathHandler-test.js │ └── resolvers │ │ └── SubjectPathResolver-test.js └── util.js └── webpack ├── webpack.common.config.js ├── webpack.demo.config.js ├── webpack.lib.config.js └── webpack.rdflib.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: [ 3 | ["@babel/preset-env"], 4 | ], 5 | plugins: [ 6 | ["@babel/plugin-proposal-class-properties", { loose: true }], 7 | ["babel-plugin-inline-import", { 8 | extensions: [ 9 | ".ttl", 10 | ".sparql", 11 | ], 12 | }], 13 | ], 14 | 15 | env: { 16 | module: { 17 | presets: [ 18 | ["@babel/preset-env", { modules: false }], 19 | ], 20 | }, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Build for Node 2 | node >=10 3 | 4 | # Build for most browsers 5 | >2% 6 | last 2 Chrome versions 7 | last 2 Firefox versions 8 | last 2 iOS versions 9 | 10 | # Drop browsers without Proxy support 11 | not ie <= 11 12 | not operamini all 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | env: { 3 | es6: true, 4 | node: true, 5 | }, 6 | extends: eslint:recommended, 7 | parser: babel-eslint, 8 | rules: { 9 | // Best Practices 10 | accessor-pairs: error, 11 | array-callback-return: error, 12 | block-scoped-var: error, 13 | class-methods-use-this: off, 14 | complexity: error, 15 | consistent-return: error, 16 | curly: [ error, multi-or-nest, consistent ], 17 | default-case: error, 18 | dot-location: [ error, property ], 19 | dot-notation: error, 20 | eqeqeq: error, 21 | guard-for-in: off, 22 | no-alert: error, 23 | no-caller: error, 24 | no-case-declarations: error, 25 | no-div-regex: error, 26 | no-else-return: error, 27 | no-empty-function: error, 28 | no-empty-pattern: error, 29 | no-eq-null: error, 30 | no-eval: error, 31 | no-extend-native: error, 32 | no-extra-bind: error, 33 | no-extra-label: error, 34 | no-fallthrough: error, 35 | no-floating-decimal: error, 36 | no-global-assign: error, 37 | no-implicit-coercion: off, 38 | no-implicit-globals: error, 39 | no-implied-eval: error, 40 | no-invalid-this: error, 41 | no-iterator: error, 42 | no-labels: error, 43 | no-lone-blocks: error, 44 | no-loop-func: error, 45 | no-magic-numbers: off, 46 | no-multi-spaces: error, 47 | no-multi-str: error, 48 | no-new: error, 49 | no-new-func: error, 50 | no-new-wrappers: error, 51 | no-octal: error, 52 | no-octal-escape: error, 53 | no-param-reassign: off, 54 | no-proto: error, 55 | no-redeclare: error, 56 | no-restricted-properties: error, 57 | no-return-assign: error, 58 | no-return-await: error, 59 | no-script-url: error, 60 | no-self-assign: error, 61 | no-self-compare: error, 62 | no-sequences: error, 63 | no-throw-literal: error, 64 | no-unmodified-loop-condition: error, 65 | no-unused-expressions: error, 66 | no-unused-labels: error, 67 | no-useless-call: error, 68 | no-useless-concat: error, 69 | no-useless-escape: error, 70 | no-useless-return: error, 71 | no-void: error, 72 | no-warning-comments: off, 73 | no-with: error, 74 | prefer-promise-reject-errors: error, 75 | radix: error, 76 | require-await: off, 77 | vars-on-top: off, 78 | wrap-iife: error, 79 | 80 | // Strict Mode 81 | strict: [ error, never ], 82 | 83 | // Variables 84 | init-declarations: off, 85 | no-catch-shadow: error, 86 | no-delete-var: error, 87 | no-label-var: error, 88 | no-restricted-globals: error, 89 | no-shadow: error, 90 | no-shadow-restricted-names: error, 91 | no-undef: error, 92 | no-undef-init: error, 93 | no-undefined: off, 94 | no-unused-vars: error, 95 | no-use-before-define: [ error, { functions: false } ], 96 | 97 | // Node.js 98 | callback-return: error, 99 | global-require: error, 100 | handle-callback-err: error, 101 | no-buffer-constructor: error, 102 | no-mixed-requires: error, 103 | no-new-require: error, 104 | no-path-concat: error, 105 | no-process-env: error, 106 | no-process-exit: error, 107 | no-restricted-modules: error, 108 | no-sync: error, 109 | 110 | // Stylistic Issues 111 | array-bracket-newline: [ error, consistent ], 112 | array-bracket-spacing: error, 113 | array-element-newline: off, 114 | block-spacing: error, 115 | brace-style: [ error, stroustrup, { allowSingleLine: true } ], 116 | camelcase: error, 117 | capitalized-comments: off, 118 | comma-dangle: [ error, always-multiline ], 119 | comma-spacing: error, 120 | comma-style: error, 121 | computed-property-spacing: error, 122 | consistent-this: [ error, self ], 123 | eol-last: error, 124 | func-call-spacing: error, 125 | func-name-matching: error, 126 | func-names: off, 127 | func-style: [ error, declaration ], 128 | function-paren-newline: off, 129 | id-blacklist: error, 130 | id-length: off, 131 | id-match: error, 132 | implicit-arrow-linebreak: off, 133 | indent: [ error, 2 ], 134 | jsx-quotes: error, 135 | key-spacing: [ error, { mode: minimum } ], 136 | keyword-spacing: error, 137 | line-comment-position: off, 138 | linebreak-style: error, 139 | lines-around-comment: [ error, { allowBlockStart: true } ], 140 | lines-between-class-members: error, 141 | max-depth: error, 142 | max-len: off, 143 | max-lines: off, 144 | max-nested-callbacks: error, 145 | max-params: error, 146 | max-statements: off, 147 | max-statements-per-line: error, 148 | multiline-comment-style: [ error, separate-lines ], 149 | multiline-ternary: off, 150 | new-cap: error, 151 | new-parens: error, 152 | newline-per-chained-call: off, 153 | no-array-constructor: error, 154 | no-bitwise: error, 155 | no-continue: error, 156 | no-inline-comments: off, 157 | no-lonely-if: error, 158 | no-mixed-operators: error, 159 | no-mixed-spaces-and-tabs: error, 160 | no-multi-assign: off, 161 | no-multiple-empty-lines: error, 162 | no-negated-condition: off, 163 | no-nested-ternary: error, 164 | no-new-object: error, 165 | no-plusplus: off, 166 | no-restricted-syntax: error, 167 | no-tabs: error, 168 | no-ternary: off, 169 | no-trailing-spaces: error, 170 | no-underscore-dangle: off, 171 | no-unneeded-ternary: error, 172 | no-whitespace-before-property: error, 173 | nonblock-statement-body-position: [ error, below ], 174 | object-curly-newline: off, 175 | object-curly-spacing: [ error, always ], 176 | object-property-newline: off, 177 | one-var: off, 178 | one-var-declaration-per-line: off, 179 | operator-assignment: error, 180 | operator-linebreak: [ error, after, { overrides: { ":": after } } ], 181 | padded-blocks: [ error, never ], 182 | padding-line-between-statements: error, 183 | quote-props: [ error, consistent-as-needed ], 184 | quotes: [ error, single, { avoidEscape: true } ], 185 | require-jsdoc: off, 186 | semi: error, 187 | semi-spacing: error, 188 | semi-style: error, 189 | sort-keys: off, 190 | sort-vars: off, 191 | space-before-blocks: error, 192 | space-before-function-paren: [ error, { anonymous: always, named: never } ], 193 | space-in-parens: error, 194 | space-infix-ops: error, 195 | space-unary-ops: error, 196 | spaced-comment: error, 197 | switch-colon-spacing: error, 198 | template-tag-spacing: error, 199 | unicode-bom: error, 200 | wrap-regex: off, 201 | 202 | // ECMAScript 6 203 | arrow-body-style: error, 204 | arrow-parens: [ error, as-needed ], 205 | arrow-spacing: error, 206 | constructor-super: error, 207 | generator-star-spacing: off, 208 | no-class-assign: error, 209 | no-confusing-arrow: off, 210 | no-const-assign: error, 211 | no-dupe-class-members: error, 212 | no-duplicate-imports: error, 213 | no-new-symbol: error, 214 | no-restricted-imports: error, 215 | no-this-before-super: error, 216 | no-useless-computed-key: error, 217 | no-useless-constructor: error, 218 | no-useless-rename: error, 219 | no-var: error, 220 | object-shorthand: error, 221 | prefer-arrow-callback: error, 222 | prefer-const: error, 223 | prefer-destructuring: [ error, { array: false, object: true } ], 224 | prefer-numeric-literals: error, 225 | prefer-rest-params: error, 226 | prefer-spread: error, 227 | prefer-template: error, 228 | require-yield: off, 229 | rest-spread-spacing: error, 230 | sort-imports: off, 231 | symbol-description: error, 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | lib 4 | module 5 | node_modules 6 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "npm test" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | - 11 5 | - 12 6 | - 13 7 | - 14 8 | - lts/* 9 | - node 10 | env: 11 | - DEFAULT_NODE_VERSION=12 12 | 13 | before_script: 14 | - npm run build 15 | 16 | after_success: 17 | - if [ "$TRAVIS_NODE_VERSION" == "$DEFAULT_NODE_VERSION" ]; then 18 | npm install coveralls; 19 | node_modules/.bin/jest --coverage --coverageReporters=text-lcov | 20 | node_modules/.bin/coveralls; 21 | ./.travis_build_pages; 22 | fi 23 | 24 | cache: npm 25 | -------------------------------------------------------------------------------- /.travis_build_pages: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | REPO_NAME="solid/query-ldflex" 3 | PUBLICATION_BRANCH=gh-pages 4 | 5 | # Only publish from the main repository's master branch 6 | if [ "$TRAVIS_REPO_SLUG" != "$REPO_NAME" ] || [ "$TRAVIS_BRANCH" != "master" ] || [ "$TRAVIS_PULL_REQUEST" != "false" ]; then exit; fi 7 | echo -e "Building $PUBLICATION_BRANCH...\n" 8 | 9 | # Checkout the branch 10 | REPO_PATH=$PWD 11 | pushd $HOME 12 | git clone --quiet --depth=1 --branch=$PUBLICATION_BRANCH https://${GH_TOKEN}@github.com/$REPO_NAME publish 2>&1 > /dev/null 13 | cd publish 14 | 15 | # Don't update if already at the latest version 16 | if [[ `git log -1 --pretty=%B` == *$TRAVIS_COMMIT* ]]; then exit; fi 17 | 18 | # Update pages 19 | rm -r * 2> /dev/null 20 | cp -r $REPO_PATH/{README.md,LICENSE.md,dist,demo} . 21 | 22 | # Commit and push latest version 23 | git add . 24 | git config user.name "Travis" 25 | git config user.email "travis@travis-ci.org" 26 | git commit -m "Update to $TRAVIS_COMMIT." 27 | git push -fq origin $PUBLICATION_BRANCH 2>&1 > /dev/null 28 | popd 29 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright ©2018–present Ruben Verborgh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included 13 | in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY 19 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 20 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LDflex for Solid 2 | Simple access to data in [Solid](https://solidproject.org/) pods 3 | through [LDflex](https://github.com/RubenVerborgh/LDflex) expressions 4 | 5 | [![npm version](https://img.shields.io/npm/v/@solid/query-ldflex.svg)](https://www.npmjs.com/package/@solid/query-ldflex) 6 | [![Build Status](https://travis-ci.com/solid/query-ldflex.svg?branch=master)](https://travis-ci.com/solid/query-ldflex) 7 | [![Coverage Status](https://coveralls.io/repos/github/solid/query-ldflex/badge.svg?branch=master)](https://coveralls.io/github/solid/query-ldflex?branch=master) 8 | [![Dependency Status](https://david-dm.org/solid/query-ldflex.svg)](https://david-dm.org/solid/query-ldflex) 9 | 10 | This library is a _configuration_ of 11 | the [LDflex](https://github.com/RubenVerborgh/LDflex) language 12 | for the Solid ecosystem. 13 | It configures LDflex with: 14 | 15 | 1. a [JSON-LD context for Solid](https://github.com/solid/context) 16 | 2. a Solid-authenticated query engine ([Comunica](https://github.com/RubenVerborgh/LDflex-Comunica) or [rdflib.js](https://github.com/LDflex/LDflex-rdflib/)) 17 | 3. useful [data paths](#data-paths) for Solid 18 | 19 | LDflex expressions occur for example 20 | on [Solid React components](https://github.com/solid/react-components), 21 | where they make it easy for developers 22 | to specify what data they want to show. 23 | They can also be used as an expression language 24 | in any other Solid project or framework. 25 | 26 | ## Creating data paths 27 | Once you [obtain the `solid.data` object](#usage), 28 | start writing data paths from the following entry points. 29 | 30 | ### The `user` entry point 31 | The `solid.data.user` path can query data about the currently logged in user, 32 | such as: 33 | - `solid.data.user.firstName` yields the user's first name(s) 34 | - `solid.data.user.email` yields the user's email address(es) 35 | - `solid.data.user.friends` yields the user's friend(s) 36 | - `solid.data.user.friends.firstName` yields the user's friends' first name(s) 37 | 38 | ### The _any URL_ entry point 39 | The `solid.data[url]` path can query data about any subject by URL, 40 | such as: 41 | - `solid.data['https://ruben.verborgh.org/profile/#me'].firstName` 42 | - `solid.data['https://ruben.verborgh.org/profile/#me'].email` 43 | - `solid.data['https://ruben.verborgh.org/profile/#me'].friends` 44 | - `solid.data['https://ruben.verborgh.org/profile/#me'].friends.firstName` 45 | 46 | ### Specifying properties 47 | As you can see in the above examples, 48 | an LDflex path starts with an entry point 49 | and is followed by property names, 50 | which can be: 51 | 52 | - **abbreviations** 53 | such as `firstName` 54 | (which expands to `http://xmlns.com/foaf/0.1/givenName`) 55 | - **prefixed names** 56 | such as `foaf:givenName` 57 | (which expands to `http://xmlns.com/foaf/0.1/givenName`) 58 | - **full URLs** 59 | such as `http://xmlns.com/foaf/0.1/givenName` 60 | 61 | The abbreviations and prefixed names are expanded 62 | using a [JSON-LD context](https://github.com/solid/context/blob/master/context.json). 63 | You can find some inspiration about what to ask for in this context. 64 | 65 | You can access data using any vocabulary you want 66 | and, when included in the JSON-LD context, in multiple ways. 67 | For example: 68 | - FOAF: 69 | - `solid.data.user.name` 70 | - `solid.data.user.foaf_name` 71 | - `solid.data.user.foaf$name` 72 | - `solid.data.user['foaf:name']` 73 | - `solid.data.user['http://xmlns.com/foaf/0.1/name']` 74 | - vCard: 75 | - `solid.data.user.vcard_fn` 76 | - `solid.data.user.vcard$fn` 77 | - `solid.data.user['vcard:fn']` 78 | - `solid.data.user['http://www.w3.org/2006/vcard/ns#fn']` 79 | - Schema.org: 80 | - `solid.data.user.schema_name` 81 | - `solid.data.user.schema$name` 82 | - `solid.data.user['schema:name']` 83 | - `solid.data.user['http://www.schema.org/name']` 84 | - Custom: 85 | - `solid.data.user['http://example.org/my-ontology/name']` 86 | 87 | The traditional colon syntax for prefixes (`schema:name`) 88 | can be substituted with an underscore (`schema_name`) 89 | or dollar sign (`schema$name`). 90 | This is because JavaScript keys with a colon 91 | require quotes (`user['schema:name']`) 92 | whereas underscores and dollar signs 93 | can be used freely (`user.schema_name`, `user.schema$name`). 94 | 95 | 96 | ## Installation 97 | ```bash 98 | npm install @solid/query-ldflex 99 | ``` 100 | 101 | ## Usage 102 | ### Within Node.js environments 103 | ```javascript 104 | const { default: data } = require('@solid/query-ldflex'); 105 | 106 | const ruben = data['https://ruben.verborgh.org/profile/#me']; 107 | showProfile(ruben); 108 | 109 | async function showProfile(person) { 110 | const label = await person.label; 111 | console.log(`\nNAME: ${label}`); 112 | 113 | console.log('\nTYPES'); 114 | for await (const type of person.type) 115 | console.log(` - ${type}`); 116 | 117 | console.log('\nFRIENDS'); 118 | for await (const name of person.friends.firstName) 119 | console.log(` - ${name} is a friend`); 120 | } 121 | 122 | ``` 123 | 124 | If, instead of Comunica, 125 | you want to use the rdflib.js query engine, 126 | install `@ldflex/rdflib` as a dependency of your project 127 | and use 128 | 129 | ```javascript 130 | const { default: data } = require('@solid/query-ldflex/lib/exports/rdflib'); 131 | ``` 132 | 133 | When creating browser builds, 134 | it can be easier to simply tell webpack 135 | to replace `@ldflex/comunica` by `@ldflex/rdflib`. 136 | 137 | 138 | ### In the browser 139 | ```html 140 | 141 | 142 | ``` 143 | 144 | ```javascript 145 | document.addEventListener('DOMContentLoaded', async () => { 146 | const user = solid.data.user; 147 | alert(`Welcome, ${await user.firstName}!`); 148 | }); 149 | 150 | ``` 151 | 152 | To replace Comunica by rdflib.js, 153 | opt for 154 | 155 | ```html 156 | 157 | 158 | 159 | ``` 160 | 161 | ### Adding a custom JSON-LD context 162 | In addition to the [default properties](https://github.com/solid/context/blob/master/context.json), 163 | you might want to support your own: 164 | 165 | ```javascript 166 | console.log(solid.data.context); // the raw default JSON-LD context 167 | await solid.data.context.extend({ // add new JSON-LD context 168 | con: 'http://www.w3.org/2000/10/swap/pim/contact#', 169 | preferred: 'con:preferredURI', 170 | }); 171 | console.log(await solid.data.context); // the expanded JSON-LD context 172 | 173 | // Now we can use both existing and new properties 174 | const ruben = solid.data['https://ruben.verborgh.org/profile/#me']; 175 | console.log(await ruben.name); 176 | console.log(await ruben.preferred); 177 | ``` 178 | 179 | Be aware though that this leads to expressions that are less portable, 180 | because they only work when the additional context is added. 181 | 182 | ### Resolving string expressions 183 | LDflex expressions are actual JavaScript—not strings. 184 | There are times when strings are more useful though, 185 | such as when building 186 | [declarative components that take LDflex expressions](https://github.com/solid/react-components). 187 | 188 | The `solid.data` object exports a `resolve` interface 189 | that transforms a string expression into an actual LDflex path. 190 | This string is appended to `solid.data` to obtain the resulting path. 191 | For example: 192 | - `solid.data.resolve('.user.firstName')` becomes the path `solid.data.user.firstName` 193 | - `solid.data.resolve('["https://example.org/"].label')` becomes the path `solid.data["https://example.org/"].label` 194 | 195 | For convenience, the starting dot 196 | and quotes inside of brackets can be omitted. 197 | If the path is a single URL, 198 | quotes and brackets can be omitted. 199 | The following strings will all resolve: 200 | - `'.user.firstName'` 201 | - `'user.firstName'` 202 | - `'["https://example.org/"].label'` 203 | - `'[https://example.org/].label'` 204 | - `'https://example.org/'` 205 | 206 | ## License 207 | ©2018–present [Ruben Verborgh](https://ruben.verborgh.org/), 208 | [MIT License](https://github.com/solid/query-ldflex/blob/master/LICENSE.md). 209 | -------------------------------------------------------------------------------- /browser/crypto.js: -------------------------------------------------------------------------------- 1 | /*! @license MIT ©2015-2019 Ruben Verborgh, Ghent University - imec */ 2 | /* Browser replacement for a subset of crypto. */ 3 | 4 | exports.getHashes = () => ['sha1']; 5 | 6 | exports.createHash = () => { 7 | let contents; 8 | return { 9 | update: c => contents ? (contents += c) : (contents = c), 10 | digest: () => sha1(contents), 11 | }; 12 | }; 13 | 14 | /*! @license MIT ©2002-2014 Chris Veness */ 15 | /* SHA-1 implementation */ 16 | 17 | // constants [§4.2.1] 18 | const K = [0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xca62c1d6]; 19 | const pow2to35 = Math.pow(2, 35); 20 | 21 | /** 22 | * Generates SHA-1 hash of string. 23 | * 24 | * @param {string} msg - (Unicode) string to be hashed. 25 | * @returns {string} Hash of msg as hex character string. 26 | */ 27 | function sha1(msg = '') { 28 | // PREPROCESSING 29 | msg += '\u0080'; // add trailing '1' bit (+ 0's padding) to string [§5.1.1] 30 | 31 | // convert string msg into 512-bit/16-integer blocks arrays of ints [§5.2.1] 32 | const length = msg.length; 33 | const l = length / 4 + 2; // length (in 32-bit integers) of msg + ‘1’ + appended length 34 | const N = ~~((l + 15) / 16); // number of 16-integer-blocks required to hold 'l' ints 35 | const M = new Array(N); 36 | 37 | for (let i = 0, index = 0; i < N; i++) { 38 | M[i] = new Array(16); 39 | for (let j = 0; j < 16; j++, index++) { // encode 4 chars per integer, big-endian encoding 40 | M[i][j] = (index < length ? msg.charCodeAt(index) << 24 : 0) | 41 | (++index < length ? msg.charCodeAt(index) << 16 : 0) | 42 | (++index < length ? msg.charCodeAt(index) << 8 : 0) | 43 | (++index < length ? msg.charCodeAt(index) : 0); 44 | } 45 | } 46 | // add length (in bits) into final pair of 32-bit integers (big-endian) [§5.1.1] 47 | // note: most significant word would be (len-1)*8 >>> 32, but since JS converts 48 | // bitwise-op args to 32 bits, we need to simulate this by arithmetic operators 49 | M[N - 1][14] = ~~((length - 1) / pow2to35); 50 | M[N - 1][15] = ((length - 1) * 8) & 0xffffffff; 51 | 52 | // set initial hash value [§5.3.1] 53 | let H0 = 0x67452301, H1 = 0xefcdab89, H2 = 0x98badcfe, H3 = 0x10325476, H4 = 0xc3d2e1f0; 54 | 55 | // HASH COMPUTATION [§6.1.2] 56 | const W = new Array(80); 57 | for (let i = 0; i < N; i++) { 58 | // 1 - prepare message schedule 'W' 59 | for (let t = 0; t < 16; t++) 60 | W[t] = M[i][t]; 61 | for (let t = 16; t < 80; t++) 62 | W[t] = rotl(W[t - 3] ^ W[t - 8] ^ W[t - 14] ^ W[t - 16], 1); 63 | 64 | // 2 - initialise five working variables a, b, c, d, e with previous hash value 65 | let a = H0, b = H1, c = H2, d = H3, e = H4; 66 | 67 | // 3 - main loop 68 | for (let t = 0; t < 80; t++) { 69 | const s = ~~(t / 20); // seq for blocks of 'f' functions and 'K' constants 70 | const T = (rotl(a, 5) + f(s, b, c, d) + e + K[s] + W[t]) & 0xffffffff; 71 | e = d; 72 | d = c; 73 | c = rotl(b, 30); 74 | b = a; 75 | a = T; 76 | } 77 | 78 | // 4 - compute the new intermediate hash value (note 'addition modulo 2^32') 79 | H0 = (H0 + a) & 0xffffffff; 80 | H1 = (H1 + b) & 0xffffffff; 81 | H2 = (H2 + c) & 0xffffffff; 82 | H3 = (H3 + d) & 0xffffffff; 83 | H4 = (H4 + e) & 0xffffffff; 84 | } 85 | 86 | return toHexStr(H0) + toHexStr(H1) + toHexStr(H2) + toHexStr(H3) + toHexStr(H4); 87 | } 88 | 89 | /** 90 | * Function 'f' [§4.1.1]. 91 | */ 92 | function f(s, x, y, z) { 93 | switch (s) { 94 | case 0: 95 | return (x & y) ^ (~x & z); // Ch() 96 | case 1: 97 | return x ^ y ^ z; // Parity() 98 | case 2: 99 | return (x & y) ^ (x & z) ^ (y & z); // Maj() 100 | case 3: 101 | return x ^ y ^ z; // Parity() 102 | } 103 | } 104 | 105 | /** 106 | * Rotates left (circular left shift) value x by n positions [§3.2.5]. 107 | */ 108 | function rotl(x, n) { 109 | return (x << n) | (x >>> (32 - n)); 110 | } 111 | 112 | /** 113 | * Hexadecimal representation of a number. 114 | */ 115 | function toHexStr(n) { 116 | // note can't use toString(16) as it is implementation-dependent, 117 | // and in IE returns signed numbers when used on full words 118 | let s = ''; 119 | for (let i = 7; i >= 0; i--) { 120 | const v = (n >>> (i * 4)) & 0xf; 121 | s += v.toString(16); 122 | } 123 | return s; 124 | } 125 | -------------------------------------------------------------------------------- /browser/process.js: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/defunctzombie/node-process 2 | // Copyright (c) 2013 Roman Shtylman 3 | 4 | // Changes: 5 | // - use setImmediate for process.nextTick 6 | 7 | var process = module.exports = {}; 8 | 9 | process.nextTick = setImmediate; 10 | 11 | process.title = 'browser'; 12 | process.browser = true; 13 | process.env = {}; 14 | process.argv = []; 15 | process.version = ''; // empty string to avoid regexp issues 16 | process.versions = {}; 17 | 18 | function noop() {} 19 | 20 | process.on = noop; 21 | process.addListener = noop; 22 | process.once = noop; 23 | process.off = noop; 24 | process.removeListener = noop; 25 | process.removeAllListeners = noop; 26 | process.emit = noop; 27 | process.prependListener = noop; 28 | process.prependOnceListener = noop; 29 | 30 | process.listeners = function (name) { return [] } 31 | 32 | process.binding = function (name) { 33 | throw new Error('process.binding is not supported'); 34 | }; 35 | 36 | process.cwd = function () { return '/' }; 37 | process.chdir = function (dir) { 38 | throw new Error('process.chdir is not supported'); 39 | }; 40 | process.umask = function() { return 0; }; 41 | -------------------------------------------------------------------------------- /browser/setImmediate.js: -------------------------------------------------------------------------------- 1 | // Install a promised-based shim for setImmediate 2 | const resolved = Promise.resolve(null); 3 | window.setImmediate = function setImmediatePromiseShim(callback, ...args) { 4 | resolved.then(args.length === 0 ? callback : () => callback(...args)); 5 | } 6 | -------------------------------------------------------------------------------- /demo/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | rules: { 3 | no-console: off, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /demo/profile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LDflex querying for Solid 6 | 7 | 8 | 9 |

Profile

10 |

Name

11 |
12 |

Types

13 |
14 |

Friends

15 |
16 | 17 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /demo/profile.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { default: data } = require('../lib'); 3 | 4 | const ruben = data['https://ruben.verborgh.org/profile/#me']; 5 | showProfile(ruben); 6 | 7 | async function showProfile(person) { 8 | const label = await person.label; 9 | console.log(`\nNAME: ${label}`); 10 | 11 | console.log('\nTYPES'); 12 | for await (const type of person.type) 13 | console.log(` - ${type}`); 14 | 15 | console.log('\nFRIENDS'); 16 | for await (const name of person.friends.firstName) 17 | console.log(` - ${name} is a friend`); 18 | } 19 | -------------------------------------------------------------------------------- /demo/user.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LDflex querying for Solid 6 | 7 | 8 | 9 | 10 | 11 |

Your profile

12 | 13 | 14 |

Personal details

15 |
16 |
Name
17 |
18 |
First
19 |
20 |
Last
21 |
22 |
Birthday
23 |
24 |
Homepage
25 |
26 |
27 | 28 |

Solid

29 |
30 |
OIDC issuer
31 |
32 |
Storage
33 |
34 |
Inbox
35 |
36 |
Preferences
37 |
38 |
Public type index
39 |
40 |
Private type index
41 |
42 |
43 | 44 |

Friends

45 |
46 | 47 | 48 | 73 | 74 | 75 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | collectCoverage: true, 4 | collectCoverageFrom: [ 5 | "/src/**/*.js", 6 | ], 7 | testMatch: [ 8 | "/test/**/*-test.js", 9 | ], 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@solid/query-ldflex", 3 | "version": "2.11.3", 4 | "description": "Access Solid data pods using the LDflex language", 5 | "author": "Ruben Verborgh (https://ruben.verborgh.org/)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/solid/query-ldflex/" 10 | }, 11 | "main": "lib/exports/comunica.js", 12 | "module": "module/exports/comunica.js", 13 | "sideEffects": false, 14 | "files": [ 15 | "src", 16 | "lib", 17 | "module", 18 | "dist", 19 | "!dist/demo", 20 | "browser", 21 | "webpack", 22 | ".babelrc" 23 | ], 24 | "dependencies": { 25 | "@ldflex/comunica": "^3.4.2", 26 | "@rdfjs/data-model": "^1.1.2", 27 | "@solid/context": "^1.1.0", 28 | "ldflex": "^2.11.1", 29 | "solid-auth-client": "^2.4.1", 30 | "uuid": "^7.0.1" 31 | }, 32 | "devDependencies": { 33 | "@babel/cli": "^7.8.4", 34 | "@babel/core": "^7.8.6", 35 | "@babel/plugin-proposal-class-properties": "^7.8.3", 36 | "@babel/preset-env": "^7.8.6", 37 | "@ldflex/rdflib": "^1.0.0", 38 | "@pollyjs/adapter-node-http": "^4.1.0", 39 | "@pollyjs/core": "^4.1.0", 40 | "@pollyjs/persister-fs": "^4.1.0", 41 | "babel-eslint": "^10.1.0", 42 | "babel-loader": "^8.0.6", 43 | "babel-plugin-inline-import": "^3.0.0", 44 | "copy-webpack-plugin": "^5.1.1", 45 | "eslint": "^6.8.0", 46 | "eslint-plugin-jest": "^23.8.1", 47 | "husky": "^4.2.3", 48 | "jest": "^25.1.0", 49 | "readable-stream": "^3.6.0", 50 | "setup-polly-jest": "^0.7.0", 51 | "solid-auth-cli": "^1.0.15", 52 | "webpack": "^4.41.6", 53 | "webpack-cli": "^3.3.11", 54 | "webpack-dev-server": "^3.10.3" 55 | }, 56 | "scripts": { 57 | "build": "npm run build:lib && npm run build:module && npm run build:dist && npm run build:demo", 58 | "build:demo": "webpack --mode=production --config=./webpack/webpack.demo.config.js", 59 | "build:dist": "npm run build:dist:clean && npm run build:dist:comunica && npm run build:dist:rdflib", 60 | "build:dist:clean": "rm -rf ./dist", 61 | "build:dist:comunica": "webpack --mode=production --config=./webpack/webpack.lib.config.js", 62 | "build:dist:rdflib": "webpack --mode=production --config=./webpack/webpack.rdflib.config.js", 63 | "build:lib": "babel src --out-dir lib --delete-dir-on-start --copy-files", 64 | "build:module": "babel src --env-name module --delete-dir-on-start --out-dir module", 65 | "jest": "jest", 66 | "lint": "eslint src test demo webpack", 67 | "precommit": "npm test", 68 | "prepublishOnly": "npm ci && npm run build", 69 | "start": "npm run start:demo", 70 | "start:demo": "webpack-dev-server --config=./webpack/webpack.demo.config.js", 71 | "test": "npm run lint && npm run jest", 72 | "test:dev": "npm run jest -- --watch" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/RootPath.js: -------------------------------------------------------------------------------- 1 | import { PathFactory, defaultHandlers } from 'ldflex'; 2 | import context from '@solid/context'; 3 | import PutHandler from './handlers/PutHandler'; 4 | import SolidDeleteFunctionHandler from './handlers/SolidDeleteFunctionHandler'; 5 | import FindActivityHandler from './handlers/FindActivityHandler'; 6 | import CreateActivityHandler from './handlers/CreateActivityHandler'; 7 | import DeleteActivityHandler from './handlers/DeleteActivityHandler'; 8 | import SourcePathHandler from './handlers/SourcePathHandler'; 9 | import UserPathHandler from './handlers/UserPathHandler'; 10 | import ContextResolver from './resolvers/ContextResolver'; 11 | import SubjectPathResolver from './resolvers/SubjectPathResolver'; 12 | 13 | const { as } = context['@context']; 14 | const contextResolver = new ContextResolver(context); 15 | 16 | // Handlers for subject paths 17 | const subjectHandlers = { 18 | ...defaultHandlers, 19 | 20 | // HTTP PUT handler 21 | put: new PutHandler(), 22 | 23 | // Custom delete handler to match node-solid-server behavior 24 | delete: new SolidDeleteFunctionHandler(), 25 | 26 | // Find activities 27 | findActivity: new FindActivityHandler(), 28 | likes: (_, path) => path.findActivity(`${as}Like`), 29 | dislikes: (_, path) => path.findActivity(`${as}Dislike`), 30 | follows: (_, path) => path.findActivity(`${as}Follow`), 31 | 32 | // Create activities 33 | createActivity: new CreateActivityHandler(), 34 | like: (_, path) => () => path.createActivity(`${as}Like`), 35 | dislike: (_, path) => () => path.createActivity(`${as}Dislike`), 36 | follow: (_, path) => () => path.createActivity(`${as}Follow`), 37 | 38 | // Delete activities 39 | deleteActivity: new DeleteActivityHandler(), 40 | unlike: (_, path) => () => path.deleteActivity(`${as}Like`), 41 | undislike: (_, path) => () => path.deleteActivity(`${as}Dislike`), 42 | unfollow: (_, path) => () => path.deleteActivity(`${as}Follow`), 43 | }; 44 | 45 | // Creates an LDflex for Solid root path with the given settings 46 | export default function createRootPath(defaultSettings) { 47 | let rootPath = null; 48 | 49 | // Factory for data paths that start from a given subject 50 | const subjectPathFactory = new PathFactory({ 51 | handlers: { ...subjectHandlers, root: () => rootPath }, 52 | resolvers: [contextResolver], 53 | }); 54 | 55 | // Root path that resolves the first property access 56 | rootPath = new PathFactory({ 57 | // Handlers of specific named properties 58 | handlers: { 59 | ...defaultHandlers, 60 | 61 | // The `from` function takes a source as input 62 | from: new SourcePathHandler(subjectPathFactory), 63 | // The `user` property starts a path with the current user as subject 64 | user: new UserPathHandler(subjectPathFactory), 65 | 66 | // Clears the cache for the given document (or everything, if undefined) 67 | clearCache: ({ settings }) => doc => 68 | settings.createQueryEngine().clearCache(doc), 69 | 70 | // Expose the JSON-LD context 71 | context: contextResolver, 72 | }, 73 | // Resolvers for all remaining properties 74 | resolvers: [ 75 | // `data[url]` starts a path with the property as subject 76 | new SubjectPathResolver(subjectPathFactory), 77 | ], 78 | ...defaultSettings, 79 | }).create(); 80 | return rootPath; 81 | } 82 | -------------------------------------------------------------------------------- /src/SolidUpdateEngine.js: -------------------------------------------------------------------------------- 1 | import auth from 'solid-auth-client'; 2 | import { asList } from './util'; 3 | 4 | /** 5 | * A wrapper around a query engine 6 | * that uses Solid authenticated requests for updates. 7 | */ 8 | export default class SolidUpdateEngine { 9 | /** 10 | * Creates a wrapper around the given query engine. 11 | */ 12 | constructor(sources, baseEngine) { 13 | // Preload source but silence errors; they will be thrown during execution 14 | this._source = this.getUpdateSource(sources); 15 | this._source.catch(() => null); 16 | this._engine = baseEngine; 17 | } 18 | 19 | /** 20 | * Creates an asynchronous iterable of results for the given SPARQL query. 21 | */ 22 | async* execute(sparql, sources) { 23 | yield* /^\s*(?:INSERT|DELETE)/i.test(sparql) ? 24 | this.executeUpdate(sparql, sources) : 25 | this._engine.execute(sparql, sources); 26 | } 27 | 28 | /** 29 | * Creates an asynchronous iterable with the results of the SPARQL UPDATE query. 30 | */ 31 | executeUpdate(sparql, sources) { 32 | let done = false; 33 | const next = async () => { 34 | if (done) 35 | return { done }; 36 | done = true; 37 | 38 | // Send authenticated PATCH request to the document 39 | const source = await (sources ? this.getUpdateSource(sources) : this._source); 40 | const { ok, status, statusText } = await auth.fetch(source, { 41 | method: 'PATCH', 42 | headers: { 43 | 'Content-Type': 'application/sparql-update', 44 | }, 45 | body: sparql, 46 | }); 47 | if (!ok) 48 | throw new Error(`Update query failed (${status}): ${statusText}`); 49 | 50 | // Clear stale cached versions of the document 51 | await this.clearCache(source); 52 | 53 | // Return success 54 | return { value: asList({ ok }) }; 55 | }; 56 | return { 57 | next, 58 | return: noop, throw: noop, // required by the interface 59 | [Symbol.asyncIterator]() { return this; }, 60 | }; 61 | } 62 | 63 | /** 64 | * Parses the source(s) into the source to update. 65 | */ 66 | async getUpdateSource(sources) { 67 | let source = await sources; 68 | 69 | // Transform URLs or terms into strings 70 | if (source instanceof URL) 71 | source = source.href; 72 | else if (source && typeof source.value === 'string') 73 | source = source.value; 74 | 75 | // Parse a string URL source 76 | if (typeof source === 'string') { 77 | if (!/^https?:\/\//.test(source)) 78 | throw new Error('Can only update an HTTP(S) document.'); 79 | return source.replace(/#.*/, ''); 80 | } 81 | 82 | // Flatten recursive calls to this function 83 | if (Array.isArray(source)) { 84 | source = await Promise.all(source.map(s => this.getUpdateSource(s))); 85 | source = [].concat(...source).filter(s => !!s); 86 | if (source.length !== 1) 87 | throw new Error('Can only update a single source.'); 88 | return source[0]; 89 | } 90 | 91 | // Error on unsupported sources 92 | throw new Error(`Unsupported source: ${source}`); 93 | } 94 | 95 | /** 96 | * Removes the given document (or all, if not specified) from the cache, 97 | * such that fresh results are obtained next time. 98 | */ 99 | clearCache(document) { 100 | return this._engine.clearCache(document); 101 | } 102 | } 103 | 104 | function noop() { /* empty */ } 105 | -------------------------------------------------------------------------------- /src/exports/comunica.js: -------------------------------------------------------------------------------- 1 | import RootPath from '../RootPath'; 2 | import SolidUpdateEngine from '../SolidUpdateEngine'; 3 | import ComunicaEngine from '@ldflex/comunica'; 4 | 5 | // Export the root path with Comunica as query engine 6 | export default new RootPath({ 7 | createQueryEngine: sources => 8 | new SolidUpdateEngine(sources, new ComunicaEngine(sources)), 9 | }); 10 | -------------------------------------------------------------------------------- /src/exports/rdflib.js: -------------------------------------------------------------------------------- 1 | import RootPath from '../RootPath'; 2 | import SolidUpdateEngine from '../SolidUpdateEngine'; 3 | import RdflibQueryEngine from '@ldflex/rdflib'; 4 | 5 | // Export the root path with rdflib.js as query engine 6 | export default new RootPath({ 7 | createQueryEngine: sources => 8 | new SolidUpdateEngine(sources, new RdflibQueryEngine(sources)), 9 | }); 10 | -------------------------------------------------------------------------------- /src/handlers/ActivityHandler.js: -------------------------------------------------------------------------------- 1 | import { toIterablePromise } from 'ldflex'; 2 | import { namedNode } from '@rdfjs/data-model'; 3 | import context from '@solid/context'; 4 | 5 | const { as } = context['@context']; 6 | 7 | /** 8 | * Base class for handlers that manipulate activities 9 | * Requires: 10 | * - the `root.user` handler 11 | * - the `root[...]` resolver 12 | * - a queryEngine property in the path settings 13 | */ 14 | export default class ActivityHandler { 15 | requireUser = true; 16 | 17 | constructor({ activitiesPath = '/public/activities' } = {}) { 18 | this.activitiesPath = activitiesPath; 19 | } 20 | 21 | handle(pathData, path) { 22 | const self = this; 23 | const { root } = path; 24 | const { settings: { queryEngine } } = pathData; 25 | 26 | // Return an iterator over the activity paths 27 | return (type = `${as}Like`) => toIterablePromise(async function* () { 28 | // Only process activities if a user is logged in 29 | let user; 30 | try { 31 | user = await root.user; 32 | } 33 | catch (error) { 34 | if (self.requireUser) 35 | throw error; 36 | return; 37 | } 38 | 39 | // Determine the storage location 40 | const storage = await root.user.pim$storage; 41 | const document = new URL(self.activitiesPath, storage || user).href; 42 | 43 | // Obtain results for every activity on the path 44 | const results = []; 45 | const actor = namedNode(user); 46 | type = namedNode(type); 47 | for await (const object of path) { 48 | if (object.termType === 'NamedNode') { 49 | const activity = { actor, type, object }; 50 | for await (const result of self.createResults(activity, document, queryEngine)) 51 | results.push(result); 52 | } 53 | } 54 | 55 | // Process all results and return paths starting from the returned terms 56 | for (const term of await self.processResults(results, document, queryEngine)) 57 | yield root[term.value]; 58 | }); 59 | } 60 | 61 | async processResults(results) { 62 | return results; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/handlers/CreateActivityHandler.js: -------------------------------------------------------------------------------- 1 | import ActivityHandler from './ActivityHandler'; 2 | import activityTemplate from './activity.ttl'; 3 | import { replaceVariables } from '../util'; 4 | import { namedNode, literal } from '@rdfjs/data-model'; 5 | import { v4 as uuidv4 } from 'uuid'; 6 | import context from '@solid/context'; 7 | 8 | const { xsd } = context['@context']; 9 | 10 | /** 11 | * Handler that creates an activity in the user's data pod 12 | * Requires: 13 | * - the `root.user` handler 14 | * - the `root[...]` resolver 15 | * - a queryEngine property in the path settings 16 | */ 17 | export default class CreateActivityHandler extends ActivityHandler { 18 | // Creates an activity for insertion in the given document 19 | async* createResults(activity, document) { 20 | const id = namedNode(new URL(`#${uuidv4()}`, document).href); 21 | const published = literal(new Date().toISOString(), `${xsd}dateTime`); 22 | activity = { id, published, ...activity }; 23 | 24 | const insert = replaceVariables(activityTemplate, activity); 25 | yield { id, insert }; 26 | } 27 | 28 | // Inserts the activities into the document 29 | async processResults(results, document, queryEngine) { 30 | const sparql = `INSERT {\n${results.map(r => r.insert).join('')}}`; 31 | await queryEngine.executeUpdate(sparql, document).next(); 32 | return results.map(r => r.id); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/handlers/DeleteActivityHandler.js: -------------------------------------------------------------------------------- 1 | import ActivityHandler from './ActivityHandler'; 2 | import queryTemplate from './activity-triples.sparql'; 3 | import { replaceVariables, termToString } from '../util'; 4 | 5 | const components = ['?subject', '?predicate', '?object']; 6 | 7 | /** 8 | * Handler that deletes an activity in the user's data pod 9 | * Requires: 10 | * - the `root.user` handler 11 | * - the `root[...]` resolver 12 | * - a queryEngine property in the path settings 13 | */ 14 | export default class DeleteActivityHandler extends ActivityHandler { 15 | // Finds activity triples for deletion 16 | async* createResults(activity, document, queryEngine) { 17 | const query = replaceVariables(queryTemplate, activity); 18 | for await (const triple of queryEngine.execute(query, document)) { 19 | const terms = components.map(c => termToString(triple.get(c))); 20 | yield `${terms.join(' ')}.\n`; 21 | } 22 | } 23 | 24 | // Deletes the activity triples from the document 25 | async processResults(results, document, queryEngine) { 26 | const sparql = `DELETE {\n${results.join('')}}`; 27 | await queryEngine.executeUpdate(sparql, document).next(); 28 | return []; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/handlers/FindActivityHandler.js: -------------------------------------------------------------------------------- 1 | import ActivityHandler from './ActivityHandler'; 2 | import queryTemplate from './activity.sparql'; 3 | import { replaceVariables } from '../util'; 4 | 5 | /** 6 | * Handler that finds an activity in the user's data pod 7 | * Requires: 8 | * - the `root.user` handler 9 | * - the `root[...]` resolver 10 | * - a queryEngine property in the path settings 11 | */ 12 | export default class FindActivityHandler extends ActivityHandler { 13 | requireUser = false; 14 | 15 | // Finds all activities in the document matching the given pattern 16 | async* createResults(activity, document, queryEngine) { 17 | const query = replaceVariables(queryTemplate, activity); 18 | for await (const binding of queryEngine.execute(query, document)) 19 | yield binding.values().next().value; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/handlers/PutHandler.js: -------------------------------------------------------------------------------- 1 | import { toIterablePromise } from 'ldflex'; 2 | import auth from 'solid-auth-client'; 3 | 4 | /** 5 | * Creates a document for every result with the given contents. 6 | * Requires: 7 | * - the `root[...]` resolver 8 | */ 9 | export default class PutHandler { 10 | handle(pathData, path) { 11 | const { root } = path; 12 | 13 | // Return an iterator over the created documents 14 | return (body = '', contentType = 'text/turtle') => 15 | toIterablePromise(async function* () { 16 | // Collect all unique URLs from the path 17 | const urls = new Set(); 18 | for await (const result of path) { 19 | const match = /^https?:\/\/[^#]+/.exec(result ? result.value : ''); 20 | if (match) 21 | urls.add(match[0]); 22 | } 23 | 24 | // Create and execute HTTP requests for every URL 25 | const requests = [...urls].map(url => auth.fetch(url, { 26 | method: 'PUT', 27 | headers: { 'Content-Type': contentType }, 28 | body, 29 | })); 30 | await Promise.all(requests); 31 | 32 | // Return paths to the created documents 33 | for (const url of urls) 34 | yield root[url]; 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/handlers/SolidDeleteFunctionHandler.js: -------------------------------------------------------------------------------- 1 | import { DeleteFunctionHandler } from 'ldflex'; 2 | 3 | /** 4 | * node-solid-server deviates from the SPARQL UPDATE spec: 5 | * whereas the spec asks for DELETE on non-existing triples to silently succeed, 6 | * node-solid-server will only DELETE if exactly one triple matches. 7 | * 8 | * This delete handler works around that limitation 9 | * by first requesting all existing values for a path, 10 | * and then only issuing DELETE statements for those that exist. 11 | */ 12 | export default class SolidDeleteFunctionHandler extends DeleteFunctionHandler { 13 | async extractObjects(pathData, path, args) { 14 | // Obtain all values whose deletion was requested 15 | const objects = await super.extractObjects(pathData, path, args); 16 | 17 | // Obtain all values that currently exist 18 | const existing = []; 19 | for await (const term of path) { 20 | if (term.termType !== 'BlankNode') 21 | existing.push(term); 22 | } 23 | 24 | // Perform deletions only for values that exist 25 | return !objects ? existing : existing.filter(e => objects.some(o => o.equals(e))); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/handlers/SourcePathHandler.js: -------------------------------------------------------------------------------- 1 | import { PathFactory, defaultHandlers } from 'ldflex'; 2 | import SubjectPathResolver from '../resolvers/SubjectPathResolver'; 3 | 4 | export default class SourcePathHandler { 5 | constructor(pathFactory) { 6 | this._paths = pathFactory; 7 | } 8 | 9 | handle({ settings }) { 10 | return source => new PathFactory({ 11 | handlers: { ...defaultHandlers }, 12 | resolvers: [new SubjectPathResolver(this._paths, source)], 13 | }).create(settings, {}); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/handlers/UserPathHandler.js: -------------------------------------------------------------------------------- 1 | import SubjectPathResolver from '../resolvers/SubjectPathResolver'; 2 | import auth from 'solid-auth-client'; 3 | import { namedNode } from '@rdfjs/data-model'; 4 | 5 | /** 6 | * Creates a path with the current user as a subject. 7 | */ 8 | export default class UserPathHandler extends SubjectPathResolver { 9 | handle({ settings }) { 10 | const subject = this.getWebId().then(namedNode); 11 | return this._createSubjectPath(subject, settings); 12 | } 13 | 14 | /** Gets the WebID of the logged in user */ 15 | async getWebId() { 16 | const session = await auth.currentSession(); 17 | if (!session) 18 | throw new Error('Cannot resolve user path: no user logged in'); 19 | return session.webId; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/handlers/activity-triples.sparql: -------------------------------------------------------------------------------- 1 | SELECT ?subject ?predicate ?object WHERE { 2 | ?subject a _:type; 3 | _:actor; 4 | _:object. 5 | ?subject ?predicate ?object. 6 | } 7 | -------------------------------------------------------------------------------- /src/handlers/activity.sparql: -------------------------------------------------------------------------------- 1 | SELECT ?activity WHERE { 2 | ?activity a _:type; 3 | _:actor; 4 | _:object. 5 | } 6 | -------------------------------------------------------------------------------- /src/handlers/activity.ttl: -------------------------------------------------------------------------------- 1 | _:id a _:type; 2 | _:actor; 3 | _:object; 4 | _:published. 5 | -------------------------------------------------------------------------------- /src/resolvers/ContextResolver.js: -------------------------------------------------------------------------------- 1 | import { JSONLDResolver } from 'ldflex'; 2 | 3 | /** 4 | * A ContextResolver is a combined resolver/handler that: 5 | * - resolves a JSON-LD context 6 | * - handles by returning an object that 7 | * - is the initial context passed to the constructor 8 | * - allows extending that context by calling `.extend` 9 | * - when `await`ed, resolves to the expanded context 10 | */ 11 | export default class ContextResolver extends JSONLDResolver { 12 | constructor(context) { 13 | super(context); 14 | 15 | // Create an exposed version of the initial context, with additional functionality 16 | const exposedContext = this._exposedContext = Object.create(context['@context']); 17 | // Allow extending the context 18 | Object.defineProperty(exposedContext, 'extend', { 19 | value: (...contexts) => this.extendContext(...contexts), 20 | }); 21 | // Resolve to the expanded context 22 | Object.defineProperty(exposedContext, 'then', { 23 | value: (resolve, reject) => this._context 24 | .then(ctx => ctx.contextRaw).then(resolve, reject), 25 | }); 26 | } 27 | 28 | handle() { 29 | return this._exposedContext; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/resolvers/SubjectPathResolver.js: -------------------------------------------------------------------------------- 1 | import { namedNode } from '@rdfjs/data-model'; 2 | 3 | /** 4 | * LDflex property resolver that returns a new path 5 | * starting from the property name as a subject. 6 | * 7 | * For example, when triggered as 8 | * data['http://person.example/#me'].friends.firstName 9 | * it will create a path with `http://person.example/#me` as subject 10 | * and then resolve `friends` and `firstName` against the JSON-LD context. 11 | * 12 | * In case a source object is given as input, data will be pulled from there. 13 | */ 14 | export default class SubjectPathResolver { 15 | constructor(pathFactory, source) { 16 | this._paths = pathFactory; 17 | this._source = source; 18 | } 19 | 20 | /** Resolve all string properties (not Symbols) */ 21 | supports(property) { 22 | return typeof property === 'string'; 23 | } 24 | 25 | resolve(property, { settings }) { 26 | return this._createSubjectPath(namedNode(property), settings); 27 | } 28 | 29 | _createSubjectPath(subject, { createQueryEngine }) { 30 | const source = this._source || Promise.resolve(subject).catch(() => null); 31 | const queryEngine = createQueryEngine(source); 32 | return this._paths.create({ queryEngine }, { subject }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import { SparqlHandler } from 'ldflex'; 2 | 3 | export const { termToString } = SparqlHandler.prototype; 4 | 5 | export function replaceVariables(template, terms) { 6 | for (const name in terms) 7 | template = template.replace(new RegExp(`_:${name}`, 'g'), termToString(terms[name])); 8 | return template; 9 | } 10 | 11 | // Transforms the arguments into an Immutable.js-style list 12 | export function asList(...items) { 13 | return { 14 | size: items.length, 15 | values: () => ({ 16 | next: () => ({ value: items.shift() }), 17 | }), 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | extends: plugin:jest/recommended, 3 | rules: { 4 | camelcase: off, 5 | global-require: off, 6 | no-extend-native: off, 7 | no-use-before-define: off, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /test/__mocks__/solid-auth-client.js: -------------------------------------------------------------------------------- 1 | export default { 2 | currentSession: jest.fn(), 3 | fetch: jest.fn((request, init) => ({ 4 | ok: !init || !/error/.test(init.body), 5 | status: 123, 6 | statusText: 'Status', 7 | })), 8 | }; 9 | -------------------------------------------------------------------------------- /test/__mocks__/uuid.js: -------------------------------------------------------------------------------- 1 | let counter = 1; 2 | export function v4() { 3 | return `${counter++}`; 4 | } 5 | v4.reset = function () { 6 | counter = 1; 7 | }; 8 | -------------------------------------------------------------------------------- /test/assets/http/System-test-PUT_1873382719/PUTting-a-Turtle-document_871210938/recording.har: -------------------------------------------------------------------------------- 1 | { 2 | "log": { 3 | "_recordingName": "System test: PUT/PUTting a Turtle document", 4 | "creator": { 5 | "comment": "persister:fs", 6 | "name": "Polly.JS", 7 | "version": "4.3.0" 8 | }, 9 | "entries": [ 10 | { 11 | "_id": "573cbe5fa9b204bbcf4cae575188ef7d", 12 | "_order": 0, 13 | "cache": {}, 14 | "request": { 15 | "bodySize": 0, 16 | "cookies": [], 17 | "headers": [ 18 | { 19 | "_fromType": "array", 20 | "name": "accept-encoding", 21 | "value": "gzip,deflate" 22 | }, 23 | { 24 | "_fromType": "array", 25 | "name": "user-agent", 26 | "value": "node-fetch/1.0 (+https://github.com/bitinn/node-fetch)" 27 | }, 28 | { 29 | "_fromType": "array", 30 | "name": "connection", 31 | "value": "close" 32 | }, 33 | { 34 | "_fromType": "array", 35 | "name": "accept", 36 | "value": "*/*" 37 | }, 38 | { 39 | "name": "host", 40 | "value": "drive.verborgh.org" 41 | } 42 | ], 43 | "headersSize": 219, 44 | "httpVersion": "HTTP/1.1", 45 | "method": "GET", 46 | "queryString": [], 47 | "url": "https://drive.verborgh.org/public/destination" 48 | }, 49 | "response": { 50 | "bodySize": 80, 51 | "content": { 52 | "_isBinary": true, 53 | "mimeType": "text/turtle", 54 | "size": 80, 55 | "text": "[\"1f8b0800000000000003535608292d2ac9495548c94f2ecd4dcd2be1020046ae618812000000\"]" 56 | }, 57 | "cookies": [], 58 | "headers": [ 59 | { 60 | "name": "date", 61 | "value": "Mon, 01 Jun 2020 15:37:49 GMT" 62 | }, 63 | { 64 | "name": "content-type", 65 | "value": "text/turtle" 66 | }, 67 | { 68 | "name": "transfer-encoding", 69 | "value": "chunked" 70 | }, 71 | { 72 | "name": "connection", 73 | "value": "close" 74 | }, 75 | { 76 | "name": "vary", 77 | "value": "Accept-Encoding, Accept, Authorization, Origin" 78 | }, 79 | { 80 | "name": "x-powered-by", 81 | "value": "solid-server" 82 | }, 83 | { 84 | "name": "access-control-allow-credentials", 85 | "value": "true" 86 | }, 87 | { 88 | "name": "access-control-expose-headers", 89 | "value": "Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate" 90 | }, 91 | { 92 | "name": "allow", 93 | "value": "OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE" 94 | }, 95 | { 96 | "name": "link", 97 | "value": "; rel=\"acl\", ; rel=\"describedBy\", ; rel=\"type\"" 98 | }, 99 | { 100 | "name": "wac-allow", 101 | "value": "user=\"read write append\",public=\"read write append\"" 102 | }, 103 | { 104 | "name": "ms-author-via", 105 | "value": "SPARQL" 106 | }, 107 | { 108 | "name": "updates-via", 109 | "value": "wss://drive.verborgh.org" 110 | }, 111 | { 112 | "name": "strict-transport-security", 113 | "value": "max-age=15768000" 114 | }, 115 | { 116 | "name": "content-encoding", 117 | "value": "gzip" 118 | } 119 | ], 120 | "headersSize": 794, 121 | "httpVersion": "HTTP/1.1", 122 | "redirectURL": "", 123 | "status": 200, 124 | "statusText": "OK" 125 | }, 126 | "startedDateTime": "2020-06-01T15:37:49.115Z", 127 | "time": 145, 128 | "timings": { 129 | "blocked": -1, 130 | "connect": -1, 131 | "dns": -1, 132 | "receive": 0, 133 | "send": 0, 134 | "ssl": -1, 135 | "wait": 145 136 | } 137 | }, 138 | { 139 | "_id": "3943aac20f36afef3cf2173c9dd87204", 140 | "_order": 0, 141 | "cache": {}, 142 | "request": { 143 | "bodySize": 18, 144 | "cookies": [], 145 | "headers": [ 146 | { 147 | "_fromType": "array", 148 | "name": "content-type", 149 | "value": "text/turtle" 150 | }, 151 | { 152 | "_fromType": "array", 153 | "name": "accept", 154 | "value": "*/*" 155 | }, 156 | { 157 | "_fromType": "array", 158 | "name": "content-length", 159 | "value": "18" 160 | }, 161 | { 162 | "_fromType": "array", 163 | "name": "user-agent", 164 | "value": "node-fetch/1.0 (+https://github.com/bitinn/node-fetch)" 165 | }, 166 | { 167 | "_fromType": "array", 168 | "name": "accept-encoding", 169 | "value": "gzip,deflate" 170 | }, 171 | { 172 | "_fromType": "array", 173 | "name": "connection", 174 | "value": "close" 175 | }, 176 | { 177 | "name": "host", 178 | "value": "drive.verborgh.org" 179 | } 180 | ], 181 | "headersSize": 266, 182 | "httpVersion": "HTTP/1.1", 183 | "method": "PUT", 184 | "postData": { 185 | "mimeType": "text/turtle", 186 | "params": [], 187 | "text": "# Turtle document\n" 188 | }, 189 | "queryString": [], 190 | "url": "https://drive.verborgh.org/public/destination" 191 | }, 192 | "response": { 193 | "bodySize": 1040, 194 | "content": { 195 | "mimeType": "text/html; charset=utf-8", 196 | "size": 1040, 197 | "text": "\n\n\n \n \n Log in\n \n \n\n\n
\n
\n
\n \n \n \n
\n

Log in to access this resource

\n
\n\n
\n

\n The resource you are trying to access\n (http://drive.verborgh.org/public/destination)\n requires you to log in.\n

\n
\n\n
\n\n\n\n\n" 198 | }, 199 | "cookies": [], 200 | "headers": [ 201 | { 202 | "name": "date", 203 | "value": "Fri, 25 Sep 2020 20:36:05 GMT" 204 | }, 205 | { 206 | "name": "content-type", 207 | "value": "text/html; charset=utf-8" 208 | }, 209 | { 210 | "name": "content-length", 211 | "value": "1040" 212 | }, 213 | { 214 | "name": "connection", 215 | "value": "close" 216 | }, 217 | { 218 | "name": "x-powered-by", 219 | "value": "solid-server/5.4.0" 220 | }, 221 | { 222 | "name": "vary", 223 | "value": "Accept, Authorization, Origin" 224 | }, 225 | { 226 | "name": "access-control-allow-credentials", 227 | "value": "true" 228 | }, 229 | { 230 | "name": "access-control-expose-headers", 231 | "value": "Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate, MS-Author-Via" 232 | }, 233 | { 234 | "name": "allow", 235 | "value": "OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE" 236 | }, 237 | { 238 | "name": "link", 239 | "value": "; rel=\"acl\", ; rel=\"describedBy\", ; rel=\"type\"" 240 | }, 241 | { 242 | "name": "www-authenticate", 243 | "value": "Bearer realm=\"https://drive.verborgh.org\", scope=\"openid webid\"" 244 | }, 245 | { 246 | "name": "etag", 247 | "value": "W/\"410-YDKwPVdINVRs8pR04Qa3/Dzxg8E\"" 248 | } 249 | ], 250 | "headersSize": 736, 251 | "httpVersion": "HTTP/1.1", 252 | "redirectURL": "", 253 | "status": 401, 254 | "statusText": "Unauthorized" 255 | }, 256 | "startedDateTime": "2020-09-25T20:36:05.023Z", 257 | "time": 876, 258 | "timings": { 259 | "blocked": -1, 260 | "connect": -1, 261 | "dns": -1, 262 | "receive": 0, 263 | "send": 0, 264 | "ssl": -1, 265 | "wait": 876 266 | } 267 | } 268 | ], 269 | "pages": [], 270 | "version": "1.2" 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /test/assets/http/System-test-blank-nodes_2374737181/Alice-s-friends-with-one-expression_87134690/recording.har: -------------------------------------------------------------------------------- 1 | { 2 | "log": { 3 | "_recordingName": "System test: blank nodes/Alice's friends with one expression", 4 | "creator": { 5 | "comment": "persister:fs", 6 | "name": "Polly.JS", 7 | "version": "4.1.0" 8 | }, 9 | "entries": [ 10 | { 11 | "_id": "8484264758b24ee3105deeb120335ab3", 12 | "_order": 0, 13 | "cache": {}, 14 | "request": { 15 | "bodySize": 0, 16 | "cookies": [], 17 | "headers": [ 18 | { 19 | "_fromType": "array", 20 | "name": "accept", 21 | "value": "application/n-quads,application/trig;q=0.95,application/ld+json;q=0.9,application/n-triples;q=0.8,text/turtle;q=0.6,application/rdf+xml;q=0.5,application/json;q=0.45,text/n3;q=0.35,application/xml;q=0.3,text/xml;q=0.3,image/svg+xml;q=0.3,text/html;q=0.2,application/xhtml+xml;q=0.18" 22 | }, 23 | { 24 | "_fromType": "array", 25 | "name": "user-agent", 26 | "value": "node-fetch/1.0 (+https://github.com/bitinn/node-fetch)" 27 | }, 28 | { 29 | "_fromType": "array", 30 | "name": "accept-encoding", 31 | "value": "gzip,deflate" 32 | }, 33 | { 34 | "_fromType": "array", 35 | "name": "connection", 36 | "value": "close" 37 | }, 38 | { 39 | "name": "host", 40 | "value": "drive.verborgh.org" 41 | } 42 | ], 43 | "headersSize": 502, 44 | "httpVersion": "HTTP/1.1", 45 | "method": "GET", 46 | "queryString": [], 47 | "url": "https://drive.verborgh.org/public/2019/blanks.ttl" 48 | }, 49 | "response": { 50 | "bodySize": 334, 51 | "content": { 52 | "_isBinary": true, 53 | "mimeType": "text/turtle", 54 | "size": 334, 55 | "text": "[\"1f8b08000000000000037328284a4dcbac5048cb4f4cb352b0c9282929b0d2d7afc8cdc92bd64bcecfd50789eb1be819eadbe97171d9283be66426a7da4154e725e6a62a28814594acb91460006c52765e7e79b14234b242a7fc242585581d84429806208da2d039b1283f07a81461a111a68d46c85642ac81d869a30cb4c8c80ebb3d0a36ca60d38d80be812a4473a291124806aa08590e22a4a40700ff2c320531010000\"]" 56 | }, 57 | "cookies": [], 58 | "headers": [ 59 | { 60 | "name": "date", 61 | "value": "Fri, 25 Sep 2020 21:07:14 GMT" 62 | }, 63 | { 64 | "name": "content-type", 65 | "value": "text/turtle" 66 | }, 67 | { 68 | "name": "transfer-encoding", 69 | "value": "chunked" 70 | }, 71 | { 72 | "name": "connection", 73 | "value": "close" 74 | }, 75 | { 76 | "name": "vary", 77 | "value": "Accept-Encoding, Accept, Authorization, Origin" 78 | }, 79 | { 80 | "name": "x-powered-by", 81 | "value": "solid-server/5.4.0" 82 | }, 83 | { 84 | "name": "access-control-allow-credentials", 85 | "value": "true" 86 | }, 87 | { 88 | "name": "access-control-expose-headers", 89 | "value": "Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate, MS-Author-Via" 90 | }, 91 | { 92 | "name": "allow", 93 | "value": "OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE" 94 | }, 95 | { 96 | "name": "link", 97 | "value": "; rel=\"acl\", ; rel=\"describedBy\", ; rel=\"type\"" 98 | }, 99 | { 100 | "name": "wac-allow", 101 | "value": "user=\"read\",public=\"read\"" 102 | }, 103 | { 104 | "name": "ms-author-via", 105 | "value": "SPARQL" 106 | }, 107 | { 108 | "name": "updates-via", 109 | "value": "wss://drive.verborgh.org" 110 | }, 111 | { 112 | "name": "strict-transport-security", 113 | "value": "max-age=15768000" 114 | }, 115 | { 116 | "name": "content-encoding", 117 | "value": "gzip" 118 | } 119 | ], 120 | "headersSize": 787, 121 | "httpVersion": "HTTP/1.1", 122 | "redirectURL": "", 123 | "status": 200, 124 | "statusText": "OK" 125 | }, 126 | "startedDateTime": "2020-09-25T21:07:12.962Z", 127 | "time": 2061, 128 | "timings": { 129 | "blocked": -1, 130 | "connect": -1, 131 | "dns": -1, 132 | "receive": 0, 133 | "send": 0, 134 | "ssl": -1, 135 | "wait": 2061 136 | } 137 | } 138 | ], 139 | "pages": [], 140 | "version": "1.2" 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /test/system/blanks-test.js: -------------------------------------------------------------------------------- 1 | import data from '../../src/exports/comunica.js'; 2 | 3 | jest.unmock('solid-auth-client'); 4 | import { mockHttp } from '../util'; 5 | 6 | const alice = 'https://drive.verborgh.org/public/2019/blanks.ttl#Alice'; 7 | 8 | describe('System test: blank nodes', () => { 9 | mockHttp(); 10 | 11 | test('Alice\'s friends with one expression', async () => { 12 | const names = []; 13 | for await (const name of data[alice].friends.name) 14 | names.push(`${name}`); 15 | expect(names).toHaveLength(2); 16 | expect(names).toContain('Bob'); 17 | expect(names).toContain('Carol'); 18 | }); 19 | 20 | test('Alice\'s friends with two expressions', async () => { 21 | const names = []; 22 | for await (const friend of data[alice].friends) 23 | names.push(`${await friend.name}`); 24 | expect(names).toHaveLength(2); 25 | expect(names).toContain('Bob'); 26 | expect(names).toContain('Carol'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/system/profile-test.js: -------------------------------------------------------------------------------- 1 | import data from '../../src/exports/comunica.js'; 2 | 3 | jest.unmock('solid-auth-client'); 4 | import { mockHttp } from '../util'; 5 | 6 | const ruben = 'https://ruben.verborgh.org/profile/#me'; 7 | 8 | describe('System test: profile', () => { 9 | mockHttp(); 10 | 11 | test('main profile fields', async () => { 12 | expect(await data[ruben].name).toHaveProperty('value', 'Ruben Verborgh'); 13 | expect(await data[ruben].givenName).toHaveProperty('value', 'Ruben'); 14 | expect(await data[ruben].familyName).toHaveProperty('value', 'Verborgh'); 15 | expect(await data[ruben].email).toHaveProperty('value', 'mailto:ruben.verborgh@ugent.be'); 16 | }); 17 | 18 | test('friend URLs', async () => { 19 | const friends = []; 20 | for await (const friend of data[ruben].friends) 21 | friends.push(`${friend}`); 22 | expect(friends.length).toBeGreaterThan(100); 23 | expect(friends).toContain('https://www.w3.org/People/Berners-Lee/card#i'); 24 | expect(friends).toContain('https://www.rubensworks.net/#me'); 25 | expect(friends).toContain('https://data.verborgh.org/people/joachim_van_herwegen'); 26 | }); 27 | 28 | test('friend names', async () => { 29 | const friends = []; 30 | for await (const friend of data[ruben].friends.givenName) 31 | friends.push(`${friend}`); 32 | expect(friends.length).toBeGreaterThan(100); 33 | expect(friends).toContain('Tim'); 34 | expect(friends).toContain('Ruben'); 35 | expect(friends).toContain('Joachim'); 36 | }); 37 | 38 | test('friend names with separate expressions', async () => { 39 | const friends = []; 40 | for await (const friend of data[ruben].friends) 41 | friends.push(`${await friend.givenName}`); 42 | expect(friends.length).toBeGreaterThan(100); 43 | expect(friends).toContain('Tim'); 44 | expect(friends).toContain('Ruben'); 45 | expect(friends).toContain('Joachim'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/system/put-test.js: -------------------------------------------------------------------------------- 1 | import data from '../../src/exports/comunica.js'; 2 | import auth from 'solid-auth-client'; 3 | 4 | jest.unmock('solid-auth-client'); 5 | import { mockHttp } from '../util'; 6 | 7 | describe('System test: PUT', () => { 8 | mockHttp(); 9 | 10 | test('PUTting a Turtle document', async () => { 11 | // Write document 12 | const document = 'https://drive.verborgh.org/public/destination'; 13 | const contents = '# Turtle document\n'; 14 | const result = await data[document].put(contents); 15 | 16 | // Verify successful write 17 | const response = await auth.fetch(document); 18 | expect(await response.text()).toBe(contents); 19 | 20 | // Verify result 21 | expect(result).toHaveProperty('value', document); 22 | expect(result).toHaveProperty('termType', 'NamedNode'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/unit/SolidUpdateEngine-test.js: -------------------------------------------------------------------------------- 1 | import SolidUpdateEngine from '../../src/SolidUpdateEngine'; 2 | import auth from 'solid-auth-client'; 3 | 4 | const mockEngine = { 5 | execute: jest.fn(async function* asyncResults() { 6 | yield 'my result'; 7 | }), 8 | clearCache: jest.fn(), 9 | }; 10 | 11 | describe('a SolidUpdateEngine instance', () => { 12 | let engine, bindings; 13 | beforeEach(() => { 14 | engine = new SolidUpdateEngine('https://example.org/', mockEngine); 15 | }); 16 | 17 | it('passes execute calls', async () => { 18 | const results = engine.execute('a', 'b'); 19 | for await (const result of results) 20 | expect(result).toBe('my result'); 21 | expect(mockEngine.execute).toHaveBeenCalledTimes(1); 22 | expect(mockEngine.execute).toHaveBeenCalledWith('a', 'b'); 23 | }); 24 | 25 | it('adds all required methods to the returned iterator', async () => { 26 | const results = engine.executeUpdate('a', 'b'); 27 | expect(results.return()).toBe(undefined); 28 | expect(results.throw()).toBe(undefined); 29 | }); 30 | 31 | it('passes clearCache calls', async () => { 32 | const result = {}; 33 | mockEngine.clearCache.mockReturnValueOnce(result); 34 | expect(await engine.clearCache('a')).toBe(result); 35 | expect(mockEngine.clearCache).toHaveBeenCalledTimes(1); 36 | expect(mockEngine.clearCache).toHaveBeenCalledWith('a'); 37 | }); 38 | 39 | describe('executing a query to insert a triple', () => { 40 | beforeEach(async () => { 41 | bindings = []; 42 | for await (const binding of engine.execute('INSERT DATA { <> <> <> }')) 43 | bindings.push(binding); 44 | }); 45 | 46 | it('issues a PATCH request', () => { 47 | expect(auth.fetch).toHaveBeenCalledTimes(1); 48 | const args = auth.fetch.mock.calls[0]; 49 | expect(args[1]).toHaveProperty('method', 'PATCH'); 50 | }); 51 | 52 | it('issues the request to the default source', () => { 53 | expect(auth.fetch).toHaveBeenCalledTimes(1); 54 | const args = auth.fetch.mock.calls[0]; 55 | expect(args[0]).toBe('https://example.org/'); 56 | }); 57 | 58 | it('sets the Content-Type to application/sparql-update', () => { 59 | expect(auth.fetch).toHaveBeenCalledTimes(1); 60 | const args = auth.fetch.mock.calls[0]; 61 | expect(args[1]).toHaveProperty('headers'); 62 | expect(args[1].headers).toHaveProperty('Content-Type', 'application/sparql-update'); 63 | }); 64 | 65 | it('sends a patch document', () => { 66 | expect(auth.fetch).toHaveBeenCalledTimes(1); 67 | const args = auth.fetch.mock.calls[0]; 68 | expect(args[1]).toHaveProperty('body'); 69 | expect(args[1].body).toEqual('INSERT DATA { <> <> <> }'); 70 | }); 71 | 72 | it('invalidates the document cache', () => { 73 | expect(mockEngine.clearCache).toHaveBeenCalledTimes(1); 74 | expect(mockEngine.clearCache).toHaveBeenCalledWith('https://example.org/'); 75 | }); 76 | 77 | it('returns one OK binding', () => { 78 | expect(bindings).toHaveLength(1); 79 | expect(bindings[0].size).toBe(1); 80 | expect(bindings[0].values().next().value).toHaveProperty('ok', true); 81 | }); 82 | }); 83 | 84 | it('accepts a URL as source', async () => { 85 | const source = new URL('http://a.example/'); 86 | await engine.execute('INSERT DATA { <> <> <> }', source).next(); 87 | expect(auth.fetch).toHaveBeenCalledTimes(1); 88 | const args = auth.fetch.mock.calls[0]; 89 | expect(args[0]).toBe('http://a.example/'); 90 | }); 91 | 92 | it('accepts a NamedNode as source', async () => { 93 | const source = { value: 'http://a.example/' }; 94 | await engine.execute('INSERT DATA { <> <> <> }', source).next(); 95 | expect(auth.fetch).toHaveBeenCalledTimes(1); 96 | const args = auth.fetch.mock.calls[0]; 97 | expect(args[0]).toBe('http://a.example/'); 98 | }); 99 | 100 | test('executing an invalid query throws an error', async () => { 101 | await expect(engine.execute('INSERT error').next()).rejects 102 | .toThrow(new Error('Update query failed (123): Status')); 103 | }); 104 | 105 | it('accepts a nested array as source', async () => { 106 | const source = [[['http://a.example/']]]; 107 | await engine.execute('INSERT DATA { <> <> <> }', source).next(); 108 | expect(auth.fetch).toHaveBeenCalledTimes(1); 109 | const args = auth.fetch.mock.calls[0]; 110 | expect(args[0]).toBe('http://a.example/'); 111 | }); 112 | 113 | test('executing a query on a non-HTTP document throws an error', async () => { 114 | const sources = ['did://a.example/']; 115 | await expect(engine.execute('INSERT DATA { <> <> <> }', sources).next()).rejects 116 | .toThrow(new Error('Can only update an HTTP(S) document.')); 117 | }); 118 | 119 | test('executing a query on multiple sources throws an error', async () => { 120 | const sources = ['http://a.example/', 'http://b.example/']; 121 | await expect(engine.execute('INSERT DATA { <> <> <> }', sources).next()).rejects 122 | .toThrow(new Error('Can only update a single source.')); 123 | }); 124 | 125 | test('executing a query on an unsupported source throws an error', async () => { 126 | const source = { toString: () => 'my source' }; 127 | await expect(engine.execute('INSERT DATA { <> <> <> }', source).next()).rejects 128 | .toThrow(new Error('Unsupported source: my source')); 129 | }); 130 | }); 131 | 132 | describe('a SolidUpdateEngine instance without a default source', () => { 133 | let engine; 134 | beforeEach(() => { 135 | engine = new SolidUpdateEngine(null, mockEngine); 136 | }); 137 | 138 | test('executing a query without source throws an error', async () => { 139 | await expect(engine.execute('INSERT DATA { <> <> <> }').next()).rejects 140 | .toThrow(new Error('Unsupported source: null')); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /test/unit/exports/comunica-test.js: -------------------------------------------------------------------------------- 1 | import data from '../../../src/exports/comunica'; 2 | import auth from 'solid-auth-client'; 3 | import SolidUpdateEngine from '../../../src/SolidUpdateEngine'; 4 | import FindActivityHandler from '../../../src/handlers/FindActivityHandler'; 5 | import CreateActivityHandler from '../../../src/handlers/CreateActivityHandler'; 6 | import DeleteActivityHandler from '../../../src/handlers/DeleteActivityHandler'; 7 | import { namedNode } from '@rdfjs/data-model'; 8 | 9 | jest.mock('../../../src/SolidUpdateEngine'); 10 | async function* noResults() { /* empty */ } 11 | SolidUpdateEngine.prototype.execute = jest.fn(noResults); 12 | 13 | FindActivityHandler.prototype.handle = jest.fn(() => jest.fn()); 14 | CreateActivityHandler.prototype.handle = jest.fn(() => jest.fn()); 15 | DeleteActivityHandler.prototype.handle = jest.fn(() => jest.fn()); 16 | 17 | describe('The @solid/ldflex module', () => { 18 | it('is an ES6 module with a default export', () => { 19 | expect(require('../../../src/exports/comunica').default).toBe(data); 20 | }); 21 | 22 | test('its default export does not identify as an ES6 module', () => { 23 | expect(data.__esModule).toBeUndefined(); 24 | }); 25 | 26 | it('exposes the original JSON-LD context', () => { 27 | expect(data.context).toHaveProperty('as', 'https://www.w3.org/ns/activitystreams#'); 28 | }); 29 | 30 | it('exposes the expanded JSON-LD context', async () => { 31 | expect(data.context).toHaveProperty('friend', 'foaf:knows'); 32 | expect(await data.context).toHaveProperty('friend', 'http://xmlns.com/foaf/0.1/knows'); 33 | }); 34 | 35 | it('allows extending the JSON-LD context', async () => { 36 | const before = await data['https://foo.bar/#me']['ex:test'].sparql; 37 | expect(before).toContain(''); 38 | 39 | await data.context.extend({ 40 | ex: 'https://example.org/foo#', 41 | }); 42 | 43 | const after = await data['https://foo.bar/#me']['ex:test'].sparql; 44 | expect(after).not.toContain(''); 45 | expect(after).toContain('https://example.org/foo#test'); 46 | }); 47 | 48 | describe('an URL path', () => { 49 | const url = 'https://ex.org/#this'; 50 | 51 | it('executes the query', async () => { 52 | await data[url].firstName; 53 | const { constructor, execute } = SolidUpdateEngine.prototype; 54 | expect(constructor).toHaveBeenCalledTimes(1); 55 | const args = constructor.mock.calls[0]; 56 | await expect(args[0]).resolves.toEqual(namedNode(url)); 57 | expect(execute).toHaveBeenCalledTimes(1); 58 | expect(execute).toHaveBeenCalledWith(urlQuery); 59 | }); 60 | 61 | it('can retrieve likes', async () => { 62 | const activity = 'https://www.w3.org/ns/activitystreams#Like'; 63 | const { handle } = FindActivityHandler.prototype; 64 | await data[url].likes; 65 | expect(handle).toHaveBeenCalledTimes(1); 66 | expect(handle.mock.results[0].value).toHaveBeenCalledTimes(1); 67 | expect(handle.mock.results[0].value).toHaveBeenCalledWith(activity); 68 | }); 69 | 70 | it('can retrieve dislikes', async () => { 71 | const activity = 'https://www.w3.org/ns/activitystreams#Dislike'; 72 | const { handle } = FindActivityHandler.prototype; 73 | await data[url].dislikes; 74 | expect(handle).toHaveBeenCalledTimes(1); 75 | expect(handle.mock.results[0].value).toHaveBeenCalledTimes(1); 76 | expect(handle.mock.results[0].value).toHaveBeenCalledWith(activity); 77 | }); 78 | 79 | it('can retrieve follows', async () => { 80 | const activity = 'https://www.w3.org/ns/activitystreams#Follow'; 81 | const { handle } = FindActivityHandler.prototype; 82 | await data[url].follows; 83 | expect(handle).toHaveBeenCalledTimes(1); 84 | expect(handle.mock.results[0].value).toHaveBeenCalledTimes(1); 85 | expect(handle.mock.results[0].value).toHaveBeenCalledWith(activity); 86 | }); 87 | 88 | it('can create likes', async () => { 89 | const activity = 'https://www.w3.org/ns/activitystreams#Like'; 90 | const { handle } = CreateActivityHandler.prototype; 91 | await data[url].like(); 92 | expect(handle).toHaveBeenCalledTimes(1); 93 | expect(handle.mock.results[0].value).toHaveBeenCalledTimes(1); 94 | expect(handle.mock.results[0].value).toHaveBeenCalledWith(activity); 95 | }); 96 | 97 | it('can create dislikes', async () => { 98 | const activity = 'https://www.w3.org/ns/activitystreams#Dislike'; 99 | const { handle } = CreateActivityHandler.prototype; 100 | await data[url].dislike(); 101 | expect(handle).toHaveBeenCalledTimes(1); 102 | expect(handle.mock.results[0].value).toHaveBeenCalledTimes(1); 103 | expect(handle.mock.results[0].value).toHaveBeenCalledWith(activity); 104 | }); 105 | 106 | it('can create follows', async () => { 107 | const activity = 'https://www.w3.org/ns/activitystreams#Follow'; 108 | const { handle } = CreateActivityHandler.prototype; 109 | await data[url].follow(); 110 | expect(handle).toHaveBeenCalledTimes(1); 111 | expect(handle.mock.results[0].value).toHaveBeenCalledTimes(1); 112 | expect(handle.mock.results[0].value).toHaveBeenCalledWith(activity); 113 | }); 114 | 115 | it('can delete likes', async () => { 116 | const activity = 'https://www.w3.org/ns/activitystreams#Like'; 117 | const { handle } = DeleteActivityHandler.prototype; 118 | await data[url].unlike(); 119 | expect(handle).toHaveBeenCalledTimes(1); 120 | expect(handle.mock.results[0].value).toHaveBeenCalledTimes(1); 121 | expect(handle.mock.results[0].value).toHaveBeenCalledWith(activity); 122 | }); 123 | 124 | it('can delete dislikes', async () => { 125 | const activity = 'https://www.w3.org/ns/activitystreams#Dislike'; 126 | const { handle } = DeleteActivityHandler.prototype; 127 | await data[url].undislike(); 128 | expect(handle).toHaveBeenCalledTimes(1); 129 | expect(handle.mock.results[0].value).toHaveBeenCalledTimes(1); 130 | expect(handle.mock.results[0].value).toHaveBeenCalledWith(activity); 131 | }); 132 | 133 | it('can delete follows', async () => { 134 | const activity = 'https://www.w3.org/ns/activitystreams#Follow'; 135 | const { handle } = DeleteActivityHandler.prototype; 136 | await data[url].unfollow(); 137 | expect(handle).toHaveBeenCalledTimes(1); 138 | expect(handle.mock.results[0].value).toHaveBeenCalledTimes(1); 139 | expect(handle.mock.results[0].value).toHaveBeenCalledWith(activity); 140 | }); 141 | }); 142 | 143 | describe('the user path', () => { 144 | describe('when not logged in', () => { 145 | it('throws an error', async () => { 146 | await expect(() => data.user.firstName).rejects 147 | .toThrow('Cannot resolve user path: no user logged in'); 148 | }); 149 | }); 150 | 151 | describe('when logged in', () => { 152 | const webId = 'https://ex.org/#me'; 153 | beforeEach(async () => { 154 | auth.currentSession.mockReturnValue({ webId }); 155 | await data.user.firstName; 156 | }); 157 | 158 | it('executes the query', async () => { 159 | const { constructor, execute } = SolidUpdateEngine.prototype; 160 | expect(constructor).toHaveBeenCalledTimes(1); 161 | await expect(constructor.mock.calls[0][0]).resolves.toEqual(namedNode(webId)); 162 | expect(execute).toHaveBeenCalledTimes(1); 163 | expect(execute).toHaveBeenCalledWith(userQuery); 164 | }); 165 | }); 166 | }); 167 | 168 | describe('the resolve path', () => { 169 | it('resolves to the root when no expression is passed', () => { 170 | expect(data.resolve()).toBe(data); 171 | }); 172 | }); 173 | 174 | describe('the root path', () => { 175 | it('resolves to the root', () => { 176 | expect(data.user.root.root.user.root).toBe(data); 177 | }); 178 | }); 179 | 180 | describe('the clearCache path', () => { 181 | it('returns a function to clear the cache', () => { 182 | const document = {}; 183 | data.clearCache(document); 184 | expect(SolidUpdateEngine.prototype.clearCache).toHaveBeenCalledTimes(1); 185 | expect(SolidUpdateEngine.prototype.clearCache).toHaveBeenCalledWith(document); 186 | }); 187 | }); 188 | }); 189 | 190 | const urlQuery = `SELECT ?firstName WHERE { 191 | ?firstName. 192 | }`; 193 | 194 | const userQuery = `SELECT ?firstName WHERE { 195 | ?firstName. 196 | }`; 197 | -------------------------------------------------------------------------------- /test/unit/exports/rdflib-test.js: -------------------------------------------------------------------------------- 1 | import data from '../../../src/exports/rdflib'; 2 | jest.mock('@ldflex/rdflib'); 3 | 4 | describe('The rdflib module', () => { 5 | it('is an ES6 module with a default export', () => { 6 | expect(require('../../../src/exports/rdflib').default).toBe(data); 7 | }); 8 | 9 | test('its default export does not identify as an ES6 module', () => { 10 | expect(data.__esModule).toBeUndefined(); 11 | }); 12 | 13 | it('allows extending the JSON-LD context', async () => { 14 | const before = await data['https://foo.bar/#me']['ex:test'].sparql; 15 | expect(before).toContain(''); 16 | 17 | await data.context.extend({ 18 | ex: 'https://example.org/foo#', 19 | }); 20 | 21 | const after = await data['https://foo.bar/#me']['ex:test'].sparql; 22 | expect(after).not.toContain(''); 23 | expect(after).toContain('https://example.org/foo#test'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/unit/handlers/ActivityHandler-test.js: -------------------------------------------------------------------------------- 1 | import ActivityHandler from '../../../src/handlers/ActivityHandler'; 2 | 3 | const loggedOutPath = { 4 | root: { 5 | get user() { 6 | throw new Error('no user'); 7 | }, 8 | }, 9 | }; 10 | 11 | describe('an ActivityHandler', () => { 12 | let handler; 13 | 14 | describe('with default settings', () => { 15 | beforeEach(() => { 16 | handler = new ActivityHandler(); 17 | }); 18 | 19 | it('errors if the user is not logged in', async () => { 20 | const findActivity = handler.handle({ settings: {} }, loggedOutPath); 21 | const iterator = findActivity(); 22 | const results = []; 23 | await expect((async () => { 24 | for await (const result of iterator) 25 | results.push(result); 26 | })()).rejects.toThrow('no user'); 27 | expect(results).toHaveLength(0); 28 | }); 29 | }); 30 | 31 | describe('with requireUser set to false', () => { 32 | beforeEach(() => { 33 | handler = new ActivityHandler(); 34 | handler.requireUser = false; 35 | }); 36 | 37 | it('returns no returns if the user is not logged in', async () => { 38 | const findActivity = handler.handle({ settings: {} }, loggedOutPath); 39 | const iterator = findActivity(); 40 | const results = []; 41 | for await (const result of iterator) 42 | results.push(result); 43 | expect(results).toHaveLength(0); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/unit/handlers/CreateActivityHandler-test.js: -------------------------------------------------------------------------------- 1 | import CreateActivityHandler from '../../../src/handlers/CreateActivityHandler'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | Date.prototype.toISOString = () => '2019-01-01T20:00:00.000Z'; 5 | 6 | describe('a CreateActivityHandler instance', () => { 7 | const queryEngine = { 8 | executeUpdate: jest.fn(() => ({ next: jest.fn() })), 9 | }; 10 | let handler; 11 | beforeEach(() => (handler = new CreateActivityHandler())); 12 | 13 | describe('creating a like', () => { 14 | let results; 15 | 16 | beforeEach(async () => { 17 | // create a mock path 18 | const path = (async function* path() { 19 | yield { value: 'http://example.org/#thing1', termType: 'NamedNode' }; 20 | yield { value: 'http://example.org/#thing2', termType: 'NamedNode' }; 21 | yield 0; 22 | }()); 23 | const user = Promise.resolve('http://user.example/#me'); 24 | user.pim$storage = 'http://user.storage/'; 25 | path.root = { 26 | user, 27 | 'http://user.storage/public/activities#1': { path: 1 }, 28 | 'http://user.storage/public/activities#2': { path: 2 }, 29 | }; 30 | 31 | // perform the activity 32 | uuidv4.reset(); 33 | const createActivity = handler.handle({ settings: { queryEngine } }, path); 34 | const iterator = createActivity(); 35 | results = []; 36 | for await (const result of iterator) 37 | results.push(result); 38 | }); 39 | 40 | it("sends an update to the user's activities document", () => { 41 | expect(queryEngine.executeUpdate).toHaveBeenCalledTimes(1); 42 | 43 | // assert that it passes the right query 44 | const [args] = queryEngine.executeUpdate.mock.calls; 45 | expect(args[0].trim()).toBe(` 46 | INSERT { 47 | a ; 48 | ; 49 | ; 50 | "2019-01-01T20:00:00.000Z"^^. 51 | a ; 52 | ; 53 | ; 54 | "2019-01-01T20:00:00.000Z"^^. 55 | }`.trim()); 56 | expect(args[1]).toBe('http://user.storage/public/activities'); 57 | 58 | // assert that it calls the `next` function to execute the query 59 | const [{ value: { next } }] = queryEngine.executeUpdate.mock.results; 60 | expect(next).toHaveBeenCalledTimes(1); 61 | }); 62 | 63 | it('returns result paths', () => { 64 | expect(results).toEqual([ 65 | { path: 1 }, 66 | { path: 2 }, 67 | ]); 68 | }); 69 | }); 70 | 71 | describe('creating a follow when no user storage link is available', () => { 72 | let results; 73 | 74 | beforeEach(async () => { 75 | // create a mock path 76 | const path = (async function* path() { 77 | yield { value: 'http://example.org/#thing1', termType: 'NamedNode' }; 78 | yield { value: 'http://example.org/#thing2', termType: 'NamedNode' }; 79 | yield 0; 80 | }()); 81 | const user = Promise.resolve('http://user.example/#me'); 82 | path.root = { 83 | user, 84 | 'http://user.example/public/activities#1': { path: 1 }, 85 | 'http://user.example/public/activities#2': { path: 2 }, 86 | }; 87 | 88 | // perform the activity 89 | uuidv4.reset(); 90 | const createActivity = handler.handle({ settings: { queryEngine } }, path); 91 | const iterator = createActivity('https://www.w3.org/ns/activitystreams#Follow'); 92 | results = []; 93 | for await (const result of iterator) 94 | results.push(result); 95 | }); 96 | 97 | it("sends an update to the user's activities document", () => { 98 | expect(queryEngine.executeUpdate).toHaveBeenCalledTimes(1); 99 | 100 | // assert that it passes the right query 101 | const [args] = queryEngine.executeUpdate.mock.calls; 102 | expect(args[0].trim()).toBe(` 103 | INSERT { 104 | a ; 105 | ; 106 | ; 107 | "2019-01-01T20:00:00.000Z"^^. 108 | a ; 109 | ; 110 | ; 111 | "2019-01-01T20:00:00.000Z"^^. 112 | }`.trim()); 113 | expect(args[1]).toBe('http://user.example/public/activities'); 114 | 115 | // assert that it calls the `next` function to execute the query 116 | const [{ value: { next } }] = queryEngine.executeUpdate.mock.results; 117 | expect(next).toHaveBeenCalledTimes(1); 118 | }); 119 | 120 | it('returns result paths', () => { 121 | expect(results).toEqual([ 122 | { path: 1 }, 123 | { path: 2 }, 124 | ]); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /test/unit/handlers/DeleteActivityHandler-test.js: -------------------------------------------------------------------------------- 1 | import DeleteActivityHandler from '../../../src/handlers/DeleteActivityHandler'; 2 | import { namedNode, literal } from '@rdfjs/data-model'; 3 | 4 | describe('a DeleteActivityHandler instance', () => { 5 | const queryEngine = { 6 | execute: jest.fn(async function*() { 7 | const components = { 8 | '?subject': namedNode('http://ex.org/likes/#1'), 9 | '?predicate': namedNode('http://ex.org/#prop'), 10 | '?object': literal('abc'), 11 | }; 12 | yield { get: c => components[c] }; 13 | }), 14 | executeUpdate: jest.fn(() => ({ next: jest.fn() })), 15 | }; 16 | let handler; 17 | beforeEach(() => (handler = new DeleteActivityHandler())); 18 | 19 | describe('deleting a like', () => { 20 | let results; 21 | 22 | beforeEach(async () => { 23 | // create a mock path 24 | const path = (async function* path() { 25 | yield { value: 'http://example.org/#thing1', termType: 'NamedNode' }; 26 | yield { value: 'http://example.org/#thing2', termType: 'NamedNode' }; 27 | yield 0; 28 | }()); 29 | const user = Promise.resolve('http://user.example/#me'); 30 | user.pim$storage = 'http://user.storage/'; 31 | path.root = { 32 | user, 33 | 'http://ex.org/likes/#1': { path: 1 }, 34 | }; 35 | 36 | // perform the activity 37 | const createActivity = handler.handle({ settings: { queryEngine } }, path); 38 | const iterator = createActivity(); 39 | results = []; 40 | for await (const result of iterator) 41 | results.push(result); 42 | }); 43 | 44 | it("queries the user's activities document", () => { 45 | expect(queryEngine.execute).toHaveBeenCalledTimes(2); 46 | const args = queryEngine.execute.mock.calls; 47 | expect(args[0][0].trim()).toBe(` 48 | SELECT ?subject ?predicate ?object WHERE { 49 | ?subject a ; 50 | ; 51 | . 52 | ?subject ?predicate ?object. 53 | } 54 | `.trim()); 55 | expect(args[0][1]).toBe('http://user.storage/public/activities'); 56 | expect(args[1][0].trim()).toBe(` 57 | SELECT ?subject ?predicate ?object WHERE { 58 | ?subject a ; 59 | ; 60 | . 61 | ?subject ?predicate ?object. 62 | } 63 | `.trim()); 64 | expect(args[1][1]).toBe('http://user.storage/public/activities'); 65 | }); 66 | 67 | it('deletes the activities', () => { 68 | expect(queryEngine.executeUpdate).toHaveBeenCalledTimes(1); 69 | const args = queryEngine.executeUpdate.mock.calls; 70 | expect(args[0][0].trim()).toBe(` 71 | DELETE { 72 | "abc". 73 | "abc". 74 | } 75 | `.trim()); 76 | expect(args[0][1]).toBe('http://user.storage/public/activities'); 77 | }); 78 | 79 | it('returns no results', () => { 80 | expect(results).toEqual([]); 81 | }); 82 | }); 83 | 84 | describe('deleting a follow when no user storage link is available', () => { 85 | let results; 86 | 87 | beforeEach(async () => { 88 | // create a mock path 89 | const path = (async function* path() { 90 | yield { value: 'http://example.org/#thing1', termType: 'NamedNode' }; 91 | yield { value: 'http://example.org/#thing2', termType: 'NamedNode' }; 92 | yield 0; 93 | }()); 94 | const user = Promise.resolve('http://user.example/#me'); 95 | path.root = { 96 | user, 97 | 'http://ex.org/likes/#1': { path: 1 }, 98 | }; 99 | 100 | // perform the activity 101 | const createActivity = handler.handle({ settings: { queryEngine } }, path); 102 | const iterator = createActivity('https://www.w3.org/ns/activitystreams#Follow'); 103 | results = []; 104 | for await (const result of iterator) 105 | results.push(result); 106 | }); 107 | 108 | it("queries the user's activities document", () => { 109 | expect(queryEngine.execute).toHaveBeenCalledTimes(2); 110 | const args = queryEngine.execute.mock.calls; 111 | expect(args[0][0].trim()).toBe(` 112 | SELECT ?subject ?predicate ?object WHERE { 113 | ?subject a ; 114 | ; 115 | . 116 | ?subject ?predicate ?object. 117 | } 118 | `.trim()); 119 | expect(args[0][1]).toBe('http://user.example/public/activities'); 120 | expect(args[1][0].trim()).toBe(` 121 | SELECT ?subject ?predicate ?object WHERE { 122 | ?subject a ; 123 | ; 124 | . 125 | ?subject ?predicate ?object. 126 | } 127 | `.trim()); 128 | expect(args[1][1]).toBe('http://user.example/public/activities'); 129 | }); 130 | 131 | it('deletes the activities', () => { 132 | expect(queryEngine.executeUpdate).toHaveBeenCalledTimes(1); 133 | const args = queryEngine.executeUpdate.mock.calls; 134 | expect(args[0][0].trim()).toBe(` 135 | DELETE { 136 | "abc". 137 | "abc". 138 | } 139 | `.trim()); 140 | expect(args[0][1]).toBe('http://user.example/public/activities'); 141 | }); 142 | 143 | it('returns no results', () => { 144 | expect(results).toEqual([]); 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /test/unit/handlers/FindActivityHandler-test.js: -------------------------------------------------------------------------------- 1 | import FindActivityHandler from '../../../src/handlers/FindActivityHandler'; 2 | import { asList } from '../../../src/util'; 3 | import { namedNode } from '@rdfjs/data-model'; 4 | 5 | describe('a FindActivityHandler instance', () => { 6 | const queryEngine = { 7 | execute: jest.fn(async function*() { 8 | yield asList(namedNode('http://ex.org/likes/#1')); 9 | }), 10 | }; 11 | let handler; 12 | beforeEach(() => (handler = new FindActivityHandler())); 13 | 14 | describe('finding a like', () => { 15 | let results; 16 | 17 | beforeEach(async () => { 18 | // create a mock path 19 | const path = (async function* path() { 20 | yield { value: 'http://example.org/#thing1', termType: 'NamedNode' }; 21 | yield { value: 'http://example.org/#thing2', termType: 'NamedNode' }; 22 | yield 0; 23 | }()); 24 | const user = Promise.resolve('http://user.example/#me'); 25 | user.pim$storage = 'http://user.storage/'; 26 | path.root = { 27 | user, 28 | 'http://ex.org/likes/#1': { path: 1 }, 29 | }; 30 | 31 | // perform the activity 32 | const findActivity = handler.handle({ settings: { queryEngine } }, path); 33 | const iterator = findActivity(); 34 | results = []; 35 | for await (const result of iterator) 36 | results.push(result); 37 | }); 38 | 39 | it("queries the user's activities document", () => { 40 | expect(queryEngine.execute).toHaveBeenCalledTimes(2); 41 | const args = queryEngine.execute.mock.calls; 42 | expect(args[0][0].trim()).toBe(` 43 | SELECT ?activity WHERE { 44 | ?activity a ; 45 | ; 46 | . 47 | } 48 | `.trim()); 49 | expect(args[0][1]).toBe('http://user.storage/public/activities'); 50 | expect(args[1][0].trim()).toBe(` 51 | SELECT ?activity WHERE { 52 | ?activity a ; 53 | ; 54 | . 55 | } 56 | `.trim()); 57 | expect(args[1][1]).toBe('http://user.storage/public/activities'); 58 | }); 59 | 60 | it('returns result paths', () => { 61 | expect(results).toEqual([ 62 | { path: 1 }, 63 | { path: 1 }, 64 | ]); 65 | }); 66 | }); 67 | 68 | describe('finding a follow when no user storage link is available', () => { 69 | let results; 70 | 71 | beforeEach(async () => { 72 | // create a mock path 73 | const path = (async function* path() { 74 | yield { value: 'http://example.org/#thing1', termType: 'NamedNode' }; 75 | yield { value: 'http://example.org/#thing2', termType: 'NamedNode' }; 76 | yield 0; 77 | }()); 78 | const user = Promise.resolve('http://user.example/#me'); 79 | path.root = { 80 | user, 81 | 'http://ex.org/likes/#1': { path: 1 }, 82 | }; 83 | 84 | // perform the activity 85 | const findActivity = handler.handle({ settings: { queryEngine } }, path); 86 | const iterator = findActivity('https://www.w3.org/ns/activitystreams#Follow'); 87 | results = []; 88 | for await (const result of iterator) 89 | results.push(result); 90 | }); 91 | 92 | it("queries the user's activities document", () => { 93 | expect(queryEngine.execute).toHaveBeenCalledTimes(2); 94 | const args = queryEngine.execute.mock.calls; 95 | expect(args[0][0].trim()).toBe(` 96 | SELECT ?activity WHERE { 97 | ?activity a ; 98 | ; 99 | . 100 | } 101 | `.trim()); 102 | expect(args[0][1]).toBe('http://user.example/public/activities'); 103 | expect(args[1][0].trim()).toBe(` 104 | SELECT ?activity WHERE { 105 | ?activity a ; 106 | ; 107 | . 108 | } 109 | `.trim()); 110 | expect(args[1][1]).toBe('http://user.example/public/activities'); 111 | }); 112 | 113 | it('returns result paths', () => { 114 | expect(results).toEqual([ 115 | { path: 1 }, 116 | { path: 1 }, 117 | ]); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /test/unit/handlers/PutHandler-test.js: -------------------------------------------------------------------------------- 1 | import PutHandler from '../../../src/handlers/PutHandler'; 2 | import auth from 'solid-auth-client'; 3 | 4 | describe('a PutHandler', () => { 5 | let handler; 6 | 7 | beforeEach(() => { 8 | handler = new PutHandler(); 9 | }); 10 | 11 | describe('when called on a path with a body', () => { 12 | const root = { 13 | 'http://example.org/doc1': {}, 14 | 'https://example.org/doc2': {}, 15 | }; 16 | 17 | let path; 18 | beforeEach(() => { 19 | // Create a mock path 20 | path = (async function* () { 21 | yield { value: 'http://example.org/doc1', termType: 'NamedNode' }; 22 | yield { value: 'https://example.org/doc2#thing2', termType: 'NamedNode' }; 23 | yield { value: 'http://example.org/doc1#thing1', termType: 'NamedNode' }; 24 | yield null; 25 | }()); 26 | path.root = root; 27 | }); 28 | 29 | describe('with a body and a content type', () => { 30 | const body = 'result body'; 31 | const contentType = 'text/plain'; 32 | 33 | let results; 34 | beforeEach(async () => { 35 | const put = handler.handle({}, path); 36 | const iterator = put(body, contentType); 37 | results = []; 38 | for await (const result of iterator) 39 | results.push(result); 40 | }); 41 | 42 | it('returns a path for every document', () => { 43 | expect(results).toHaveLength(2); 44 | expect(results[0]).toBe(root['http://example.org/doc1']); 45 | expect(results[1]).toBe(root['https://example.org/doc2']); 46 | }); 47 | 48 | it('executes a PUT request with Turtle for every HTTP URL on the path', () => { 49 | const requestDetails = { 50 | method: 'PUT', 51 | body, 52 | headers: { 'Content-Type': contentType }, 53 | }; 54 | expect(auth.fetch).toHaveBeenCalledTimes(2); 55 | expect(auth.fetch).toHaveBeenCalledWith('http://example.org/doc1', requestDetails); 56 | expect(auth.fetch).toHaveBeenCalledWith('https://example.org/doc2', requestDetails); 57 | }); 58 | }); 59 | 60 | describe('without a content type', () => { 61 | beforeEach(async () => { 62 | const put = handler.handle({}, path); 63 | await put(); 64 | }); 65 | 66 | it('executes an empty PUT request for every HTTP URL on the path', () => { 67 | const requestDetails = { 68 | method: 'PUT', 69 | body: '', 70 | headers: { 'Content-Type': 'text/turtle' }, 71 | }; 72 | expect(auth.fetch).toHaveBeenCalledTimes(2); 73 | expect(auth.fetch).toHaveBeenCalledWith('http://example.org/doc1', requestDetails); 74 | expect(auth.fetch).toHaveBeenCalledWith('https://example.org/doc2', requestDetails); 75 | }); 76 | }); 77 | 78 | describe('with a body', () => { 79 | const body = 'result body'; 80 | 81 | beforeEach(async () => { 82 | const put = handler.handle({}, path); 83 | await put(body); 84 | }); 85 | 86 | it('executes a PUT request with Turtle for every HTTP URL on the path', () => { 87 | const requestDetails = { 88 | method: 'PUT', 89 | body, 90 | headers: { 'Content-Type': 'text/turtle' }, 91 | }; 92 | expect(auth.fetch).toHaveBeenCalledTimes(2); 93 | expect(auth.fetch).toHaveBeenCalledWith('http://example.org/doc1', requestDetails); 94 | expect(auth.fetch).toHaveBeenCalledWith('https://example.org/doc2', requestDetails); 95 | }); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /test/unit/handlers/SolidDeleteFunctionHandler-test.js: -------------------------------------------------------------------------------- 1 | import { namedNode, literal, blankNode } from '@rdfjs/data-model'; 2 | import SolidDeleteFunctionHandler from '../../../src/handlers/SolidDeleteFunctionHandler'; 3 | 4 | describe('a SolidDeleteFunctionHandler instance', () => { 5 | let handler; 6 | beforeEach(() => (handler = new SolidDeleteFunctionHandler())); 7 | 8 | async function* createPath() { 9 | yield namedNode('https://ex.org/#1'); 10 | yield namedNode('https://ex.org/#2'); 11 | yield literal('3'); 12 | yield blankNode('4'); 13 | } 14 | 15 | describe('extractObjects', () => { 16 | it('returns all objects that exist on the path when args is empty', async () => { 17 | const objects = await handler.extractObjects(null, createPath(), []); 18 | expect(objects).toEqual([ 19 | namedNode('https://ex.org/#1'), 20 | namedNode('https://ex.org/#2'), 21 | literal('3'), 22 | ]); 23 | }); 24 | 25 | it('only returns objects that exist on the path when args are given', async () => { 26 | const objects = await handler.extractObjects(null, createPath(), [ 27 | namedNode('https://ex.org/#2'), 28 | namedNode('https://ex.org/#3'), 29 | literal('2'), 30 | literal('3'), 31 | ]); 32 | expect(objects).toEqual([ 33 | namedNode('https://ex.org/#2'), 34 | literal('3'), 35 | ]); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/unit/handlers/SourcePathHandler-test.js: -------------------------------------------------------------------------------- 1 | import SourcePathHandler from '../../../src/handlers/SourcePathHandler'; 2 | import { PathFactory } from 'ldflex'; 3 | import SubjectPathResolver from '../../../src/resolvers/SubjectPathResolver'; 4 | 5 | jest.mock('ldflex'); 6 | jest.mock('../../../src/resolvers/SubjectPathResolver'); 7 | 8 | const path = {}; 9 | PathFactory.prototype.create.mockReturnValue(path); 10 | 11 | describe('a SourcePathHandler', () => { 12 | const factory = {}, source = {}, settings = { x: 1 }; 13 | let handler, subjectResolverFn; 14 | 15 | beforeEach(() => { 16 | handler = new SourcePathHandler(factory); 17 | subjectResolverFn = handler.handle({ settings }); 18 | }); 19 | 20 | it('returns a function', () => { 21 | expect(subjectResolverFn).toBeInstanceOf(Function); 22 | }); 23 | 24 | describe('the function', () => { 25 | let result; 26 | beforeEach(() => { 27 | result = subjectResolverFn(source); 28 | }); 29 | 30 | it('creates a path with a single resolver', () => { 31 | expect(PathFactory).toHaveBeenCalledTimes(1); 32 | const { resolvers } = PathFactory.mock.calls[0][0]; 33 | expect(resolvers).toHaveLength(1); 34 | expect(resolvers[0]).toBeInstanceOf(SubjectPathResolver); 35 | expect(PathFactory.mock.instances[0].create).toHaveBeenCalledTimes(1); 36 | expect(PathFactory.mock.instances[0].create).toHaveBeenCalledWith(settings, {}); 37 | expect(result).toBe(path); 38 | }); 39 | 40 | it('passes the source to the SubjectPathResolver', () => { 41 | expect(SubjectPathResolver).toHaveBeenCalledTimes(1); 42 | expect(SubjectPathResolver.mock.calls[0][0]).toBe(factory); 43 | expect(SubjectPathResolver.mock.calls[0][1]).toBe(source); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/unit/resolvers/SubjectPathResolver-test.js: -------------------------------------------------------------------------------- 1 | import { PathFactory } from 'ldflex'; 2 | import SubjectPathResolver from '../../../src/resolvers/SubjectPathResolver'; 3 | 4 | jest.mock('ldflex'); 5 | 6 | describe('a SubjectPathResolver', () => { 7 | const source = {}; 8 | let resolver; 9 | 10 | beforeEach(() => { 11 | resolver = new SubjectPathResolver(new PathFactory(), source); 12 | }); 13 | 14 | it('supports strings', () => { 15 | expect(resolver.supports('apple')).toBe(true); 16 | }); 17 | 18 | it('does not support Symbols', () => { 19 | expect(resolver.supports(Symbol('apple'))).toBe(false); 20 | }); 21 | 22 | it('resolves to a path that uses the given source', () => { 23 | const engine = {}; 24 | const createQueryEngine = jest.fn(() => engine); 25 | const settings = { createQueryEngine }; 26 | resolver.resolve('apple', { settings }); 27 | expect(createQueryEngine).toHaveBeenCalledTimes(1); 28 | expect(createQueryEngine.mock.calls[0][0]).toBe(source); 29 | 30 | const { create } = PathFactory.mock.instances[0]; 31 | expect(create).toHaveBeenCalledTimes(1); 32 | expect(create.mock.calls[0][0].queryEngine).toBe(engine); 33 | expect(create.mock.calls[0][1].subject.value).toBe('apple'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { Polly } from '@pollyjs/core'; 3 | import { setupPolly } from 'setup-polly-jest'; 4 | import FSPersister from '@pollyjs/persister-fs'; 5 | import NodeHttpAdapter from '@pollyjs/adapter-node-http'; 6 | 7 | const recordingsDir = resolve(__dirname, './assets/http'); 8 | 9 | Polly.register(FSPersister); 10 | Polly.register(NodeHttpAdapter); 11 | 12 | // Mocks HTTP requests using Polly.JS 13 | export function mockHttp() { 14 | return setupPolly({ 15 | adapters: ['node-http'], 16 | persister: 'fs', 17 | persisterOptions: { fs: { recordingsDir } }, 18 | recordFailedRequests: true, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /webpack/webpack.common.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const { ProgressPlugin, NormalModuleReplacementPlugin } = require('webpack'); 3 | 4 | module.exports = ({ outputDir }) => ({ 5 | mode: 'development', 6 | context: resolve(__dirname, '..'), 7 | entry: { 8 | 'solid-query-ldflex': '.', 9 | }, 10 | output: { 11 | path: resolve(outputDir), 12 | filename: '[name].bundle.js', 13 | libraryExport: 'default', 14 | library: ['solid', 'data'], 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | loader: 'babel-loader', 21 | exclude: /node_modules/, 22 | }, 23 | ], 24 | }, 25 | plugins: [ 26 | // Report progress 27 | new ProgressPlugin(), 28 | // Use latest readable-stream version (as opposed to stream-browserify) 29 | new NormalModuleReplacementPlugin(/^stream$/, require.resolve('readable-stream/readable-browser')), 30 | // Shim crypto for smaller bundle size 31 | new NormalModuleReplacementPlugin(/^crypto$/, require.resolve('../browser/crypto')), 32 | // Shim process to use faster process.nextTick implementation 33 | new NormalModuleReplacementPlugin(/process\/browser\.js$/, require.resolve('../browser/process')), 34 | // Shim setImmediate to a faster implementation 35 | new NormalModuleReplacementPlugin(/^setimmediate$/, require.resolve('../browser/setImmediate')), 36 | ], 37 | externals: { 38 | // Rely on external solid-auth-client at window.solid.auth 39 | 'solid-auth-client': ['solid', 'auth'], 40 | // Disable shims for supported browser features 41 | 'web-streams-polyfill': 'window', 42 | // Exclude the following unneeded modules 43 | '@comunica/actor-rdf-serialize-jsonld': 'null', 44 | 'graphql': 'null', 45 | 'graphql-to-sparql': 'null', 46 | }, 47 | devtool: 'source-map', 48 | }); 49 | -------------------------------------------------------------------------------- /webpack/webpack.demo.config.js: -------------------------------------------------------------------------------- 1 | const outputDir = './dist/demo/'; 2 | const common = require('./webpack.common.config')({ outputDir }); 3 | 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | 6 | const localAssets = [ 7 | 'user.html', 8 | ]; 9 | 10 | const externalAssets = [ 11 | 'solid-auth-client/dist-popup/popup.html', 12 | 'solid-auth-client/dist-lib/solid-auth-client.bundle.js', 13 | 'solid-auth-client/dist-lib/solid-auth-client.bundle.js.map', 14 | ]; 15 | 16 | module.exports = Object.assign({}, common, { 17 | plugins: [ 18 | ...common.plugins, 19 | new CopyWebpackPlugin(localAssets, { context: 'demo' }), 20 | new CopyWebpackPlugin(externalAssets.map(a => require.resolve(a))), 21 | ], 22 | devServer: { 23 | index: 'user.html', 24 | contentBase: outputDir, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /webpack/webpack.lib.config.js: -------------------------------------------------------------------------------- 1 | const outputDir = './dist/'; 2 | const common = require('./webpack.common.config')({ outputDir }); 3 | 4 | module.exports = Object.assign({}, common); 5 | -------------------------------------------------------------------------------- /webpack/webpack.rdflib.config.js: -------------------------------------------------------------------------------- 1 | const outputDir = './dist/'; 2 | const common = require('./webpack.common.config')({ outputDir }); 3 | 4 | module.exports = Object.assign({}, common, { 5 | entry: { 6 | 'solid-query-ldflex': './src/exports/rdflib', 7 | }, 8 | output: { 9 | ...common.output, 10 | filename: '[name].rdflib.js', 11 | }, 12 | externals: { 13 | ...common.externals, 14 | rdflib: '$rdf', 15 | }, 16 | }); 17 | --------------------------------------------------------------------------------