diff --git a/README.md b/README.md index 302155b..38d7241 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ LDE is an [Nx](https://nx.dev) monorepo that includes the following packages: - [ ] [@lde/pipeline](packages/pipeline): build pipelines that query, transform and enrich Linked Data - [x] [@lde/docgen](packages/docgen): generate documentation from RDF such as SHACL shapes - [x] [@lde/fastify-rdf](packages/fastify-rdf): Fastify plugin for serving RDF data with content negotiation +- [x] [@lde/ldp-server](packages/ldp-server): Fastify plugin implementing W3C Linked Data Platform 1.0 - [x] [@lde/sparql-importer](packages/sparql-importer): import data dumps to a local SPARQL endpoint for querying - [x] [@lde/sparql-monitor](packages/sparql-monitor): monitor SPARQL endpoints with periodic checks - [x] [@lde/sparql-qlever](packages/sparql-qlever): QLever SPARQL adapter for importing and serving data diff --git a/package-lock.json b/package-lock.json index dd54bd9..861d993 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -403,7 +404,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -413,7 +414,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -462,7 +463,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.0" @@ -1771,7 +1772,7 @@ "version": "7.28.2", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -8385,6 +8386,10 @@ "resolved": "packages/fastify-rdf", "link": true }, + "node_modules/@lde/ldp-server": { + "resolved": "packages/ldp-server", + "link": true + }, "node_modules/@lde/local-sparql-endpoint": { "resolved": "packages/local-sparql-endpoint", "link": true @@ -9506,7 +9511,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/@smessie/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.3.tgz", "integrity": "sha512-8FFE7psRtRWQT31/duqbmgnSf2++QLR2YH9kj5iwsHhnoqSvHdOY3SAN5e7dhc+60p2cNk7rv3HYOiXOapTEXQ==", - "dev": true, "license": "MIT", "dependencies": { "process": "^0.11.10", @@ -9544,6 +9548,7 @@ "integrity": "sha512-BBjg0QNuEEmJSoU/++JOXhrjWdu3PTyYeJWsvchsI0Aqtj8ICkz/DqlwtXbmZVZ5vuDPpTfFlwDBZe81zgShMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@swc-node/core": "^1.13.1", "@swc-node/sourcemap-support": "^0.5.0", @@ -9590,6 +9595,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.8" @@ -9815,6 +9821,7 @@ "integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -10153,6 +10160,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -10333,6 +10341,7 @@ "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/types": "8.38.0", @@ -11155,6 +11164,7 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -11237,6 +11247,7 @@ "integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -11291,6 +11302,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11778,22 +11790,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, "node_modules/bare-fs": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", @@ -12095,6 +12091,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -13009,6 +13006,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -14043,6 +14041,7 @@ "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -14104,6 +14103,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -17394,6 +17394,7 @@ "resolved": "https://registry.npmjs.org/ky/-/ky-0.33.3.tgz", "integrity": "sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==", "license": "MIT", + "peer": true, "engines": { "node": ">=14.16" }, @@ -18313,8 +18314,9 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", @@ -18818,6 +18820,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -19624,6 +19627,7 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.8.tgz", "integrity": "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==", "license": "Unlicense", + "peer": true, "engines": { "node": ">=12" }, @@ -19648,6 +19652,7 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -21668,7 +21673,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -23301,6 +23306,7 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23577,6 +23583,7 @@ "integrity": "sha512-zUMMKW0hjtOaLIm1cY9AqA0bMjvuGtKJVolzXQacIW9PHTnTjcsWF2+sbNLBhVrHwo+FJ1DzdNVaTWXOBWZgiQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cypress/request": "3.0.9", "@verdaccio/auth": "8.0.0-next-8.19", @@ -23727,6 +23734,7 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -23825,6 +23833,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -23942,7 +23951,6 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/web-streams-ponyfill/-/web-streams-ponyfill-1.4.2.tgz", "integrity": "sha512-LCHW+fE2UBJ2vjhqJujqmoxh1ytEDEr0dPO3CabMdMDJPKmsaxzS90V1Ar6LtNE5VHLqxR4YMEj1i4lzMAccIA==", - "dev": true, "license": "MIT" }, "node_modules/webidl-conversions": { @@ -24694,6 +24702,421 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "packages/ldp-server": { + "name": "@lde/ldp-server", + "version": "0.1.0", + "dependencies": { + "@lde/fastify-rdf": "0.1.0", + "fastify-plugin": "^5.0.0", + "n3": "^1.17.0", + "rdf-parse": "^3.0.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@rdfjs/types": "^2.0.0", + "fastify": "^5.0.0" + }, + "peerDependencies": { + "fastify": "^5.0.0" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-abstract-mediatyped": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-abstract-mediatyped/-/actor-abstract-mediatyped-2.10.0.tgz", + "integrity": "sha512-0o6WBujsMnIVcwvRJv6Nj+kKPLZzqBS3On48rm01Rh9T1/My0E/buJMXwgcARKCfMonc2mJ9zxpPCh5ilGEU2A==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-abstract-parse": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-abstract-parse/-/actor-abstract-parse-2.10.0.tgz", + "integrity": "sha512-0puCWF+y24EDOOAUUVVbC+tOf4UV+LzEbqi8T5v25jcVGCXyTqfra+bDywfrcv3adrVp18jLCJ46ycaH5xhy9Q==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "readable-stream": "^4.4.2" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-http-fetch": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-http-fetch/-/actor-http-fetch-2.10.2.tgz", + "integrity": "sha512-siHGx0TMVNb2gXvOroq0B3JE6uuS+4s+MsDkntqdBNVigwVYqLpNSKEaL5is8pputFfohJfDQY06lAHbfDNEcw==", + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/context-entries": "^2.10.0", + "@comunica/mediatortype-time": "^2.10.0", + "abort-controller": "^3.0.0", + "cross-fetch": "^4.0.0" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-http-proxy": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-http-proxy/-/actor-http-proxy-2.10.2.tgz", + "integrity": "sha512-3yUF8BCh4nwq8J6NRILEsyNrQNStkE9ggJ7hYwRfA1XcMgz1pANNaWJ2P2TEKH1jNinr23bL3JeuUZCm9Kz9dA==", + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/context-entries": "^2.10.0", + "@comunica/mediatortype-time": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-rdf-parse-html": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-html/-/actor-rdf-parse-html-2.10.0.tgz", + "integrity": "sha512-zgImXKpc+BN1i6lQiN1Qhlb1HbKdMIeJMOys6qbzRIijdK8GkGGChwhQp7Cso3lY1Nf4K7M3jPLZeQXeED2w7g==", + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/bus-rdf-parse-html": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "htmlparser2": "^9.0.0", + "readable-stream": "^4.4.2" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-rdf-parse-html-microdata": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-html-microdata/-/actor-rdf-parse-html-microdata-2.10.0.tgz", + "integrity": "sha512-JLfiDauq4SmpI6TDS4HaHzI6iJe1j8lSk5FRRYK6YVEu8eO28jPmxQJiOiwbQiYqsjsV7kON/WIZSoUELoI4Ig==", + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse-html": "^2.10.0", + "@comunica/core": "^2.10.0", + "microdata-rdf-streaming-parser": "^2.0.1" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-rdf-parse-html-rdfa": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-html-rdfa/-/actor-rdf-parse-html-rdfa-2.10.0.tgz", + "integrity": "sha512-9K3iaws9+FGl50oZi53hqyzhwjNKZ3mIr2zg/TAJZoapKvc14cthH17zKSSJrqI/NgBStRmZhBBkXcwfu1CANw==", + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse-html": "^2.10.0", + "@comunica/core": "^2.10.0", + "rdfa-streaming-parser": "^2.0.1" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-rdf-parse-html-script": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-html-script/-/actor-rdf-parse-html-script-2.10.0.tgz", + "integrity": "sha512-7XYqWchDquWnBLjG7rmmY+tdE81UZ8fPCU0Hn+vI39/MikNOpaiyr/ZYFqhogWFa9SkjmH0a7idVUzmjiwKRZQ==", + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/bus-rdf-parse-html": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "readable-stream": "^4.4.2", + "relative-to-absolute-iri": "^1.0.7" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-rdf-parse-jsonld": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-jsonld/-/actor-rdf-parse-jsonld-2.10.2.tgz", + "integrity": "sha512-K4fvD0zMU22KkQCqIFVT5Oy2FREEZ9CAo9u6kOcsMxEvg9aHGIM6hkaXR8I+1JCx1mDuEj3zQ8joR4tQh8fYCw==", + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "jsonld-context-parser": "^2.2.2", + "jsonld-streaming-parser": "^3.0.1", + "stream-to-string": "^1.2.0" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-rdf-parse-n3": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-n3/-/actor-rdf-parse-n3-2.10.0.tgz", + "integrity": "sha512-o1MAbwJxW4Br2WCZdhFoRmAiOP4mfogeQqJ4nqlsOkoMtQ45EvLHsotb3Kqhuk5V+vsTxyK5v/a4zylGtcU7VQ==", + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/types": "^2.10.0", + "n3": "^1.17.0" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-rdf-parse-rdfxml": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-rdfxml/-/actor-rdf-parse-rdfxml-2.10.0.tgz", + "integrity": "sha512-HoJN52shXY3cvYtsS0cpin9KXpW3L7g1leebyCRSqnlnHdJv5D6G0Ep8vyt2xhquKNbOQ7LnP5VhiDiqz73XDg==", + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/types": "^2.10.0", + "rdfxml-streaming-parser": "^2.2.3" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-rdf-parse-shaclc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-shaclc/-/actor-rdf-parse-shaclc-2.10.0.tgz", + "integrity": "sha512-i6tmuZuS+RtDiSXpQc3s/PxtCqwIguo4ANmVB20PK4VWgQgBwoPG7LlNcJ0xmuH/3Bv6C2Agn18PLF6dZX+fKw==", + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "readable-stream": "^4.4.2", + "shaclc-parse": "^1.4.0", + "stream-to-string": "^1.2.0" + } + }, + "packages/ldp-server/node_modules/@comunica/actor-rdf-parse-xml-rdfa": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-xml-rdfa/-/actor-rdf-parse-xml-rdfa-2.10.0.tgz", + "integrity": "sha512-68r/6B/fEyA1/OYleVuaPq47J+g4xJcJijpdL1wEj7CqjV+Xa+sDWRpNCyLcD/e1Y/g9UQmLz0ZnSpR00PFddA==", + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/types": "^2.10.0", + "rdfa-streaming-parser": "^2.0.1" + } + }, + "packages/ldp-server/node_modules/@comunica/bus-http": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/bus-http/-/bus-http-2.10.2.tgz", + "integrity": "sha512-MAYRF6uEBAuJ9dCPW2Uyne7w3lNwXFXKfa14XuPG5DFTDpgo/Z2pWupPrBsA1eIWMNJ6WOG6QyEv4rllSIBqlg==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@smessie/readable-web-to-node-stream": "^3.0.3", + "is-stream": "^2.0.1", + "readable-stream-node-to-web": "^1.0.1", + "web-streams-ponyfill": "^1.4.2" + } + }, + "packages/ldp-server/node_modules/@comunica/bus-init": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-init/-/bus-init-2.10.0.tgz", + "integrity": "sha512-hJejHa8sLVhQLFlduCVnhOd5aW3FCEz8wmWjyeLI3kiHFaQibnGVMhUuuNRX5f8bnnPuTdEiHc1nnYHuSi+j8A==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "readable-stream": "^4.4.2" + } + }, + "packages/ldp-server/node_modules/@comunica/bus-rdf-parse": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-parse/-/bus-rdf-parse-2.10.0.tgz", + "integrity": "sha512-EgCMZACfTG/+mayQpExWt0HoBT32BBVC1aS1lC43fXKBTxJ8kYrSrorVUuMACoh4dQVGTb+7j1j4K0hGNVzXGA==", + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-mediatyped": "^2.10.0", + "@comunica/actor-abstract-parse": "^2.10.0", + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "packages/ldp-server/node_modules/@comunica/bus-rdf-parse-html": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-parse-html/-/bus-rdf-parse-html-2.10.0.tgz", + "integrity": "sha512-RZliz4TtKP63QggoohGuIkGb6lq0BoYJ4aztKtGldWtPAVP/pdEvlDpiZWLB/j19g7S2aDLNY/lJtZ5efM1tHQ==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "packages/ldp-server/node_modules/@comunica/config-query-sparql": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@comunica/config-query-sparql/-/config-query-sparql-2.7.0.tgz", + "integrity": "sha512-rMnFgT7cz9+0z7wV4OzIMY5qM9/Z0mTGrR8y2JokoHyyTcBGOSajFmy61XCSLMCsLLG8qDXsJ4ClCCky3TGfqA==", + "license": "MIT" + }, + "packages/ldp-server/node_modules/@comunica/context-entries": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/context-entries/-/context-entries-2.10.0.tgz", + "integrity": "sha512-lmCYCcXxW8C6ecFH2whZCt31NT1ejb0P/sbytK7f4ctyA06Q8iYFEcYE4eWOXMdpfkwkcnz31x9XL77OGeSC2Q==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "jsonld-context-parser": "^2.2.2", + "sparqlalgebrajs": "^4.2.0" + } + }, + "packages/ldp-server/node_modules/@comunica/core": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/core/-/core-2.10.0.tgz", + "integrity": "sha512-onsGs2iKHUPRxxMOdx42vdxslk8q9FQZdRjQtHJ6SGiCpJwIL9ciBgPIOl2RL2YfzXHemr/0umeNOppRDcWhJA==", + "license": "MIT", + "dependencies": { + "@comunica/types": "^2.10.0", + "immutable": "^4.1.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "packages/ldp-server/node_modules/@comunica/mediator-combine-pipeline": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediator-combine-pipeline/-/mediator-combine-pipeline-2.10.0.tgz", + "integrity": "sha512-j7+/oUlbhKB4Rq6g9oNKU+e9cQL8U9z8tAUNhoXUSHajcr4huj0t1+riaOD109/DRWhV793ILhBDzgiZbHd7DA==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "packages/ldp-server/node_modules/@comunica/mediator-combine-union": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediator-combine-union/-/mediator-combine-union-2.10.0.tgz", + "integrity": "sha512-QbP4zP1i6nMDZ8teC0RoTz5E8pOpxDhWPBr1ylb2jzPUjPpMgrnbHYTondlN0Oau3SMEehItojg/LYDtPOP/GQ==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "packages/ldp-server/node_modules/@comunica/mediator-number": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediator-number/-/mediator-number-2.10.0.tgz", + "integrity": "sha512-0T8D1HGTu5Sd8iKb2dBjc6VRc/U4A15TAN6m561ra9pFlP+w31kby0ZYP6WWBHBobbUsX1LCvnbRQaAC4uWwVw==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "packages/ldp-server/node_modules/@comunica/mediator-race": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediator-race/-/mediator-race-2.10.0.tgz", + "integrity": "sha512-JiEtOLMkPnbjSLabVpE4VqDbu2ZKKnkUdATGBeWX+o+MjPw6c0hhw01RG4WY2rQhDyNl++nLQe3EowQh8xW9TA==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "packages/ldp-server/node_modules/@comunica/mediatortype-time": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediatortype-time/-/mediatortype-time-2.10.0.tgz", + "integrity": "sha512-nBz1exxrja1Tj8KSlSevG4Hw2u09tTh6gtNfVjI76i/e7muu4RUWVhi9b8PcwBNAfuUqRl+5OgOSa2X4W+6QlA==", + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "packages/ldp-server/node_modules/@comunica/types": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/types/-/types-2.10.0.tgz", + "integrity": "sha512-1UjPGbZcYrapBjMGUZedrIGcn9rOLpEOlJo1ZkWddFUGTwndVg9d4BZnQw+UnQzXMcLJcdKt94Zns8iEmBqARw==", + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@types/yargs": "^17.0.24", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "packages/ldp-server/node_modules/@types/readable-stream": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "packages/ldp-server/node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "packages/ldp-server/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "packages/ldp-server/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "packages/ldp-server/node_modules/jsonld-streaming-parser": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/jsonld-streaming-parser/-/jsonld-streaming-parser-3.4.0.tgz", + "integrity": "sha512-897CloyQgQidfkB04dLM5XaAXVX/cN9A2hvgHJo4y4jRhIpvg3KLMBBfcrswepV2N3T8c/Rp2JeFdWfVsbVZ7g==", + "license": "MIT", + "dependencies": { + "@bergos/jsonparse": "^1.4.0", + "@rdfjs/types": "*", + "@types/http-link-header": "^1.0.1", + "@types/readable-stream": "^2.3.13", + "buffer": "^6.0.3", + "canonicalize": "^1.0.1", + "http-link-header": "^1.0.2", + "jsonld-context-parser": "^2.4.0", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0" + } + }, + "packages/ldp-server/node_modules/rdf-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/rdf-parse/-/rdf-parse-3.0.0.tgz", + "integrity": "sha512-W+h4cEL299Va9XXmtbM6Cl3Mh6dMUgFC2a3q7nyohEOPjp/ZOxFS9zNfnJY1/5wnmaLXbydnJhahwkcnQ8kD/g==", + "license": "MIT", + "dependencies": { + "@comunica/actor-http-fetch": "^2.0.1", + "@comunica/actor-http-proxy": "^2.0.1", + "@comunica/actor-rdf-parse-html": "^2.0.1", + "@comunica/actor-rdf-parse-html-microdata": "^2.0.1", + "@comunica/actor-rdf-parse-html-rdfa": "^2.0.1", + "@comunica/actor-rdf-parse-html-script": "^2.0.1", + "@comunica/actor-rdf-parse-jsonld": "^2.0.1", + "@comunica/actor-rdf-parse-n3": "^2.0.1", + "@comunica/actor-rdf-parse-rdfxml": "^2.0.1", + "@comunica/actor-rdf-parse-shaclc": "^2.6.2", + "@comunica/actor-rdf-parse-xml-rdfa": "^2.0.1", + "@comunica/bus-http": "^2.0.1", + "@comunica/bus-init": "^2.0.1", + "@comunica/bus-rdf-parse": "^2.0.1", + "@comunica/bus-rdf-parse-html": "^2.0.1", + "@comunica/config-query-sparql": "^2.0.1", + "@comunica/core": "^2.0.1", + "@comunica/mediator-combine-pipeline": "^2.0.1", + "@comunica/mediator-combine-union": "^2.0.1", + "@comunica/mediator-number": "^2.0.1", + "@comunica/mediator-race": "^2.0.1", + "@rdfjs/types": "*", + "readable-stream": "^4.3.0", + "stream-to-string": "^1.2.0" + } + }, "packages/local-sparql-endpoint": { "name": "@lde/local-sparql-endpoint", "version": "0.0.3", diff --git a/packages/ldp-server/README.md b/packages/ldp-server/README.md new file mode 100644 index 0000000..9ab9a3a --- /dev/null +++ b/packages/ldp-server/README.md @@ -0,0 +1,136 @@ +# @lde/ldp-server + +A Fastify plugin implementing the [W3C Linked Data Platform (LDP) 1.0](https://www.w3.org/TR/ldp/) specification for storing RDF resources within containers. + +## Features + +- **LDP Basic Containers (LDP-BC)** for organizing resources +- **RDF sources (LDP-RS)** with full content negotiation via [@lde/fastify-rdf](../fastify-rdf) +- **In-memory storage** with a `Store` interface for custom backends +- **Conditional requests** with ETag support (`If-Match` headers) +- **Standard LDP headers** (`Link`, `Accept-Post`, `Allow`) + +## Installation + +```bash +npm install @lde/ldp-server +``` + +## Usage + +```typescript +import Fastify from 'fastify'; +import {ldpServer} from '@lde/ldp-server'; + +const app = Fastify(); +await app.register(ldpServer); +await app.listen({port: 3000}); +``` + +### With custom store + +```typescript +import {ldpServer, MemoryStore} from '@lde/ldp-server'; + +const store = new MemoryStore(); +await app.register(ldpServer, {store}); +``` + +## HTTP Methods + +| Method | Description | Notes | +| ------- | -------------------------------- | ------------------------------- | +| GET | Retrieve resource | Content negotiation via Accept | +| HEAD | Retrieve headers only | Same as GET without body | +| OPTIONS | List allowed methods | Returns `Allow`, `Accept-Post` | +| POST | Create resource in container | Uses `Slug` header for URI hint | +| PUT | Replace resource | Conditional via `If-Match` | +| DELETE | Remove resource | Fails if container is non-empty | + +## Examples + +### Create a container + +```bash +curl -X POST http://localhost:3000/ \ + -H "Slug: my-dataset" \ + -H 'Link: ; rel="type"' \ + -H "Content-Type: text/turtle" \ + -d "" +``` + +### Create an RDF resource + +```bash +curl -X POST http://localhost:3000/my-dataset/ \ + -H "Slug: resource1" \ + -H "Content-Type: text/turtle" \ + -d "<> a ." +``` + +### Retrieve a resource + +```bash +curl http://localhost:3000/my-dataset/resource1 \ + -H "Accept: text/turtle" +``` + +### Update a resource + +```bash +curl -X PUT http://localhost:3000/my-dataset/resource1 \ + -H "Content-Type: text/turtle" \ + -H 'If-Match: "abc123"' \ + -d "<> a ." +``` + +### Delete a resource + +```bash +curl -X DELETE http://localhost:3000/my-dataset/resource1 +``` + +## Custom Store Implementation + +Implement the `Store` interface to use a different backend: + +```typescript +import type {Store, StoreResult, StoredResource, CreateResourceOptions} from '@lde/ldp-server'; +import type {DatasetCore} from '@rdfjs/types'; + +class MyStore implements Store { + async exists(uri: string): Promise { /* ... */ } + async get(uri: string): Promise> { /* ... */ } + async create(containerUri: string, options: CreateResourceOptions): Promise> { /* ... */ } + async replace(uri: string, data: DatasetCore, ifMatch?: string): Promise> { /* ... */ } + async delete(uri: string, ifMatch?: string): Promise> { /* ... */ } + async getContained(containerUri: string): Promise> { /* ... */ } + async initialize(rootUri: string): Promise { /* ... */ } +} +``` + +## LDP Compliance + +This package implements a subset of LDP 1.0: + +- ✅ LDP-RS (RDF Source) +- ✅ LDP-BC (Basic Container) +- ✅ `ldp:contains` membership triples +- ✅ `Slug` header for resource naming +- ✅ Conditional requests (`If-Match`) +- ❌ LDP-NR (Non-RDF Source / binary resources) +- ❌ LDP-DC (Direct Container) +- ❌ LDP-IC (Indirect Container) + +## Validation + +```bash +npx nx build ldp-server +npx nx test ldp-server +npx nx lint ldp-server +npx nx typecheck ldp-server +``` + +## License + +MIT diff --git a/packages/ldp-server/eslint.config.mjs b/packages/ldp-server/eslint.config.mjs new file mode 100644 index 0000000..caa697a --- /dev/null +++ b/packages/ldp-server/eslint.config.mjs @@ -0,0 +1,23 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + '{projectRoot}/vite.config.{js,ts,mjs,mts}', + ], + ignoredDependencies: ['@rdfjs/types'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/packages/ldp-server/package.json b/packages/ldp-server/package.json new file mode 100644 index 0000000..570828d --- /dev/null +++ b/packages/ldp-server/package.json @@ -0,0 +1,41 @@ +{ + "name": "@lde/ldp-server", + "version": "0.1.0", + "description": "Fastify plugin implementing W3C Linked Data Platform 1.0 for RDF resources", + "homepage": "https://github.com/ldengine/lde/tree/main/packages/ldp-server", + "repository": { + "url": "https://github.com/ldengine/lde", + "directory": "packages/ldp-server" + }, + "type": "module", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "development": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "!**/*.tsbuildinfo" + ], + "dependencies": { + "@lde/fastify-rdf": "0.1.0", + "fastify-plugin": "^5.0.0", + "n3": "^1.17.0", + "rdf-parse": "^3.0.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@rdfjs/types": "^2.0.0", + "fastify": "^5.0.0" + }, + "peerDependencies": { + "fastify": "^5.0.0" + } +} diff --git a/packages/ldp-server/src/handlers/delete.ts b/packages/ldp-server/src/handlers/delete.ts new file mode 100644 index 0000000..7482397 --- /dev/null +++ b/packages/ldp-server/src/handlers/delete.ts @@ -0,0 +1,45 @@ +import type {FastifyRequest, FastifyReply} from 'fastify'; +import type {Store} from '../store/index.js'; + +interface DeleteRequest extends FastifyRequest { + headers: FastifyRequest['headers'] & { + 'if-match'?: string; + }; +} + +export async function handleDelete( + request: DeleteRequest, + reply: FastifyReply, + store: Store, + baseUri: string +): Promise { + const resourceUri = `${baseUri}${request.url}`; + + // Get If-Match header for conditional delete + const ifMatch = request.headers['if-match']; + + const result = await store.delete(resourceUri, ifMatch); + + if (!result.ok) { + if (result.error.type === 'not-found') { + reply.status(404).send({error: 'Not Found', uri: result.error.uri}); + return; + } + if (result.error.type === 'precondition-failed') { + reply.status(412).send({error: 'Precondition Failed', message: result.error.message}); + return; + } + if (result.error.type === 'not-empty') { + reply.status(409).send({ + error: 'Conflict', + message: 'Cannot delete non-empty container', + uri: result.error.uri, + }); + return; + } + reply.status(500).send({error: 'Internal Server Error'}); + return; + } + + reply.status(204).send(); +} diff --git a/packages/ldp-server/src/handlers/get.ts b/packages/ldp-server/src/handlers/get.ts new file mode 100644 index 0000000..8da3019 --- /dev/null +++ b/packages/ldp-server/src/handlers/get.ts @@ -0,0 +1,28 @@ +import type {FastifyRequest, FastifyReply} from 'fastify'; +import type {Store} from '../store/index.js'; +import {setLdpHeaders} from './shared.js'; + +export async function handleGet( + request: FastifyRequest, + reply: FastifyReply, + store: Store, + baseUri: string +): Promise { + const resourceUri = `${baseUri}${request.url}`; + const result = await store.get(resourceUri); + + if (!result.ok) { + if (result.error.type === 'not-found') { + reply.status(404).send({error: 'Not Found', uri: result.error.uri}); + return; + } + reply.status(500).send({error: 'Internal Server Error'}); + return; + } + + const resource = result.value; + setLdpHeaders(reply, resource.metadata); + + // For HEAD requests, Fastify automatically omits the body + await reply.sendRdf(resource.data); +} diff --git a/packages/ldp-server/src/handlers/index.ts b/packages/ldp-server/src/handlers/index.ts new file mode 100644 index 0000000..87fb6a7 --- /dev/null +++ b/packages/ldp-server/src/handlers/index.ts @@ -0,0 +1,5 @@ +export {handleGet} from './get.js'; +export {handleOptions} from './options.js'; +export {handlePost} from './post.js'; +export {handlePut} from './put.js'; +export {handleDelete} from './delete.js'; diff --git a/packages/ldp-server/src/handlers/options.ts b/packages/ldp-server/src/handlers/options.ts new file mode 100644 index 0000000..1e7fe18 --- /dev/null +++ b/packages/ldp-server/src/handlers/options.ts @@ -0,0 +1,33 @@ +import type {FastifyRequest, FastifyReply} from 'fastify'; +import type {Store} from '../store/index.js'; +import {setLdpHeaders} from './shared.js'; + +const RESOURCE_METHODS = 'GET, HEAD, PUT, DELETE, OPTIONS'; +const CONTAINER_METHODS = 'GET, HEAD, POST, PUT, DELETE, OPTIONS'; + +export async function handleOptions( + request: FastifyRequest, + reply: FastifyReply, + store: Store, + baseUri: string +): Promise { + const resourceUri = `${baseUri}${request.url}`; + const result = await store.get(resourceUri); + + if (!result.ok) { + if (result.error.type === 'not-found') { + reply.status(404).send({error: 'Not Found', uri: result.error.uri}); + return; + } + reply.status(500).send({error: 'Internal Server Error'}); + return; + } + + const resource = result.value; + const isContainer = resource.metadata.type === 'container'; + + setLdpHeaders(reply, resource.metadata); + reply.header('Allow', isContainer ? CONTAINER_METHODS : RESOURCE_METHODS); + + reply.status(204).send(); +} diff --git a/packages/ldp-server/src/handlers/post.ts b/packages/ldp-server/src/handlers/post.ts new file mode 100644 index 0000000..69c249e --- /dev/null +++ b/packages/ldp-server/src/handlers/post.ts @@ -0,0 +1,136 @@ +import type {FastifyRequest, FastifyReply} from 'fastify'; +import type {DatasetCore, Quad} from '@rdfjs/types'; +import {rdfParser} from 'rdf-parse'; +import {Store as N3Store} from 'n3'; +import {Readable} from 'stream'; +import type {Store} from '../store/index.js'; +import {LDP} from '../types.js'; + +interface PostRequest extends FastifyRequest { + headers: FastifyRequest['headers'] & { + slug?: string; + link?: string | string[]; + }; +} + +export async function handlePost( + request: PostRequest, + reply: FastifyReply, + store: Store, + baseUri: string +): Promise { + const containerUri = `${baseUri}${request.url}`; + + // Check if container exists + const containerResult = await store.get(containerUri); + if (!containerResult.ok) { + if (containerResult.error.type === 'not-found') { + reply.status(404).send({error: 'Not Found', uri: containerResult.error.uri}); + return; + } + reply.status(500).send({error: 'Internal Server Error'}); + return; + } + + if (containerResult.value.metadata.type !== 'container') { + reply.status(405).send({error: 'Method Not Allowed', message: 'POST is only allowed on containers'}); + return; + } + + // Parse request body + const contentType = request.headers['content-type']; + if (!contentType) { + reply.status(400).send({error: 'Bad Request', message: 'Content-Type header is required'}); + return; + } + + let data: DatasetCore; + try { + data = await parseRdf(request.body as string, contentType, containerUri); + } catch (error) { + reply.status(400).send({ + error: 'Bad Request', + message: `Failed to parse RDF: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + return; + } + + // Check if creating a container + const isContainer = isContainerCreation(request.headers.link); + + // Get slug for URI hint + const slug = request.headers.slug; + + const result = await store.create(containerUri, { + slug, + data, + isContainer, + }); + + if (!result.ok) { + if (result.error.type === 'not-found') { + reply.status(404).send({error: 'Not Found', uri: result.error.uri}); + return; + } + if (result.error.type === 'invalid-container') { + reply.status(405).send({error: 'Method Not Allowed', message: 'POST is only allowed on containers'}); + return; + } + reply.status(500).send({error: 'Internal Server Error'}); + return; + } + + reply.header('Location', result.value.uri); + reply.header('ETag', result.value.etag); + + // Set Link headers + const linkHeaders = [`<${LDP.Resource}>; rel="type"`]; + if (isContainer) { + linkHeaders.push(`<${LDP.BasicContainer}>; rel="type"`); + } else { + linkHeaders.push(`<${LDP.RDFSource}>; rel="type"`); + } + reply.header('Link', linkHeaders); + + reply.status(201).send(); +} + +function isContainerCreation(linkHeader: string | string[] | undefined): boolean { + if (!linkHeader) { + return false; + } + + const links = Array.isArray(linkHeader) ? linkHeader : [linkHeader]; + + for (const link of links) { + // Parse Link header format: ; rel="type" + if (link.includes(LDP.BasicContainer) && link.includes('rel="type"')) { + return true; + } + if (link.includes(LDP.Container) && link.includes('rel="type"')) { + return true; + } + } + + return false; +} + +async function parseRdf( + body: string, + contentType: string, + baseIri: string +): Promise { + const store = new N3Store(); + + return new Promise((resolve, reject) => { + const textStream = Readable.from([body]); + const quadStream = rdfParser.parse(textStream, { + contentType, + baseIRI: baseIri, + }); + + quadStream.on('data', (quad: Quad) => store.add(quad)); + quadStream.on('error', reject); + quadStream.on('end', () => resolve(store)); + }); +} diff --git a/packages/ldp-server/src/handlers/put.ts b/packages/ldp-server/src/handlers/put.ts new file mode 100644 index 0000000..b7e64fd --- /dev/null +++ b/packages/ldp-server/src/handlers/put.ts @@ -0,0 +1,99 @@ +import type {FastifyRequest, FastifyReply} from 'fastify'; +import type {DatasetCore, Quad} from '@rdfjs/types'; +import {rdfParser} from 'rdf-parse'; +import {Store as N3Store} from 'n3'; +import {Readable} from 'stream'; +import type {Store} from '../store/index.js'; +import {setLdpHeaders} from './shared.js'; + +interface PutRequest extends FastifyRequest { + headers: FastifyRequest['headers'] & { + 'if-match'?: string; + }; +} + +export async function handlePut( + request: PutRequest, + reply: FastifyReply, + store: Store, + baseUri: string +): Promise { + const resourceUri = `${baseUri}${request.url}`; + + // Check if resource exists + const existsResult = await store.get(resourceUri); + if (!existsResult.ok) { + if (existsResult.error.type === 'not-found') { + reply.status(404).send({error: 'Not Found', uri: existsResult.error.uri}); + return; + } + reply.status(500).send({error: 'Internal Server Error'}); + return; + } + + // Parse request body + const contentType = request.headers['content-type']; + if (!contentType) { + reply.status(400).send({error: 'Bad Request', message: 'Content-Type header is required'}); + return; + } + + let data: DatasetCore; + try { + data = await parseRdf(request.body as string, contentType, resourceUri); + } catch (error) { + reply.status(400).send({ + error: 'Bad Request', + message: `Failed to parse RDF: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + return; + } + + // Get If-Match header for conditional update + const ifMatch = request.headers['if-match']; + + const result = await store.replace(resourceUri, data, ifMatch); + + if (!result.ok) { + if (result.error.type === 'not-found') { + reply.status(404).send({error: 'Not Found', uri: result.error.uri}); + return; + } + if (result.error.type === 'precondition-failed') { + reply.status(412).send({error: 'Precondition Failed', message: result.error.message}); + return; + } + reply.status(500).send({error: 'Internal Server Error'}); + return; + } + + // Get updated resource for headers + const updatedResult = await store.get(resourceUri); + if (updatedResult.ok) { + setLdpHeaders(reply, updatedResult.value.metadata); + } else { + reply.header('ETag', result.value.etag); + } + + reply.status(204).send(); +} + +async function parseRdf( + body: string, + contentType: string, + baseIri: string +): Promise { + const store = new N3Store(); + + return new Promise((resolve, reject) => { + const textStream = Readable.from([body]); + const quadStream = rdfParser.parse(textStream, { + contentType, + baseIRI: baseIri, + }); + + quadStream.on('data', (quad: Quad) => store.add(quad)); + quadStream.on('error', reject); + quadStream.on('end', () => resolve(store)); + }); +} diff --git a/packages/ldp-server/src/handlers/shared.ts b/packages/ldp-server/src/handlers/shared.ts new file mode 100644 index 0000000..f3f3dc8 --- /dev/null +++ b/packages/ldp-server/src/handlers/shared.ts @@ -0,0 +1,30 @@ +import type {FastifyReply} from 'fastify'; +import type {ResourceMetadata} from '../types.js'; +import {LDP} from '../types.js'; + +/** + * Set standard LDP headers on a response. + */ +export function setLdpHeaders( + reply: FastifyReply, + metadata: ResourceMetadata +): void { + // ETag header + reply.header('ETag', metadata.etag); + + // Link headers for LDP types + const linkHeaders: string[] = [`<${LDP.Resource}>; rel="type"`]; + + if (metadata.type === 'container') { + linkHeaders.push(`<${LDP.BasicContainer}>; rel="type"`); + // Containers accept POST with RDF content types + reply.header('Accept-Post', 'text/turtle, application/ld+json, application/n-triples, application/n-quads'); + } else { + linkHeaders.push(`<${LDP.RDFSource}>; rel="type"`); + } + + reply.header('Link', linkHeaders); + + // Last-Modified header + reply.header('Last-Modified', metadata.modified.toUTCString()); +} diff --git a/packages/ldp-server/src/index.ts b/packages/ldp-server/src/index.ts new file mode 100644 index 0000000..7acd2ad --- /dev/null +++ b/packages/ldp-server/src/index.ts @@ -0,0 +1,12 @@ +export {ldpServer} from './plugin.js'; +export {MemoryStore} from './store/index.js'; +export type { + Store, + StoreResult, + StoreError, + StoredResource, + ResourceMetadata, + CreateResourceOptions, +} from './store/index.js'; +export type {LdpServerOptions, ResourceType} from './types.js'; +export {LDP, DCTerms} from './types.js'; diff --git a/packages/ldp-server/src/plugin.ts b/packages/ldp-server/src/plugin.ts new file mode 100644 index 0000000..81711f3 --- /dev/null +++ b/packages/ldp-server/src/plugin.ts @@ -0,0 +1,81 @@ +import type {FastifyInstance, FastifyRequest} from 'fastify'; +import fp from 'fastify-plugin'; +import {fastifyRdf} from '@lde/fastify-rdf'; +import type {LdpServerOptions} from './types.js'; +import {MemoryStore} from './store/index.js'; +import { + handleGet, + handleOptions, + handlePost, + handlePut, + handleDelete, +} from './handlers/index.js'; + +function getBaseUri(request: FastifyRequest): string { + return `${request.protocol}://${request.hostname}`; +} + +async function ldpServerPlugin( + fastify: FastifyInstance, + options: LdpServerOptions +): Promise { + const store = options.store ?? new MemoryStore(); + + // Register fastify-rdf for content negotiation + await fastify.register(fastifyRdf); + + // Add content type parser for RDF types + const rdfContentTypes = [ + 'text/turtle', + 'application/ld+json', + 'application/n-triples', + 'application/n-quads', + 'application/rdf+xml', + ]; + + for (const contentType of rdfContentTypes) { + fastify.addContentTypeParser( + contentType, + {parseAs: 'string'}, + (_request, payload, done) => { + done(null, payload); + } + ); + } + + // Add hook to ensure root container exists + let rootInitialized = false; + fastify.addHook('preHandler', async (request, _reply) => { + if (!rootInitialized) { + await store.initialize(`${getBaseUri(request)}/`); + rootInitialized = true; + } + }); + + // Register routes + // Note: Fastify 5 auto-generates HEAD handlers for GET routes + fastify.get('/*', async (request, reply) => { + await handleGet(request, reply, store, getBaseUri(request)); + }); + + fastify.options('/*', async (request, reply) => { + await handleOptions(request, reply, store, getBaseUri(request)); + }); + + fastify.post('/*', async (request, reply) => { + await handlePost(request, reply, store, getBaseUri(request)); + }); + + fastify.put('/*', async (request, reply) => { + await handlePut(request, reply, store, getBaseUri(request)); + }); + + fastify.delete('/*', async (request, reply) => { + await handleDelete(request, reply, store, getBaseUri(request)); + }); +} + +export const ldpServer = fp(ldpServerPlugin, { + name: '@lde/ldp-server', + fastify: '5.x', +}); diff --git a/packages/ldp-server/src/store/index.ts b/packages/ldp-server/src/store/index.ts new file mode 100644 index 0000000..7ad63d5 --- /dev/null +++ b/packages/ldp-server/src/store/index.ts @@ -0,0 +1,9 @@ +export {MemoryStore} from './memory-store.js'; +export type { + Store, + StoreResult, + StoreError, + StoredResource, + ResourceMetadata, + CreateResourceOptions, +} from './store.js'; diff --git a/packages/ldp-server/src/store/memory-store.ts b/packages/ldp-server/src/store/memory-store.ts new file mode 100644 index 0000000..0f147d9 --- /dev/null +++ b/packages/ldp-server/src/store/memory-store.ts @@ -0,0 +1,324 @@ +import type {DatasetCore} from '@rdfjs/types'; +import {Store as N3Store, DataFactory} from 'n3'; +import type { + Store, + StoreResult, + StoredResource, + ResourceMetadata, + CreateResourceOptions, +} from './store.js'; +import {LDP, DCTerms} from '../types.js'; + +const {namedNode, literal, quad} = DataFactory; + +/** + * In-memory implementation of the Store interface. + */ +export class MemoryStore implements Store { + private resources = new Map(); + + async exists(uri: string): Promise { + return this.resources.has(normalizeUri(uri)); + } + + async get(uri: string): Promise> { + const normalized = normalizeUri(uri); + const resource = this.resources.get(normalized); + if (!resource) { + return {ok: false, error: {type: 'not-found', uri: normalized}}; + } + + // For containers, include ldp:contains triples + if (resource.metadata.type === 'container') { + const data = new N3Store([...resource.data]); + const containerUri = normalizeUri(resource.metadata.uri); + + for (const [, stored] of this.resources) { + if (stored.metadata.container === containerUri) { + data.add( + quad( + namedNode(containerUri), + namedNode(LDP.contains), + namedNode(stored.metadata.uri) + ) + ); + } + } + + return { + ok: true, + value: { + metadata: resource.metadata, + data, + }, + }; + } + + return {ok: true, value: resource}; + } + + async create( + containerUri: string, + options: CreateResourceOptions + ): Promise> { + const normalizedContainer = normalizeUri(containerUri); + const container = this.resources.get(normalizedContainer); + + if (!container) { + return { + ok: false, + error: {type: 'not-found', uri: normalizedContainer}, + }; + } + + if (container.metadata.type !== 'container') { + return { + ok: false, + error: {type: 'invalid-container', uri: normalizedContainer}, + }; + } + + const resourceName = options.slug ?? generateId(); + let resourceUri = `${normalizedContainer}${resourceName}`; + if (options.isContainer) { + resourceUri = ensureTrailingSlash(resourceUri); + } + + // Check for conflicts + if (this.resources.has(resourceUri)) { + // Try with a suffix + const uniqueName = `${resourceName}-${generateId()}`; + resourceUri = `${normalizedContainer}${uniqueName}`; + if (options.isContainer) { + resourceUri = ensureTrailingSlash(resourceUri); + } + } + + const etag = generateEtag(); + const now = new Date(); + + // Build resource data with type triples + const data = new N3Store([...options.data]); + const subject = namedNode(resourceUri); + + data.add(quad(subject, namedNode(LDP.Resource), subject)); + + if (options.isContainer) { + data.add( + quad( + subject, + namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), + namedNode(LDP.BasicContainer) + ) + ); + } else { + data.add( + quad( + subject, + namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), + namedNode(LDP.RDFSource) + ) + ); + } + + data.add( + quad( + subject, + namedNode(DCTerms.modified), + literal(now.toISOString(), namedNode('http://www.w3.org/2001/XMLSchema#dateTime')) + ) + ); + + const metadata: ResourceMetadata = { + uri: resourceUri, + etag, + type: options.isContainer ? 'container' : 'resource', + modified: now, + container: normalizedContainer, + }; + + this.resources.set(resourceUri, {metadata, data}); + + return {ok: true, value: {uri: resourceUri, etag}}; + } + + async replace( + uri: string, + data: DatasetCore, + ifMatch?: string + ): Promise> { + const normalized = normalizeUri(uri); + const existing = this.resources.get(normalized); + + if (!existing) { + return {ok: false, error: {type: 'not-found', uri: normalized}}; + } + + if (ifMatch && existing.metadata.etag !== ifMatch) { + return { + ok: false, + error: { + type: 'precondition-failed', + message: `ETag mismatch: expected ${existing.metadata.etag}, got ${ifMatch}`, + }, + }; + } + + const etag = generateEtag(); + const now = new Date(); + + // Build new data with type triples preserved + const newData = new N3Store([...data]); + const subject = namedNode(normalized); + + newData.add(quad(subject, namedNode(LDP.Resource), subject)); + + if (existing.metadata.type === 'container') { + newData.add( + quad( + subject, + namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), + namedNode(LDP.BasicContainer) + ) + ); + } else { + newData.add( + quad( + subject, + namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), + namedNode(LDP.RDFSource) + ) + ); + } + + newData.add( + quad( + subject, + namedNode(DCTerms.modified), + literal(now.toISOString(), namedNode('http://www.w3.org/2001/XMLSchema#dateTime')) + ) + ); + + this.resources.set(normalized, { + metadata: { + ...existing.metadata, + etag, + modified: now, + }, + data: newData, + }); + + return {ok: true, value: {etag}}; + } + + async delete(uri: string, ifMatch?: string): Promise> { + const normalized = normalizeUri(uri); + const existing = this.resources.get(normalized); + + if (!existing) { + return {ok: false, error: {type: 'not-found', uri: normalized}}; + } + + if (ifMatch && existing.metadata.etag !== ifMatch) { + return { + ok: false, + error: { + type: 'precondition-failed', + message: `ETag mismatch: expected ${existing.metadata.etag}, got ${ifMatch}`, + }, + }; + } + + // Check if container is non-empty + if (existing.metadata.type === 'container') { + for (const [, stored] of this.resources) { + if (stored.metadata.container === normalized) { + return {ok: false, error: {type: 'not-empty', uri: normalized}}; + } + } + } + + this.resources.delete(normalized); + return {ok: true, value: undefined}; + } + + async getContained(containerUri: string): Promise> { + const normalized = normalizeUri(containerUri); + const container = this.resources.get(normalized); + + if (!container) { + return {ok: false, error: {type: 'not-found', uri: normalized}}; + } + + if (container.metadata.type !== 'container') { + return {ok: false, error: {type: 'invalid-container', uri: normalized}}; + } + + const contained: string[] = []; + for (const [, stored] of this.resources) { + if (stored.metadata.container === normalized) { + contained.push(stored.metadata.uri); + } + } + + return {ok: true, value: contained}; + } + + async initialize(rootUri: string): Promise { + const normalized = ensureTrailingSlash(normalizeUri(rootUri)); + + if (this.resources.has(normalized)) { + return; + } + + const etag = generateEtag(); + const now = new Date(); + + const data = new N3Store(); + const subject = namedNode(normalized); + + data.add( + quad( + subject, + namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), + namedNode(LDP.BasicContainer) + ) + ); + data.add( + quad( + subject, + namedNode(DCTerms.modified), + literal(now.toISOString(), namedNode('http://www.w3.org/2001/XMLSchema#dateTime')) + ) + ); + + this.resources.set(normalized, { + metadata: { + uri: normalized, + etag, + type: 'container', + modified: now, + container: null, + }, + data, + }); + } +} + +function normalizeUri(uri: string): string { + // Remove query strings and fragments + const url = new URL(uri, 'http://localhost'); + return `${url.origin}${url.pathname}`; +} + +function ensureTrailingSlash(uri: string): string { + return uri.endsWith('/') ? uri : `${uri}/`; +} + +function generateId(): string { + return Math.random().toString(36).substring(2, 10); +} + +function generateEtag(): string { + return `"${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 8)}"`; +} diff --git a/packages/ldp-server/src/store/store.ts b/packages/ldp-server/src/store/store.ts new file mode 100644 index 0000000..7912496 --- /dev/null +++ b/packages/ldp-server/src/store/store.ts @@ -0,0 +1,8 @@ +export type { + Store, + StoreResult, + StoreError, + StoredResource, + ResourceMetadata, + CreateResourceOptions, +} from '../types.js'; diff --git a/packages/ldp-server/src/types.ts b/packages/ldp-server/src/types.ts new file mode 100644 index 0000000..9b40766 --- /dev/null +++ b/packages/ldp-server/src/types.ts @@ -0,0 +1,129 @@ +import type {DatasetCore} from '@rdfjs/types'; + +/** + * LDP namespace and type URIs. + */ +export const LDP = { + namespace: 'http://www.w3.org/ns/ldp#', + Resource: 'http://www.w3.org/ns/ldp#Resource', + RDFSource: 'http://www.w3.org/ns/ldp#RDFSource', + Container: 'http://www.w3.org/ns/ldp#Container', + BasicContainer: 'http://www.w3.org/ns/ldp#BasicContainer', + contains: 'http://www.w3.org/ns/ldp#contains', +} as const; + +/** + * DCTerms namespace for metadata. + */ +export const DCTerms = { + namespace: 'http://purl.org/dc/terms/', + modified: 'http://purl.org/dc/terms/modified', +} as const; + +/** + * Resource type: container or regular RDF resource. + */ +export type ResourceType = 'container' | 'resource'; + +/** + * Metadata about a stored resource. + */ +export interface ResourceMetadata { + uri: string; + etag: string; + type: ResourceType; + modified: Date; + container: string | null; +} + +/** + * A stored resource with its metadata and RDF data. + */ +export interface StoredResource { + metadata: ResourceMetadata; + data: DatasetCore; +} + +/** + * Options for creating a new resource. + */ +export interface CreateResourceOptions { + slug?: string; + data: DatasetCore; + isContainer: boolean; +} + +/** + * Result type for store operations. + */ +export type StoreResult = + | {ok: true; value: T} + | {ok: false; error: StoreError}; + +/** + * Error types that can occur during store operations. + */ +export type StoreError = + | {type: 'not-found'; uri: string} + | {type: 'conflict'; message: string} + | {type: 'precondition-failed'; message: string} + | {type: 'not-empty'; uri: string} + | {type: 'invalid-container'; uri: string}; + +/** + * Plugin options for configuring the LDP server. + */ +export interface LdpServerOptions { + /** + * The store to use for persisting resources. + * Defaults to an in-memory store. + */ + store?: Store; +} + +/** + * Interface for resource storage backends. + */ +export interface Store { + /** + * Check if a resource exists at the given URI. + */ + exists(uri: string): Promise; + + /** + * Get a resource by its URI. + */ + get(uri: string): Promise>; + + /** + * Create a new resource in a container. + */ + create( + containerUri: string, + options: CreateResourceOptions + ): Promise>; + + /** + * Replace a resource's content. + */ + replace( + uri: string, + data: DatasetCore, + ifMatch?: string + ): Promise>; + + /** + * Delete a resource. + */ + delete(uri: string, ifMatch?: string): Promise>; + + /** + * Get URIs of resources contained in a container. + */ + getContained(containerUri: string): Promise>; + + /** + * Initialize the store with a root container. + */ + initialize(rootUri: string): Promise; +} diff --git a/packages/ldp-server/test/memory-store.test.ts b/packages/ldp-server/test/memory-store.test.ts new file mode 100644 index 0000000..4719d11 --- /dev/null +++ b/packages/ldp-server/test/memory-store.test.ts @@ -0,0 +1,430 @@ +import {describe, it, expect, beforeEach} from 'vitest'; +import {Store as N3Store, DataFactory} from 'n3'; +import {MemoryStore} from '../src/store/memory-store.js'; +import {LDP} from '../src/types.js'; + +const {namedNode, quad} = DataFactory; + +describe('MemoryStore', () => { + let store: MemoryStore; + + beforeEach(async () => { + store = new MemoryStore(); + await store.initialize('http://localhost:3000/'); + }); + + describe('initialize', () => { + it('creates a root container', async () => { + const result = await store.get('http://localhost:3000/'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.metadata.type).toBe('container'); + expect(result.value.metadata.container).toBeNull(); + } + }); + + it('is idempotent', async () => { + await store.initialize('http://localhost:3000/'); + await store.initialize('http://localhost:3000/'); + + const result = await store.get('http://localhost:3000/'); + expect(result.ok).toBe(true); + }); + }); + + describe('exists', () => { + it('returns true for existing resources', async () => { + const exists = await store.exists('http://localhost:3000/'); + expect(exists).toBe(true); + }); + + it('returns false for non-existing resources', async () => { + const exists = await store.exists('http://localhost:3000/nonexistent'); + expect(exists).toBe(false); + }); + }); + + describe('get', () => { + it('returns not-found for non-existing resources', async () => { + const result = await store.get('http://localhost:3000/nonexistent'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('not-found'); + } + }); + + it('includes ldp:contains for containers', async () => { + // Create a resource in the root container + const data = new N3Store(); + await store.create('http://localhost:3000/', { + slug: 'resource1', + data, + isContainer: false, + }); + + const result = await store.get('http://localhost:3000/'); + expect(result.ok).toBe(true); + if (result.ok) { + const containsQuads = [...result.value.data].filter( + q => + q.predicate.value === LDP.contains && + q.subject.value === 'http://localhost:3000/' + ); + expect(containsQuads.length).toBe(1); + } + }); + }); + + describe('create', () => { + it('creates a resource in a container', async () => { + const data = new N3Store(); + data.add( + quad( + namedNode(''), + namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), + namedNode('http://example.org/Resource') + ) + ); + + const result = await store.create('http://localhost:3000/', { + slug: 'myresource', + data, + isContainer: false, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.uri).toBe('http://localhost:3000/myresource'); + expect(result.value.etag).toBeTruthy(); + } + }); + + it('creates a container', async () => { + const data = new N3Store(); + + const result = await store.create('http://localhost:3000/', { + slug: 'subcontainer', + data, + isContainer: true, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.uri).toBe('http://localhost:3000/subcontainer/'); + } + + // Verify it's actually a container + const getResult = await store.get('http://localhost:3000/subcontainer/'); + expect(getResult.ok).toBe(true); + if (getResult.ok) { + expect(getResult.value.metadata.type).toBe('container'); + } + }); + + it('generates unique ID when no slug provided', async () => { + const data = new N3Store(); + + const result = await store.create('http://localhost:3000/', { + data, + isContainer: false, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.uri).toMatch(/^http:\/\/localhost:3000\/[a-z0-9]+$/); + } + }); + + it('generates unique ID on conflict', async () => { + const data = new N3Store(); + + // Create first resource + await store.create('http://localhost:3000/', { + slug: 'duplicate', + data, + isContainer: false, + }); + + // Create second with same slug + const result = await store.create('http://localhost:3000/', { + slug: 'duplicate', + data, + isContainer: false, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.uri).not.toBe('http://localhost:3000/duplicate'); + expect(result.value.uri).toContain('duplicate-'); + } + }); + + it('fails when container does not exist', async () => { + const data = new N3Store(); + + const result = await store.create('http://localhost:3000/nonexistent/', { + slug: 'resource', + data, + isContainer: false, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('not-found'); + } + }); + + it('fails when target is not a container', async () => { + const data = new N3Store(); + + // Create a regular resource + await store.create('http://localhost:3000/', { + slug: 'resource', + data, + isContainer: false, + }); + + // Try to create inside the regular resource + const result = await store.create('http://localhost:3000/resource', { + slug: 'child', + data, + isContainer: false, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('invalid-container'); + } + }); + }); + + describe('replace', () => { + it('replaces resource content', async () => { + const data = new N3Store(); + const createResult = await store.create('http://localhost:3000/', { + slug: 'resource', + data, + isContainer: false, + }); + + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + const newData = new N3Store(); + newData.add( + quad( + namedNode(createResult.value.uri), + namedNode('http://example.org/title'), + namedNode('http://example.org/NewTitle') + ) + ); + + const result = await store.replace(createResult.value.uri, newData); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.etag).not.toBe(createResult.value.etag); + } + }); + + it('respects If-Match header', async () => { + const data = new N3Store(); + const createResult = await store.create('http://localhost:3000/', { + slug: 'resource', + data, + isContainer: false, + }); + + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + const newData = new N3Store(); + const result = await store.replace( + createResult.value.uri, + newData, + 'wrong-etag' + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('precondition-failed'); + } + }); + + it('allows update with correct If-Match', async () => { + const data = new N3Store(); + const createResult = await store.create('http://localhost:3000/', { + slug: 'resource', + data, + isContainer: false, + }); + + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + const newData = new N3Store(); + const result = await store.replace( + createResult.value.uri, + newData, + createResult.value.etag + ); + + expect(result.ok).toBe(true); + }); + + it('fails for non-existing resource', async () => { + const data = new N3Store(); + const result = await store.replace('http://localhost:3000/nonexistent', data); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('not-found'); + } + }); + }); + + describe('delete', () => { + it('deletes a resource', async () => { + const data = new N3Store(); + const createResult = await store.create('http://localhost:3000/', { + slug: 'resource', + data, + isContainer: false, + }); + + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + const result = await store.delete(createResult.value.uri); + expect(result.ok).toBe(true); + + // Verify it's gone + const exists = await store.exists(createResult.value.uri); + expect(exists).toBe(false); + }); + + it('respects If-Match header', async () => { + const data = new N3Store(); + const createResult = await store.create('http://localhost:3000/', { + slug: 'resource', + data, + isContainer: false, + }); + + expect(createResult.ok).toBe(true); + if (!createResult.ok) return; + + const result = await store.delete(createResult.value.uri, 'wrong-etag'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('precondition-failed'); + } + }); + + it('fails for non-empty container', async () => { + const data = new N3Store(); + + // Create a sub-container + const containerResult = await store.create('http://localhost:3000/', { + slug: 'container', + data, + isContainer: true, + }); + + expect(containerResult.ok).toBe(true); + if (!containerResult.ok) return; + + // Create a resource in the container + await store.create(containerResult.value.uri, { + slug: 'resource', + data, + isContainer: false, + }); + + // Try to delete the container + const result = await store.delete(containerResult.value.uri); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('not-empty'); + } + }); + + it('allows deleting empty container', async () => { + const data = new N3Store(); + + const containerResult = await store.create('http://localhost:3000/', { + slug: 'emptycontainer', + data, + isContainer: true, + }); + + expect(containerResult.ok).toBe(true); + if (!containerResult.ok) return; + + const result = await store.delete(containerResult.value.uri); + expect(result.ok).toBe(true); + }); + + it('fails for non-existing resource', async () => { + const result = await store.delete('http://localhost:3000/nonexistent'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('not-found'); + } + }); + }); + + describe('getContained', () => { + it('returns contained resources', async () => { + const data = new N3Store(); + + await store.create('http://localhost:3000/', { + slug: 'resource1', + data, + isContainer: false, + }); + + await store.create('http://localhost:3000/', { + slug: 'resource2', + data, + isContainer: false, + }); + + const result = await store.getContained('http://localhost:3000/'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toHaveLength(2); + expect(result.value).toContain('http://localhost:3000/resource1'); + expect(result.value).toContain('http://localhost:3000/resource2'); + } + }); + + it('fails for non-existing container', async () => { + const result = await store.getContained('http://localhost:3000/nonexistent/'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('not-found'); + } + }); + + it('fails for non-container resource', async () => { + const data = new N3Store(); + await store.create('http://localhost:3000/', { + slug: 'resource', + data, + isContainer: false, + }); + + const result = await store.getContained('http://localhost:3000/resource'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe('invalid-container'); + } + }); + }); +}); diff --git a/packages/ldp-server/test/plugin.test.ts b/packages/ldp-server/test/plugin.test.ts new file mode 100644 index 0000000..b6b5998 --- /dev/null +++ b/packages/ldp-server/test/plugin.test.ts @@ -0,0 +1,464 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import Fastify, {type FastifyInstance} from 'fastify'; +import {ldpServer, LDP} from '../src/index.js'; + +describe('ldpServer plugin', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = Fastify(); + await app.register(ldpServer); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('GET', () => { + it('returns the root container', async () => { + const response = await app.inject({ + method: 'GET', + url: '/', + headers: { + Accept: 'text/turtle', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/turtle'); + expect(response.headers['etag']).toBeTruthy(); + expect(response.headers['link']).toBeDefined(); + }); + + it('returns 404 for non-existing resource', async () => { + const response = await app.inject({ + method: 'GET', + url: '/nonexistent', + headers: { + Accept: 'text/turtle', + }, + }); + + expect(response.statusCode).toBe(404); + }); + + it('includes LDP type headers', async () => { + const response = await app.inject({ + method: 'GET', + url: '/', + headers: { + Accept: 'text/turtle', + }, + }); + + const linkHeaders = Array.isArray(response.headers['link']) + ? response.headers['link'] + : [response.headers['link']]; + const linkStr = linkHeaders.join(', '); + + expect(linkStr).toContain(LDP.Resource); + expect(linkStr).toContain(LDP.BasicContainer); + }); + }); + + describe('HEAD', () => { + it('returns headers without body', async () => { + const response = await app.inject({ + method: 'HEAD', + url: '/', + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['etag']).toBeTruthy(); + expect(response.body).toBe(''); + }); + + it('returns 404 for non-existing resource', async () => { + const response = await app.inject({ + method: 'HEAD', + url: '/nonexistent', + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('OPTIONS', () => { + it('returns allowed methods for containers', async () => { + const response = await app.inject({ + method: 'OPTIONS', + url: '/', + }); + + expect(response.statusCode).toBe(204); + expect(response.headers['allow']).toContain('GET'); + expect(response.headers['allow']).toContain('POST'); + expect(response.headers['allow']).toContain('PUT'); + expect(response.headers['allow']).toContain('DELETE'); + }); + + it('returns allowed methods for resources', async () => { + // First create a resource + await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'myresource', + }, + payload: '<> a .', + }); + + const response = await app.inject({ + method: 'OPTIONS', + url: '/myresource', + }); + + expect(response.statusCode).toBe(204); + expect(response.headers['allow']).toContain('GET'); + expect(response.headers['allow']).not.toContain('POST'); + }); + }); + + describe('POST', () => { + it('creates a resource in a container', async () => { + const response = await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'myresource', + }, + payload: '<> a .', + }); + + expect(response.statusCode).toBe(201); + expect(response.headers['location']).toContain('myresource'); + expect(response.headers['etag']).toBeTruthy(); + }); + + it('creates a container with Link header', async () => { + const response = await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'subcontainer', + Link: `<${LDP.BasicContainer}>; rel="type"`, + }, + payload: '', + }); + + expect(response.statusCode).toBe(201); + expect(response.headers['location']).toContain('subcontainer/'); + + // Verify it's a container + const getResponse = await app.inject({ + method: 'GET', + url: '/subcontainer/', + headers: { + Accept: 'text/turtle', + }, + }); + + const linkHeaders = Array.isArray(getResponse.headers['link']) + ? getResponse.headers['link'] + : [getResponse.headers['link']]; + const linkStr = linkHeaders.join(', '); + + expect(linkStr).toContain(LDP.BasicContainer); + }); + + it('returns 415 for missing Content-Type', async () => { + const response = await app.inject({ + method: 'POST', + url: '/', + payload: '<> a .', + }); + + // Fastify returns 415 Unsupported Media Type when no content type parser matches + expect(response.statusCode).toBe(415); + }); + + it('returns 400 for invalid RDF', async () => { + const response = await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + }, + payload: 'this is not valid turtle <<<', + }); + + expect(response.statusCode).toBe(400); + }); + + it('returns 405 when POSTing to a non-container', async () => { + // First create a resource + await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'resource', + }, + payload: '<> a .', + }); + + // Try to POST to the resource + const response = await app.inject({ + method: 'POST', + url: '/resource', + headers: { + 'Content-Type': 'text/turtle', + }, + payload: '<> a .', + }); + + expect(response.statusCode).toBe(405); + }); + }); + + describe('PUT', () => { + it('replaces resource content', async () => { + // First create a resource + const createResponse = await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'myresource', + }, + payload: '<> a .', + }); + + const location = createResponse.headers['location'] as string; + const url = new URL(location); + + // Replace it + const response = await app.inject({ + method: 'PUT', + url: url.pathname, + headers: { + 'Content-Type': 'text/turtle', + }, + payload: '<> a .', + }); + + expect(response.statusCode).toBe(204); + expect(response.headers['etag']).toBeTruthy(); + }); + + it('respects If-Match header', async () => { + // First create a resource + const createResponse = await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'myresource', + }, + payload: '<> a .', + }); + + const location = createResponse.headers['location'] as string; + const url = new URL(location); + + // Try to replace with wrong ETag + const response = await app.inject({ + method: 'PUT', + url: url.pathname, + headers: { + 'Content-Type': 'text/turtle', + 'If-Match': '"wrong-etag"', + }, + payload: '<> a .', + }); + + expect(response.statusCode).toBe(412); + }); + + it('returns 404 for non-existing resource', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/nonexistent', + headers: { + 'Content-Type': 'text/turtle', + }, + payload: '<> a .', + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('DELETE', () => { + it('deletes a resource', async () => { + // First create a resource + const createResponse = await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'myresource', + }, + payload: '<> a .', + }); + + const location = createResponse.headers['location'] as string; + const url = new URL(location); + + // Delete it + const response = await app.inject({ + method: 'DELETE', + url: url.pathname, + }); + + expect(response.statusCode).toBe(204); + + // Verify it's gone + const getResponse = await app.inject({ + method: 'GET', + url: url.pathname, + }); + + expect(getResponse.statusCode).toBe(404); + }); + + it('respects If-Match header', async () => { + // First create a resource + const createResponse = await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'myresource', + }, + payload: '<> a .', + }); + + const location = createResponse.headers['location'] as string; + const url = new URL(location); + + // Try to delete with wrong ETag + const response = await app.inject({ + method: 'DELETE', + url: url.pathname, + headers: { + 'If-Match': '"wrong-etag"', + }, + }); + + expect(response.statusCode).toBe(412); + }); + + it('returns 409 when deleting non-empty container', async () => { + // Create a sub-container + await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'container', + Link: `<${LDP.BasicContainer}>; rel="type"`, + }, + payload: '', + }); + + // Create a resource in the container + await app.inject({ + method: 'POST', + url: '/container/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'resource', + }, + payload: '<> a .', + }); + + // Try to delete the container + const response = await app.inject({ + method: 'DELETE', + url: '/container/', + }); + + expect(response.statusCode).toBe(409); + }); + + it('returns 404 for non-existing resource', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/nonexistent', + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('Content negotiation', () => { + it('returns Turtle for text/turtle Accept', async () => { + const response = await app.inject({ + method: 'GET', + url: '/', + headers: { + Accept: 'text/turtle', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/turtle'); + }); + + it('returns JSON-LD for application/ld+json Accept', async () => { + const response = await app.inject({ + method: 'GET', + url: '/', + headers: { + Accept: 'application/ld+json', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('application/ld+json'); + }); + }); + + describe('Container membership', () => { + it('includes ldp:contains for container contents', async () => { + // Create resources in the root container + await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'resource1', + }, + payload: '<> a .', + }); + + await app.inject({ + method: 'POST', + url: '/', + headers: { + 'Content-Type': 'text/turtle', + Slug: 'resource2', + }, + payload: '<> a .', + }); + + const response = await app.inject({ + method: 'GET', + url: '/', + headers: { + Accept: 'text/turtle', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain(LDP.contains); + expect(response.body).toContain('resource1'); + expect(response.body).toContain('resource2'); + }); + }); +}); diff --git a/packages/ldp-server/tsconfig.json b/packages/ldp-server/tsconfig.json new file mode 100644 index 0000000..9acb057 --- /dev/null +++ b/packages/ldp-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "../fastify-rdf" + }, + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/ldp-server/tsconfig.lib.json b/packages/ldp-server/tsconfig.lib.json new file mode 100644 index 0000000..b739a68 --- /dev/null +++ b/packages/ldp-server/tsconfig.lib.json @@ -0,0 +1,31 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../fastify-rdf/tsconfig.lib.json" + } + ], + "exclude": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "test/**/*.test.ts", + "test/**/*.spec.ts", + "test/**/*.test.tsx", + "test/**/*.spec.tsx", + "test/**/*.test.js", + "test/**/*.spec.js", + "test/**/*.test.jsx", + "test/**/*.spec.jsx" + ] +} diff --git a/packages/ldp-server/tsconfig.spec.json b/packages/ldp-server/tsconfig.spec.json new file mode 100644 index 0000000..b091366 --- /dev/null +++ b/packages/ldp-server/tsconfig.spec.json @@ -0,0 +1,31 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/vitest", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vitest.config.ts", + "vitest.config.mts", + "test/**/*.test.ts", + "test/**/*.spec.ts", + "test/**/*.test.tsx", + "test/**/*.spec.tsx", + "test/**/*.test.js", + "test/**/*.spec.js", + "test/**/*.test.jsx", + "test/**/*.spec.jsx", + "test/**/*.d.ts" + ], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/ldp-server/vite.config.ts b/packages/ldp-server/vite.config.ts new file mode 100644 index 0000000..daadc7d --- /dev/null +++ b/packages/ldp-server/vite.config.ts @@ -0,0 +1,21 @@ +/// +import {defineConfig, mergeConfig} from 'vite'; +import baseConfig from '../../vite.base.config.js'; + +export default mergeConfig( + baseConfig, + defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/packages/ldp-server', + test: { + coverage: { + thresholds: { + functions: 100, + lines: 88.66, + branches: 85.92, + statements: 88.66, + }, + }, + }, + }) +); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 6360233..759478e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "./packages/fastify-rdf" + }, + { + "path": "./packages/ldp-server" } ] }