diff --git a/.github/workflows/http-tests.yml b/.github/workflows/http-tests.yml index bea5eb053a..90ef8da10a 100644 --- a/.github/workflows/http-tests.yml +++ b/.github/workflows/http-tests.yml @@ -33,6 +33,11 @@ jobs: - name: Add bin/ and its subdirectories to PATH run: | find "$GITHUB_WORKSPACE/bin" -type d >> "$GITHUB_PATH" + - name: Build the ldh CLI + run: mvn -B package # the CLI unit tests run here; the suite drives ldh for its fixtures + working-directory: cli + - name: Add the ldh launcher to PATH + run: echo "$GITHUB_WORKSPACE/cli/bin" >> "$GITHUB_PATH" - name: Generating server certificate run: | server-cert-gen.sh .env nginx ssl @@ -46,11 +51,22 @@ jobs: shell: bash - name: Enable graph versioning if: env.VERSIONING_TEST_TOKEN != '' + env: + GH_TOKEN: ${{ secrets.VERSIONING_TEST_TOKEN }} run: | + # Each run commits to its own branch. The Contents API takes an optimistic lock on the branch + # head rather than the file, so concurrent runs sharing one branch lose the race against each + # other; versioning is best-effort and async, so the lost commits surface as unrelated test + # timeouts. A per-run path prefix isolates the files but not the head, which is the contended part. + branch="ci-${{ github.run_id }}" + base_sha=$(gh api "repos/$VERSIONING_TEST_REPO/git/ref/heads/main" --jq '.object.sha') + gh api "repos/$VERSIONING_TEST_REPO/git/refs" -f ref="refs/heads/$branch" -f sha="$base_sha" > /dev/null + echo "Created versioning test branch '$branch' at ${base_sha:0:9}" + printf '@prefix a:\t .\n\n\n{\n a:authToken "%s" .\n}\n' "$VERSIONING_TEST_TOKEN" > ./secrets/credentials.trig cat >> ./http-tests/config/system.trig < . @prefix github: . @@ -64,12 +80,13 @@ jobs: { a doap:GitRepository ; doap:location ; - github:branch "main" ; + github:branch "$branch" ; github:pathPrefix "graphs-${{ github.run_id }}" . } EOF echo "COMPOSE_VERSIONING=-f ./http-tests/docker-compose.versioning.yml" >> "$GITHUB_ENV" echo "VERSIONING_PATH_PREFIX=graphs-${{ github.run_id }}" >> "$GITHUB_ENV" + echo "VERSIONING_TEST_BRANCH=$branch" >> "$GITHUB_ENV" shell: bash - name: Build Docker image & Run Docker containers run: docker compose -f docker-compose.yml -f ./http-tests/docker-compose.http-tests.yml ${COMPOSE_VERSIONING:-} --env-file ./http-tests/.env up --build -d @@ -77,7 +94,7 @@ jobs: run: while ! (status=$(curl -k -s -w "%{http_code}\n" https://localhost:4443 -o /dev/null) && echo "$status" && echo "$status" | grep "403") ; do sleep 1 ; done # wait for the webapp to start (returns 403 by default) - name: Fix certificate permissions on the host run: | - sudo chmod 644 ./ssl/owner/cert.pem ./ssl/secretary/cert.pem + sudo chmod 644 ./ssl/owner/cert.pem ./ssl/secretary/cert.pem ./ssl/owner/keystore.p12 ./ssl/secretary/keystore.p12 working-directory: http-tests - name: Run HTTP test scripts run: ./run.sh "$PWD/ssl/owner/cert.pem" "${{ secrets.HTTP_TEST_OWNER_CERT_PASSWORD }}" "$PWD/ssl/secretary/cert.pem" "${{ secrets.HTTP_TEST_SECRETARY_CERT_PASSWORD }}" @@ -85,6 +102,7 @@ jobs: working-directory: http-tests env: GITHUB_TOKEN: ${{ secrets.VERSIONING_TEST_TOKEN }} # the versioning suite (and its gh api calls) authenticate with this; empty = suite skips + VERSIONING_TEST_BRANCH: ${{ env.VERSIONING_TEST_BRANCH }} # the per-run branch the suite polls; unset = main - name: Generate test summary if: always() run: python3 scripts/generate_test_summary.py http-tests/out http-tests/out/report.md @@ -104,6 +122,11 @@ jobs: - name: Dump Tomcat logs from linkeddatahub container on failure if: failure() run: docker compose --env-file ./http-tests/.env exec -T linkeddatahub sh -c 'for f in /usr/local/tomcat/logs/*; do echo "=== $f ==="; cat "$f"; done' || true + - name: Delete the versioning test branch + if: always() && env.VERSIONING_TEST_BRANCH != '' + env: + GH_TOKEN: ${{ secrets.VERSIONING_TEST_TOKEN }} + run: gh api -X DELETE "repos/$VERSIONING_TEST_REPO/git/refs/heads/$VERSIONING_TEST_BRANCH" || true - name: Stop Docker containers and remove volumes run: docker compose --env-file ./http-tests/.env down -v - name: Remove Docker containers diff --git a/CHANGELOG.md b/CHANGELOG.md index 0790891527..04755bee02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,75 @@ +## [Unreleased] +### Added +- `ldh` command line interface (`cli/`): a standalone picocli/Jena port of the `bin/` HTTP API scripts — one command per script with the same option names, `bin/` subdirectories as nested subcommand groups, PKCS12 WebID keystore authentication, env-var defaults and a shaded executable jar +- Restore a document to an earlier version from the history modal: the memento is read back and written to the live document, so the restore rolls forward as a new commit and the versions rolled past stay in the TimeMap; gated on `acl:Write`, hidden on the version being viewed, and confirmed first +- Version diffs in the history modal: From/To selection navigates to `?version=&diff=` and the diff renders on the document page — `diff-added`/`diff-removed`/`diff-changed` block borders, marked property values, a changed XHTML block stacking its old content above the new, and a color legend +- `diff` is display state read from the URL at render time and never sent to the server, so back/forward re-render it and a plain reload degrades to the `?version=` snapshot +- Memento TimeGate (`?timegate`): `Accept-Datetime` negotiation answers `302` with the closest Memento in `Location` — smallest absolute distance, ties towards the more recent, most recent when no datetime is asked for — carrying `Vary: accept-datetime` +- TimeMaps serialized as `application/link-format` (`TimeMapWriter`), the representation RFC 7089 requires, derived from the PROV description and scoped to the `?timemap` response so ordinary documents answer `406` for it +- `http-tests/versioning/` covers the RFC 7089 contract: datetime negotiation (`GET-timegate.sh`), the PROV description of the TimeMap, its link-format serialization and `406` scoping, `rel=original` on Mementos but not the Original Resource, and a zero-padded RFC 1123 `Memento-Datetime` +- CLI unit tests for `HttpException.check`, the stdout contract the shell pipelines depend on, and stdin handling in `put`/`patch`, driven against a `com.sun.net.httpserver` stub — 41 tests to 63 +- `http-tests/federation/` self-federation suite: one dataspace's client browses, queries and writes against another origin's dataspace through the Linked Data proxy — endpoint discovery from forwarded `Link` headers, the constructor SELECT against the remote `ns`, a graph-scoped SPARQL Update `PATCH` under the origin's `If-Match` precondition, and the unauthenticated negative + +### Changed +- http-tests build their fixtures with `ldh` instead of the `bin/` HTTP API scripts — 260 invocations of 19 commands per CI run, making the suite the CLI's end-to-end coverage; only the arrange phase moves, every assertion stays a `curl` call +- **DEPRECATED**: the `bin/` HTTP API scripts, superseded by `ldh`; the certificate and WebID tooling (`webid-keygen.sh`, `webid-keygen-pem.sh`, `webid-uri.sh`, `webid-modulus.sh`, `server-cert-gen.sh`) talks to no API and is not deprecated +- Test authentication splits by consumer: `ldh` reads the PKCS12 keystore, `curl` keeps the PEM derived beside it, and `run.sh` derives the keystore paths from the certificate paths it is given (its four-argument interface is unchanged) +- `signup.sh` keeps the keystore it downloads instead of deleting it after the PEM conversion +- CI builds `cli/`, runs its unit tests, puts the launcher on `PATH`, and extends the certificate permission fix to the keystores +- The CLI JVM starts with `-XX:TieredStopAtLevel=1 -XX:+UseSerialGC`, tuned for startup rather than throughput (0.231s to 0.201s per invocation locally); `LDH_JAVA_OPTS` replaces those defaults rather than appending +- `ldh import-ontology` mirrors the rewritten script's scratch-document flow (proxy fetch → PUT into a UUID-slugged scratch document → `construct-constructors` scoped to that graph → append the constructors and an `owl:imports` header to the target → delete the scratch document on every exit path), replacing the `POST` to the deleted `/transform` endpoint +- **BREAKING**: TimeMaps are described with PROV-O instead of `http://mementoweb.org/ns#`, which has no published vocabulary — a `prov:Collection` of `prov:Entity` mementos, each `prov:specializationOf` the Original Resource, `prov:generatedAtTime` its commit datetime and `prov:wasRevisionOf` its predecessor; a TimeMap with no mementos `404`s +- Memento hypermedia uses the IANA-registered relation types with `type="application/link-format"`, branching per response type: the Original Resource advertises `timemap`/`timegate` and never `original`, a Memento links back to its Original Resource, the TimeMap identifies itself with `self` +- The `type` parameter of `Link` response headers is emitted as a quoted-string per RFC 8288 — a media type containing a solidus is not a token +- `Memento-Datetime` uses the same zero-padded RFC 1123 formatter as the TimeMap body; `DateTimeFormatter.RFC_1123_DATE_TIME` leaves the day of month unpadded where the RFC 7089 grammar wants `2DIGIT` +- TimeMap commit history is paged, bounded at `MAX_COMMIT_PAGES` with truncation logged, instead of stopping at the first 100 commits; mementos are ordered by parsed instant rather than datetime string +- TimeGate redirects are `no-store`: `Vary: accept-datetime` gives Varnish an unbounded key space, and the write-time ban matches the document URL, never the `?timegate` URL +- Install metadata is applied by one SPARQL update per app with the existence check in the `WHERE` clause, replacing `enrich_document_metadata`'s blind append +- The owner and secretary authorizations get stable slugs (`acl/authorizations/owner-webid/`, `acl/authorizations/secretary-webid/`) instead of a fresh UUID per dataset load, and the test owner fixture points at the same slug +- CI gives each run its own versioning branch, branched from `main` and deleted afterwards +- Editing forms already open on the page reconcile with the constructor after a constructor save: missing properties get `bs2:FormControl` groups appended, value-less groups the constructor no longer asserts are removed, controls holding entered values and `rdf:type` controls are untouched, and a failed fetch leaves the form as it was +- Snapshot params (`?version`/`?timemap`) deliberately do not survive a modal document save +- Constructor instances are instantiated client-side: one SPARQL SELECT fetches the type set's `spin:constructor` queries (subclass closure, deduplicated) and their CONSTRUCT templates are expanded onto a single instance typed with all the resource's classes — same-range duplicate properties collapse, and a constructor must have an empty `WHERE` clause to be client-instantiable +- Server-rendered edit forms show data properties only; the client re-render supplies the constructor controls (`ldh:construct-forClass` is an empty stub under SAXON, the `ac:construct` stub pattern) +- "Add data" accepts foreign target documents: the proxied append carries the delegated agent identity, so the target instance's access control arbitrates and its refusal surfaces as the form error +- "Import ontology" keeps the local-target requirement because its constructor derivation is scoped to the local `/sparql` endpoint +- Packages are declarative: an application imports a package with a single ` ldh:import ` triple in its dataspace settings — in `config/dataspaces.trig` (permanent, applied on restart) or live via `PATCH /settings` (effective on the next request, no restart) +- A package's components are discovered from its Linked Data description (bundled ones resolve from the classpath) and its stylesheet is composed into the application stylesheet in memory at compile time, per dataspace — nothing is copied into the webapp and `/static/` is never modified +- The available-package catalog is data at the registry URI `https://packages.linkeddatahub.com/` (bundled one-entry copy listing the SKOS package, served through the Linked Data proxy's mapped-URI resolution until the registry is live) +- The application settings modal lists the available packages with a per-row Installed checkbox serialized as an RDF/POST `ldh:import` input, and the form's single Save submits settings and package imports as one PATCH through `/settings` +- The package's ontology joins the application's ontology imports closure automatically, derived from `ldh:import` at ontology-load time: each package ontology is assembled as its own `owl:imports` closure and added as a union member, with no `owl:imports` triple materialized anywhere +- A `/settings` PATCH evicts the assembled closure, so package installs and uninstalls take effect on the next request; a package ontology that fails to load is skipped +- "Import ontology" persists only the derived annotation ontology — generated class constructors plus `owl:imports` of the canonical vocabulary URI, the artifact shape a package ontology ships; the fetched vocabulary is scaffolding in a scratch document deleted on every exit path +- The vocabulary resolves live through the graph repository, so constructors derived for bundled vocabularies now reach the ontology closure — the shipped file previously shadowed the local copy holding them +- The annotation document is wired into the namespace ontology itself (`add-ontology-import.sh --import `) +- XSLT compilation resolves `xsl:import` URLs under an application origin's `/static/` path to local webapp files (`LocalStylesheetResolver`) instead of HTTPS round-trips through nginx, and modules imported via different routes deduplicate under one URL +- `ac:stylesheet` values in `config/dataspaces.trig` are absolute URLs on the application's own origin (previously relative, absolutized against the root base URI) + +### Removed +- **BREAKING**: `/ns?forClass=` constructed-instance responses — the client-side instantiation is the only consumer path; the `Namespace` endpoint serves SPARQL queries and the raw ontology graph only +- **BREAKING**: `packages/install` and `packages/uninstall` endpoints, the admin `packages/` container and their ACL entries, the package Actions UI (`imports/lapp.xsl`), and `bin/admin/packages/` CLI scripts — the `ldh:import` declaration itself is the installation. Packages installed with earlier releases were webapp-file mutations and do not carry over: re-declare them with `ldh:import` +- `XSLTMasterUpdater`, `Package.getStylesheetPath()` and the bundled `packages/skos/layout.xsl` copy — dead now that the webapp-file installation path is gone +- `MEM` vocabulary class — no consumers left once the Memento namespace gave way to PROV-O and the IANA relation types +- Vestigial `forClass` URL params: the `add-constructor` button `@href`s (the onclick reads `@data-for-class`), the chart form's `@action` (`btn-save-chart` `PATCH`es the current document), `ldh:build-query`'s `forClass` arity, and `CacheInvalidationFilter`'s unreachable ban branch +- `ldh:NoOp`, replaced by the constructor-sync fan-out on `ldh:ClearNamespace` +- `ProvenanceFilter` — a 2021 skeleton whose registration was commented out since it was written; the PROV-O provenance sidecar (P2.3) will not start from its graph-per-request shape + +### Fixed +- GraphMode rendering of `ldh:Object` blocks crashed with a cardinality error: the `bs2:Row` branch applied `bs2:Graph` without the required `canvas-id` param; the 3D force graph now initializes after the row is rendered +- Constructor edits never surfaced in instance forms: the callback cleared the ontology derived from the class' `rdfs:isDefinedBy` and left the annotation graph cached, so it now clears the document its `PATCH` just updated (`ac:document-uri($constructor-uri)`) +- Modal document save re-rendered in the default layout mode instead of the active one; the post-save navigation now carries the URL's `?mode=`, guarded to same-document reloads +- Restore buttons were missing when History was opened from a `?version=` view: `acl:mode()` reflects the snapshot response, which `ResponseHeadersFilter` caps at `acl:Read`, so the modal now `HEAD`s the live document and reads its `acl:mode` links +- Documents accumulated a `dct:created` value per container recreate — 201 on one development instance — because the dataset load appends rather than replaces; documents that already carry one keep the timestamp they have +- Every dataset load left another copy of the owner's and secretary's authorizations behind, 60 on one development instance; the stable slugs make re-running the load a no-op +- Concurrent CI runs silently lost versioning commits to the GitHub Contents API's optimistic lock on the branch head, timing out unrelated tests waiting for them +- `create-file.sh` carried two lines on stdout once `ldh add-file` printed the content-addressed upload URI, mangling the URL `GET-file-304.sh` captures from it +- `cache: 'maven'` failed the CLI job outright — `setup-java` runs before the checkout in that workflow, so there was no `pom.xml` to hash +- Constructor-supplied inputs went nondeterministically missing (the intermittently vanishing app-settings Description field) and multi-range predicates raised cardinality errors — both fixed by the client-side instantiation +- Modal violation re-renders harvested `property-uris` from everything except the edited resource, degrading property labels to their local-name fallback; the violation/response machinery no longer pollutes the `property-uris`/`object-uris` harvests +- The `required` function on the modal violation context is stamped per flow by the response handlers, matching each flow's initial-render chain — the shared Container/Item test disagreed with the app-settings chain +- The Linked Data proxy stamped re-serialization validators instead of forwarding the origin's `ETag`/`Last-Modified`, dropped conditional request headers (`If-Match`, `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since`), and parsed the origin's `4xx`/`5xx` error bodies as RDF (turning a proxied `412`/`403` into a `502`) — preconditioned and access-controlled writes against proxied documents are now evaluated at the origin and their real status reaches the client + + ## [5.9.1] - 2026-08-19 ### Added - Inline creation in views: views carrying the new `ldh:container` metadata render a Create button that creates a linked instance in that container (#351) diff --git a/CLAUDE.md b/CLAUDE.md index 5a9b979f0c..79b38d67d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,8 @@ sudo rm -rf data uploads && docker-compose down -v # Complete reset ### Testing ```bash -# HTTP tests (requires running application) +# HTTP tests (requires running application and the ldh CLI on PATH - see cli/) +cd cli && mvn package && export PATH="$PWD/bin:$PATH" && cd .. cd http-tests ./run.sh ssl/owner/cert.pem [password] ssl/secretary/cert.pem [password] @@ -134,13 +135,25 @@ The SPARQL endpoint forwarding chain ensures ContentMode blocks (charts, maps) q - **XSLT transformations** in `src/main/webapp/static/com/atomgraph/linkeddatahub/xsl` ## CLI Tools -LinkedDataHub includes extensive CLI tools in the `bin/` directory: -- Resource management: `create-container.sh`, `create-item.sh`, `get.sh`, `post.sh`, `put.sh` -- Import functionality: `imports/create-csv-import.sh`, `imports/import-rdf.sh` -- Admin operations: `admin/model/add-class.sh`, `admin/acl/create-authorization.sh` -- Certificate management: `webid-keygen.sh`, `server-cert-gen.sh` -Add CLI tools to PATH for development: +`ldh` (in `cli/`) is the command line interface for the HTTP API — one command per `bin/` script, +same option names, `bin/` subdirectories as nested subcommand groups. See `cli/README.md` for the +full script → command table. + +```bash +cd cli && mvn package && export PATH="$PWD/bin:$PATH" + +ldh create-container --parent "$LDH_BASE" --title "Some" --slug some +ldh admin acl add-agent-to-group --agent "$AGENT_URI" "${ADMIN_BASE}acl/groups/writers/" +``` + +The `bin/` HTTP API scripts are **deprecated** — `ldh` replaces them, and http-tests build their +fixtures with it. Authentication moves from the `.pem` the scripts feed `curl -E` to the PKCS12 +keystore beside it (`-f ssl/owner/keystore.p12`). + +Certificate and WebID tooling stays in `bin/` and is not deprecated: `webid-keygen.sh`, +`webid-keygen-pem.sh`, `webid-uri.sh`, `webid-modulus.sh`, `server-cert-gen.sh`. + ```bash export PATH="$(find bin -type d -exec realpath {} \; | tr '\n' ':')$PATH" ``` diff --git a/bin/admin/ontologies/import-ontology.sh b/bin/admin/ontologies/import-ontology.sh index a8038b4fe2..5a22efb513 100755 --- a/bin/admin/ontologies/import-ontology.sh +++ b/bin/admin/ontologies/import-ontology.sh @@ -3,9 +3,9 @@ set -eo pipefail print_usage() { - printf "Imports an external ontology: appends its triples to a document and derives class constructors from them.\n" - printf "The CONSTRUCT transformation runs on the /sparql endpoint, scoped to the document graph via the SPARQL Protocol dataset specification.\n" - printf "Use add-ontology-import.sh and clear-ontology.sh to make the imported document part of the application ontology.\n" + printf "Imports an external ontology: derives class constructors from its triples and appends them, together with an owl:imports of the source, to a document.\n" + printf "The vocabulary itself is fetched into a scratch document (deleted afterwards) that scopes the CONSTRUCT transformation on the /sparql endpoint via the SPARQL Protocol dataset specification - only the derived annotations persist; the vocabulary resolves live through the graph repository.\n" + printf "Use add-ontology-import.sh and clear-ontology.sh to make the annotation document part of the application ontology.\n" printf "\n" printf "Usage: %s options\n" "$0" printf "\n" @@ -21,6 +21,7 @@ print_usage() hash curl 2>/dev/null || { echo >&2 "curl not on \$PATH. Aborting."; exit 1; } hash xmllint 2>/dev/null || { echo >&2 "xmllint not on \$PATH. Aborting."; exit 1; } +hash uuidgen 2>/dev/null || { echo >&2 "uuidgen not on \$PATH. Aborting."; exit 1; } args=() while [[ $# -gt 0 ]] @@ -112,14 +113,35 @@ curl -f -s -k \ -H "Accept: application/rdf+xml" \ > "$tmp_source" -# append the raw ontology to the document graph +# create the scratch document that holds the vocabulary during the constructor derivation + +scratch="${base}$(uuidgen | tr '[:upper:]' '[:lower:]')/" +scratch_url="$scratch" + +if [ -n "$proxy" ]; then + scratch_url="${scratch/$base_host/$proxy_host}" +fi + +printf '@prefix dh:\t .\n@prefix dct:\t .\n<%s> a dh:Item ;\n dct:title "Import ontology scratch" .\n' "$scratch" \ +| curl -f -s -k -o /dev/null \ + -E "$cert_pem_file":"$cert_password" \ + -X PUT --data-binary @- \ + -H "Content-Type: text/turtle" \ + -H "Accept: application/rdf+xml" \ + "$scratch_url" + +# the scratch document must not outlive the derivation - delete it on exit, the failure paths included + +trap 'rm -f "$tmp_source" "$tmp_query" "$tmp_constructors"; curl -s -k -o /dev/null -E "$cert_pem_file":"$cert_password" -X DELETE "$scratch_url"' EXIT + +# append the raw ontology to the scratch document graph curl -f -s -k -o /dev/null \ -E "$cert_pem_file":"$cert_password" \ -X POST --data-binary "@$tmp_source" \ -H "Content-Type: application/rdf+xml" \ -H "Accept: application/rdf+xml" \ - "$graph_url" + "$scratch_url" # read the construct-constructors query text, scoped to its own document graph @@ -137,13 +159,13 @@ if [ ! -s "$tmp_query" ]; then exit 1 fi -# run the CONSTRUCT over the document graph via the SPARQL Protocol dataset specification +# run the CONSTRUCT over the scratch graph via the SPARQL Protocol dataset specification curl -f -s -k \ -E "$cert_pem_file":"$cert_password" \ -X POST "${endpoint_base}sparql" \ --data-urlencode "query@${tmp_query}" \ - --data-urlencode "default-graph-uri=${graph}" \ + --data-urlencode "default-graph-uri=${scratch}" \ -H "Accept: application/rdf+xml" \ > "$tmp_constructors" @@ -155,3 +177,13 @@ curl -f -s -k -o /dev/null \ -H "Content-Type: application/rdf+xml" \ -H "Accept: application/rdf+xml" \ "$graph_url" + +# append the annotation-ontology header: the document imports the source vocabulary, which resolves live through the graph repository + +printf '@prefix owl:\t .\n<%s> a owl:Ontology ;\n owl:imports <%s> .\n' "$graph" "$source" \ +| curl -f -s -k -o /dev/null \ + -E "$cert_pem_file":"$cert_password" \ + -X POST --data-binary @- \ + -H "Content-Type: text/turtle" \ + -H "Accept: application/rdf+xml" \ + "$graph_url" diff --git a/bin/admin/packages/install-package.sh b/bin/admin/packages/install-package.sh deleted file mode 100755 index 86f597c59d..0000000000 --- a/bin/admin/packages/install-package.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail - -print_usage() -{ - printf "Installs a LinkedDataHub package.\n" - printf "\n" - printf "Usage: %s options\n" "$0" - printf "\n" - printf "Options:\n" - printf " -b, --base BASE_URL Base URL of the application\n" - printf " -f, --cert-pem-file CERT_FILE .pem file with the WebID certificate of the agent\n" - printf " -p, --cert-password CERT_PASSWORD Password of the WebID certificate\n" - printf " --proxy PROXY_URL The host this request will be proxied through (optional)\n" - printf " --package PACKAGE_URI URI of the package to install (e.g., https://packages.linkeddatahub.com/skos/#this)\n" - printf "\n" - printf "Example:\n" - printf " %s -b https://localhost:4443/ -f ssl/owner/cert.pem -p Password --package https://packages.linkeddatahub.com/skos/#this\n" "$0" -} - -hash curl 2>/dev/null || { echo >&2 "curl not on \$PATH. Aborting."; exit 1; } - -unknown=() -while [[ $# -gt 0 ]] -do - key="$1" - - case $key in - -b|--base) - base="$2" - shift # past argument - shift # past value - ;; - -f|--cert-pem-file) - cert_pem_file="$2" - shift # past argument - shift # past value - ;; - -p|--cert-password) - cert_password="$2" - shift # past argument - shift # past value - ;; - --proxy) - proxy="$2" - shift # past argument - shift # past value - ;; - --package) - package_uri="$2" - shift # past argument - shift # past value - ;; - *) # unknown option - unknown+=("$1") # save it in an array for later - shift # past argument - ;; - esac -done -set -- "${unknown[@]}" # restore args - -if [ -z "$base" ] ; then - print_usage - exit 1 -fi -if [ -z "$cert_pem_file" ] ; then - print_usage - exit 1 -fi -if [ -z "$cert_password" ] ; then - print_usage - exit 1 -fi -if [ -z "$package_uri" ] ; then - print_usage - exit 1 -fi - -# Convert base URL to admin base URL -admin_uri() { - local uri="$1" - echo "$uri" | sed 's|://|://admin.|' -} - -admin_base=$(admin_uri "$base") -target_url="${admin_base}packages/install" - -if [ -n "$proxy" ]; then - admin_proxy=$(admin_uri "$proxy") - # rewrite target hostname to proxy hostname - url_host=$(echo "$target_url" | cut -d '/' -f 1,2,3) - proxy_host=$(echo "$admin_proxy" | cut -d '/' -f 1,2,3) - final_url="${target_url/$url_host/$proxy_host}" -else - final_url="$target_url" -fi - -# POST to packages/install endpoint -curl -k -f -s -w "%{http_code}\n" -E "$cert_pem_file":"$cert_password" \ - -X POST \ - -H "Accept: text/turtle" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=${package_uri}" \ - "${final_url}" diff --git a/bin/admin/packages/uninstall-package.sh b/bin/admin/packages/uninstall-package.sh deleted file mode 100755 index 065e48cb82..0000000000 --- a/bin/admin/packages/uninstall-package.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail - -print_usage() -{ - printf "Uninstalls a LinkedDataHub package.\n" - printf "\n" - printf "Usage: %s options\n" "$0" - printf "\n" - printf "Options:\n" - printf " -b, --base BASE_URL Base URL of the application\n" - printf " -f, --cert-pem-file CERT_FILE .pem file with the WebID certificate of the agent\n" - printf " -p, --cert-password CERT_PASSWORD Password of the WebID certificate\n" - printf " --proxy PROXY_URL The host this request will be proxied through (optional)\n" - printf " --package PACKAGE_URI URI of the package to uninstall (e.g., https://packages.linkeddatahub.com/skos/#this)\n" - printf "\n" - printf "Example:\n" - printf " %s -b https://localhost:4443/ -f ssl/owner/cert.pem -p Password --package https://packages.linkeddatahub.com/skos/#this\n" "$0" -} - -hash curl 2>/dev/null || { echo >&2 "curl not on \$PATH. Aborting."; exit 1; } - -unknown=() -while [[ $# -gt 0 ]] -do - key="$1" - - case $key in - -b|--base) - base="$2" - shift # past argument - shift # past value - ;; - -f|--cert-pem-file) - cert_pem_file="$2" - shift # past argument - shift # past value - ;; - -p|--cert-password) - cert_password="$2" - shift # past argument - shift # past value - ;; - --proxy) - proxy="$2" - shift # past argument - shift # past value - ;; - --package) - package_uri="$2" - shift # past argument - shift # past value - ;; - *) # unknown option - unknown+=("$1") # save it in an array for later - shift # past argument - ;; - esac -done -set -- "${unknown[@]}" # restore args - -if [ -z "$base" ] ; then - print_usage - exit 1 -fi -if [ -z "$cert_pem_file" ] ; then - print_usage - exit 1 -fi -if [ -z "$cert_password" ] ; then - print_usage - exit 1 -fi -if [ -z "$package_uri" ] ; then - print_usage - exit 1 -fi - -# Convert base URL to admin base URL -admin_uri() { - local uri="$1" - echo "$uri" | sed 's|://|://admin.|' -} - -admin_base=$(admin_uri "$base") -target_url="${admin_base}packages/uninstall" - -if [ -n "$proxy" ]; then - admin_proxy=$(admin_uri "$proxy") - # rewrite target hostname to proxy hostname - url_host=$(echo "$target_url" | cut -d '/' -f 1,2,3) - proxy_host=$(echo "$admin_proxy" | cut -d '/' -f 1,2,3) - final_url="${target_url/$url_host/$proxy_host}" -else - final_url="$target_url" -fi - -# POST to packages/uninstall endpoint -curl -k -f -s -w "%{http_code}\n" -E "$cert_pem_file":"$cert_password" \ - -X POST \ - -H "Accept: text/turtle" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=${package_uri}" \ - "${final_url}" diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000000..288ad4fce1 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,139 @@ +# LinkedDataHub CLI + +`ldh` is a command line interface for the [LinkedDataHub](https://github.com/AtomGraph/LinkedDataHub) HTTP API. +It mirrors the shell scripts in [`bin/`](../bin) one command per script, with the same option names, +implemented in Java on top of AtomGraph [Core](https://github.com/AtomGraph/Core)'s `GraphStoreClient` +(picocli + Apache Jena). It replaces the scripts' external dependencies (`curl`, `turtle`, `python`, +`uuidgen`, `shasum`) with a single executable jar. + +The `bin/` HTTP API scripts it replaces are deprecated. The [http-tests](../http-tests) suite builds +all of its fixtures with `ldh`, so the commands are exercised against a live instance on every CI run; +`run.sh` aborts if `ldh` is not on `PATH`. + +## Build + +Requires Java 21 and Maven: + +```bash +cd cli +mvn package +``` + +This produces the self-contained `target/ldh.jar`. The `cli/bin/ldh` launcher runs it: + +```bash +export PATH="$PATH:$(pwd)/bin" +ldh --help +``` + +The launcher starts the JVM with `-XX:TieredStopAtLevel=1 -XX:+UseSerialGC`, trading peak +throughput for startup time — a command exits long before C2 could pay for itself, and spends +most of its life waiting on HTTP. `LDH_JAVA_OPTS` replaces those flags outright, and `LDH_JAR` +points the launcher at a jar elsewhere. + +## Authentication + +Commands authenticate with a WebID client certificate from a **PKCS12 (.p12) keystore** — the format +produced by `bin/webid-keygen.sh`: + +```bash +ldh get --accept text/turtle \ + -f ssl/owner/keystore.p12 -p "$OWNER_CERT_PWD" \ + https://localhost:4443/ +``` + +Server certificates are not validated (equivalent of `curl -k`), matching the shell scripts' +behavior against self-signed development instances. + +### Environment variable defaults + +Repeated options can be set once via environment variables: + +| Variable | Option | +|---|---| +| `LDH_CERT_FILE` | `-f`, `--cert-file` | +| `LDH_CERT_PASSWORD` | `-p`, `--cert-password` | +| `LDH_BASE` | `-b`, `--base` | +| `LDH_PROXY` | `--proxy` | + +```bash +export LDH_CERT_FILE=ssl/owner/keystore.p12 LDH_CERT_PASSWORD=... LDH_BASE=https://localhost:4443/ + +ldh create-container --parent "$LDH_BASE" --title "Some" --slug some +ldh create-item --container https://localhost:4443/some/ --title "My item" --slug my-item +``` + +## Conventions + +- Commands that create or append to a document print its URL as the only line on stdout, so shell + pipelines keep working: `item=$(ldh create-item ...)`. `add-file` prints the content-addressed + upload URI (`{base}uploads/{sha1}`). All diagnostics go to stderr. +- Exit codes: `0` success, `1` HTTP error status or runtime failure (message on stderr, stack trace + with `--verbose`), `2` usage error. +- `--proxy` rewrites the request URI's origin to the proxy's origin, like the scripts do; printed + URLs keep the logical origin. +- `post`/`put` read RDF from stdin and resolve relative URIs against the target URI (the scripts' + `turtle --base` piping); `patch` reads a SPARQL 1.1 update from stdin, validates it and sends it + verbatim. + +Shell completion: `source <(ldh generate-completion)` (bash/zsh). + +## Script → command migration + +| Script | Command | +|---|---| +| `get.sh` | `ldh get` | +| `post.sh` | `ldh post` | +| `put.sh` | `ldh put` | +| `patch.sh` | `ldh patch` | +| `delete.sh` | `ldh delete` | +| `create-item.sh` | `ldh create-item` | +| `create-container.sh` | `ldh create-container` | +| `add-view.sh` | `ldh add-view` | +| `add-construct.sh` | `ldh add-construct` | +| `add-select.sh` | `ldh add-select` | +| `add-result-set-chart.sh` | `ldh add-result-set-chart` | +| `add-file.sh` | `ldh add-file` | +| `add-generic-service.sh` | `ldh add-generic-service` | +| `admin/clear-ontology.sh` | `ldh admin clear-ontology` | +| `admin/add-ontology-import.sh` | `ldh admin add-ontology-import` | +| `admin/ontologies/create-ontology.sh` | `ldh admin ontologies create-ontology` | +| `admin/ontologies/import-ontology.sh` | `ldh admin ontologies import-ontology` | +| `admin/ontologies/add-class.sh` | `ldh admin ontologies add-class` | +| `admin/ontologies/add-constructor.sh` | `ldh admin ontologies add-constructor` | +| `admin/ontologies/add-select.sh` | `ldh admin ontologies add-select` | +| `admin/ontologies/add-property-constraint.sh` | `ldh admin ontologies add-property-constraint` | +| `admin/ontologies/add-restriction.sh` | `ldh admin ontologies add-restriction` | +| `admin/acl/create-group.sh` | `ldh admin acl create-group` | +| `admin/acl/create-authorization.sh` | `ldh admin acl create-authorization` | +| `admin/acl/add-agent-to-group.sh` | `ldh admin acl add-agent-to-group` | +| `admin/acl/make-public.sh` | `ldh admin acl make-public` | +| `content/add-object-block.sh` | `ldh content add-object-block` | +| `content/add-xhtml-block.sh` | `ldh content add-xhtml-block` | +| `content/remove-block.sh` | `ldh content remove-block` | +| `imports/add-csv-import.sh` | `ldh imports add-csv-import` | +| `imports/add-rdf-import.sh` | `ldh imports add-rdf-import` | +| `imports/import-csv.sh` | `ldh imports import-csv` | +| `imports/import-rdf.sh` | `ldh imports import-rdf` | + +Local certificate tooling (`webid-keygen.sh`, `webid-keygen-pem.sh`, `webid-uri.sh`, +`webid-modulus.sh`, `server-cert-gen.sh`) and the experimental `sitemap/` generator remain +shell scripts. + +Packages have no command: an application imports one with a single ` ldh:import ` +triple, so `ldh patch` on the application's `/settings` document is the whole interface. + +### Differences from the scripts + +- `-f/--cert-pem-file` is now `-f/--cert-file` and takes the `.p12` keystore directly — no + PEM conversion needed. +- `create-group` writes the `--name` value into `foaf:name`/`dct:title` (the script wrote an + unset variable, producing empty literals). +- `add-generic-service` drops the documented-but-unparsed `--slug` option. +- `add-csv-import`/`import-csv` default `--delimiter` to `,` (the script required it despite + documenting a default). +- `import-csv`/`import-rdf` run their steps in-process instead of spawning subscripts, and pass + `--description` through to the import metadata. +- `import-ontology` reads the `construct-constructors` query text by dereferencing its document + instead of going through a `SELECT` on `/sparql`; the CONSTRUCT it then runs over the scratch + graph is unchanged. diff --git a/cli/bin/ldh b/cli/bin/ldh new file mode 100755 index 0000000000..3b93cc02c8 --- /dev/null +++ b/cli/bin/ldh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Launcher for the LinkedDataHub CLI. +# Uses the jar built by `mvn package` unless LDH_JAR points elsewhere. +# +# The default JVM flags trade peak throughput for startup time: a command is a short-lived +# process that exits long before C2 could pay for itself, and it spends most of its life +# waiting on HTTP. The http-tests suite pays this cost 260 times per run. +# +# LDH_JAVA_OPTS replaces the defaults outright - it is not appended, so a different collector +# does not collide with the one set here. + +dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck disable=SC2086 # unquoted on purpose: the flags must word-split +exec java ${LDH_JAVA_OPTS:--XX:TieredStopAtLevel=1 -XX:+UseSerialGC} -jar "${LDH_JAR:-$dir/../target/ldh.jar}" "$@" diff --git a/cli/pom.xml b/cli/pom.xml new file mode 100644 index 0000000000..bb7dfa7475 --- /dev/null +++ b/cli/pom.xml @@ -0,0 +1,162 @@ + + + 4.0.0 + + com.atomgraph + linkeddatahub-cli + 1.0.0-SNAPSHOT + jar + + LinkedDataHub CLI + Command line interface for the LinkedDataHub HTTP API + https://github.com/AtomGraph/LinkedDataHub + + + UTF-8 + 21 + 3.1.11 + + + + + info.picocli + picocli + 4.7.7 + + + com.atomgraph + core + 5.0.2 + + + + org.glassfish.jersey.containers + jersey-container-servlet + + + + + org.apache.jena + jena-arq + 6.1.0 + + + + org.apache.httpcomponents + httpcore + + + org.apache.httpcomponents + httpclient-cache + + + org.apache.httpcomponents + httpclient + + + org.apache.httpcomponents + httpclient-osgi + + + org.apache.httpcomponents + httpcore-osgi + + + + + org.glassfish.jersey.connectors + jersey-apache-connector + ${jersey.version} + + + org.glassfish.jersey.media + jersey-media-multipart + ${jersey.version} + + + + jakarta.activation + jakarta.activation-api + 2.1.3 + runtime + + + org.slf4j + slf4j-nop + 2.0.17 + runtime + + + org.junit.jupiter + junit-jupiter + 5.12.2 + test + + + + + ldh + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + 21 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + + + com.atomgraph.linkeddatahub.cli.LDH + + true + + + + + + + META-INF/hk2-locator/default + + + + false + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + META-INF/versions/*/module-info.class + + + + + + + + + + + diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/BaseCommand.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/BaseCommand.java new file mode 100644 index 0000000000..986b32bf3d --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/BaseCommand.java @@ -0,0 +1,209 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli; + +import com.atomgraph.core.MediaTypes; +import com.atomgraph.linkeddatahub.cli.http.ClientFactory; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import com.atomgraph.linkeddatahub.cli.http.LDHClient; +import com.atomgraph.linkeddatahub.cli.mixin.CertAuthMixin; +import com.atomgraph.linkeddatahub.cli.mixin.ProxyMixin; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.concurrent.Callable; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.riot.Lang; +import org.apache.jena.riot.RDFLanguages; +import org.apache.jena.riot.RDFParser; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.Spec; + +/** + * Base class for all commands: WebID certificate authentication, proxy handling + * and shared RDF/HTTP helpers. + * + * @author Martynas Jusevičius {@literal } + */ +public abstract class BaseCommand implements Callable +{ + + /** Accepted response media type used by the scripts (Accept: text/turtle) */ + protected static final MediaType[] ACCEPT_TURTLE = { com.atomgraph.core.MediaType.TEXT_TURTLE_TYPE }; + /** Accepted response media type for content block sequence scanning */ + protected static final MediaType[] ACCEPT_NTRIPLES = { com.atomgraph.core.MediaType.APPLICATION_NTRIPLES_TYPE }; + /** Turtle request body media type */ + protected static final MediaType TEXT_TURTLE_TYPE = com.atomgraph.core.MediaType.TEXT_TURTLE_TYPE; + + @Spec + private CommandSpec spec; + + @Mixin + private CertAuthMixin certAuth; + + @Mixin + private ProxyMixin proxyMixin; + + private LDHClient client; + + /** + * Returns the lazily-built authenticated client. + * + * @return client instance + */ + protected LDHClient getClient() + { + if (client == null) + { + getCertAuth().validate(getSpec()); + client = new LDHClient(ClientFactory.createClient(getCertAuth().getCertFile(), getCertAuth().getCertPassword()), + new MediaTypes(), getEffectiveProxy()); + } + + return client; + } + + /** + * Returns the proxy URI applied to requests. Commands that target the admin application + * override this to convert the proxy to the admin subdomain. + * + * @return proxy URI or null + */ + protected URI getEffectiveProxy() + { + return getProxyMixin().getProxy(); + } + + /** + * POSTs a model to a document, failing on error status. + * + * @param client client instance + * @param target document URI + * @param model appended model + */ + protected static void post(LDHClient client, URI target, Model model) + { + HttpException.check(target, client.post(target, Entity.entity(model, TEXT_TURTLE_TYPE), ACCEPT_TURTLE)).close(); + } + + /** + * PUTs a model as a document, failing on error status. + * + * @param client client instance + * @param target document URI + * @param model document model + */ + protected static void put(LDHClient client, URI target, Model model) + { + HttpException.check(target, client.put(target, Entity.entity(model, TEXT_TURTLE_TYPE), ACCEPT_TURTLE)).close(); + } + + /** + * Returns the subject resource for an appended description: the --uri value + * resolved against the target document URI, or a fresh blank node when not given. + * + * @param model model to create the resource in + * @param target target document URI + * @param uri --uri option value (absolute or relative, can be null) + * @return subject resource + */ + protected static Resource createSubject(Model model, URI target, String uri) + { + return uri != null ? model.createResource(target.resolve(uri).toString()) : model.createResource(); + } + + /** + * Parses an RDF stream into a model, resolving relative URIs against the base URI + * (the equivalent of the scripts' turtle --base piping). + * + * @param contentType RDF media type + * @param base base URI + * @param in RDF input stream + * @return parsed model + */ + protected Model readModel(String contentType, URI base, InputStream in) + { + Lang lang = RDFLanguages.contentTypeToLang(contentType); + if (lang == null) throw new ParameterException(getSpec().commandLine(), "Unsupported RDF media type: '" + contentType + "'"); + + Model model = ModelFactory.createDefaultModel(); + RDFParser.create().source(in).lang(lang).base(base.toString()).parse(model); + return model; + } + + /** + * Streams a response body to standard output unmodified. + * + * @param response response with the body to stream + * @throws IOException stream error + */ + protected static void printBody(jakarta.ws.rs.core.Response response) throws IOException + { + try (response; InputStream is = response.readEntity(InputStream.class)) + { + is.transferTo(System.out); + System.out.flush(); + } + } + + /** + * Prints a line to standard output. Command results (created document URLs) go through here. + * + * @param value printed value + */ + protected void print(Object value) + { + getSpec().commandLine().getOut().println(value); + } + + /** + * Returns the command spec. + * + * @return command spec + */ + protected CommandSpec getSpec() + { + return spec; + } + + /** + * Returns the certificate options. + * + * @return certificate mixin + */ + protected CertAuthMixin getCertAuth() + { + return certAuth; + } + + /** + * Returns the proxy options. + * + * @return proxy mixin + */ + protected ProxyMixin getProxyMixin() + { + return proxyMixin; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/LDH.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/LDH.java new file mode 100644 index 0000000000..4f3842bbf7 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/LDH.java @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli; + +import com.atomgraph.linkeddatahub.cli.command.AddConstruct; +import com.atomgraph.linkeddatahub.cli.command.AddFile; +import com.atomgraph.linkeddatahub.cli.command.AddGenericService; +import com.atomgraph.linkeddatahub.cli.command.AddResultSetChart; +import com.atomgraph.linkeddatahub.cli.command.AddSelect; +import com.atomgraph.linkeddatahub.cli.command.AddView; +import com.atomgraph.linkeddatahub.cli.command.CreateContainer; +import com.atomgraph.linkeddatahub.cli.command.CreateItem; +import com.atomgraph.linkeddatahub.cli.command.Delete; +import com.atomgraph.linkeddatahub.cli.command.Get; +import com.atomgraph.linkeddatahub.cli.command.Patch; +import com.atomgraph.linkeddatahub.cli.command.Post; +import com.atomgraph.linkeddatahub.cli.command.Put; +import com.atomgraph.linkeddatahub.cli.command.admin.Admin; +import com.atomgraph.linkeddatahub.cli.command.content.Content; +import com.atomgraph.linkeddatahub.cli.command.imports.Imports; +import org.apache.jena.sys.JenaSystem; +import picocli.AutoComplete; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.ScopeType; + +/** + * Root command of the LinkedDataHub CLI. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "ldh", + mixinStandardHelpOptions = true, + version = "ldh 1.0.0-SNAPSHOT", + description = "Command line interface for the LinkedDataHub HTTP API.", + subcommands = { + Get.class, Post.class, Put.class, Patch.class, Delete.class, + CreateItem.class, CreateContainer.class, + AddView.class, AddConstruct.class, AddSelect.class, AddResultSetChart.class, AddFile.class, AddGenericService.class, + Admin.class, Content.class, Imports.class, + AutoComplete.GenerateCompletion.class + }) +public class LDH +{ + + @Option(names = "--verbose", scope = ScopeType.INHERIT, description = "Print stack traces of errors") + boolean verbose; + + /** + * CLI entry point. + * + * @param args command line arguments + */ + public static void main(String[] args) + { + JenaSystem.init(); + + CommandLine cmd = new CommandLine(new LDH()); + cmd.setExecutionExceptionHandler(LDH::handleExecutionException); + System.exit(cmd.execute(args)); + } + + static int handleExecutionException(Exception ex, CommandLine cmdLine, CommandLine.ParseResult parseResult) + { + boolean verbose = cmdLine.getCommandSpec().root().userObject() instanceof LDH root && root.verbose; + + cmdLine.getErr().println(cmdLine.getColorScheme().errorText(messageOf(ex))); + if (verbose) ex.printStackTrace(cmdLine.getErr()); + + return CommandLine.ExitCode.SOFTWARE; + } + + static String messageOf(Throwable ex) + { + // unwrap Jersey ProcessingException chains down to the I/O cause, e.g. "Connection refused" + Throwable cause = ex; + while (cause.getCause() != null && (cause instanceof jakarta.ws.rs.ProcessingException || cause.getMessage() == null)) + cause = cause.getCause(); + + return cause.getMessage() != null ? cause.getMessage() : cause.toString(); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddConstruct.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddConstruct.java new file mode 100644 index 0000000000..91194f7b21 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddConstruct.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.LDHClient; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.LDH; +import com.atomgraph.linkeddatahub.cli.vocab.SP; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds a SPARQL CONSTRUCT query to a document. Mirrors bin/add-construct.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-construct", description = "Adds a CONSTRUCT query to a document.") +public class AddConstruct extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the query") + private String title; + + @Option(names = "--query-file", required = true, paramLabel = "ABS_PATH", description = "Path to the file with the query string") + private Path queryFile; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the query (optional)") + private String description; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the query (optional, blank node if not set)") + private String uri; + + @Option(names = "--service", paramLabel = "SERVICE_URI", description = "URI of the SPARQL service (optional)") + private URI service; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + core(getClient(), target, uri, title, Files.readString(queryFile), service, description); + print(target); + + return 0; + } + + /** + * Appends the CONSTRUCT query description to the target document. + * + * @param client client instance + * @param target target document URI + * @param uri query URI (optional) + * @param title query title + * @param queryText query string + * @param service SPARQL service URI (optional) + * @param description query description (optional) + */ + public static void core(LDHClient client, URI target, String uri, String title, String queryText, URI service, String description) + { + post(client, target, buildModel(target, uri, SP.Construct, title, queryText, service, description)); + } + + /** + * Builds a SPIN query description. + * + * @param target target document URI + * @param uri query URI (optional) + * @param queryType SPIN query class (sp:Construct or sp:Select) + * @param title query title + * @param queryText query string + * @param service SPARQL service URI (optional) + * @param description query description (optional) + * @return query model + */ + public static Model buildModel(URI target, String uri, Resource queryType, String title, String queryText, URI service, String description) + { + Model model = ModelFactory.createDefaultModel(); + + Resource query = createSubject(model, target, uri). + addProperty(RDF.type, queryType). + addProperty(DCTerms.title, title). + addProperty(SP.text, queryText); + if (service != null) query.addProperty(LDH.service, model.createResource(service.toString())); + if (description != null) query.addProperty(DCTerms.description, description); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddFile.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddFile.java new file mode 100644 index 0000000000..8114dec2ab --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddFile.java @@ -0,0 +1,141 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import com.atomgraph.linkeddatahub.cli.http.LDHClient; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.util.Digests; +import com.atomgraph.linkeddatahub.cli.vocab.NFO; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import org.glassfish.jersey.media.multipart.FormDataMultiPart; +import org.glassfish.jersey.media.multipart.file.FileDataBodyPart; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Uploads a file using the RDF/POST multipart encoding. Mirrors bin/add-file.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-file", description = "Uploads a file.") +public class AddFile extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the file") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the file (optional)") + private String description; + + @Option(names = "--file", required = true, paramLabel = "ABS_PATH", description = "Path to the file") + private Path file; + + @Option(names = "--content-type", paramLabel = "MEDIA_TYPE", description = "Media type of the file (optional, auto-detected if not set)") + private String contentType; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + URI base = baseMixin.require(getSpec()); + + URI fileURI = core(getClient(), base, target, file, contentType, title, description); + print(fileURI); + + return 0; + } + + /** + * Uploads the file to the target document and returns its content-addressed upload URI. + * The RDF/POST field order is positional: each pu must immediately precede + * its ol/ou value. + * + * @param client client instance + * @param base application base URI + * @param target target document URI + * @param file file path + * @param contentType file media type (optional, auto-detected if null) + * @param title file title + * @param description file description (optional) + * @return upload URI derived from the SHA1 hash of the file content + * @throws IOException file read error + */ + public static URI core(LDHClient client, URI base, URI target, Path file, String contentType, String title, String description) throws IOException + { + String fileContentType = contentType != null ? contentType : detectContentType(file); + + try (FormDataMultiPart multiPart = buildMultiPart(file, fileContentType, title, description)) + { + HttpException.check(target, client.post(target, Entity.entity(multiPart, multiPart.getMediaType()), ACCEPT_TURTLE)).close(); + } + + return URI.create(base.toString() + "uploads/" + Digests.sha1Hex(file)); + } + + /** + * Builds the RDF/POST multipart body. + * + * @param file file path + * @param contentType file media type + * @param title file title + * @param description file description (optional) + * @return multipart body + */ + public static FormDataMultiPart buildMultiPart(Path file, String contentType, String title, String description) + { + FormDataMultiPart multiPart = new FormDataMultiPart(); + + multiPart.field("rdf", ""); + multiPart.field("sb", "file"); + multiPart.field("pu", NFO.fileName.getURI()); + multiPart.bodyPart(new FileDataBodyPart("ol", file.toFile(), MediaType.valueOf(contentType))); + multiPart.field("pu", DCTerms.title.getURI()); + multiPart.field("ol", title); + multiPart.field("pu", RDF.type.getURI()); + multiPart.field("ou", NFO.FileDataObject.getURI()); + if (description != null) + { + multiPart.field("pu", DCTerms.description.getURI()); + multiPart.field("ol", description); + } + + return multiPart; + } + + static String detectContentType(Path file) throws IOException + { + String detected = Files.probeContentType(file); + return detected != null ? detected : "application/octet-stream"; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddGenericService.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddGenericService.java new file mode 100644 index 0000000000..cb737c570a --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddGenericService.java @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.A; +import com.atomgraph.linkeddatahub.cli.vocab.SD; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Appends a generic SPARQL service description to a document. Mirrors bin/add-generic-service.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-generic-service", description = "Appends a generic SPARQL service to a document.") +public class AddGenericService extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the service") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the service (optional)") + private String description; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the service (optional, blank node if not set)") + private String uri; + + @Option(names = "--endpoint", required = true, paramLabel = "ENDPOINT_URI", description = "URI of the SPARQL endpoint") + private URI endpoint; + + @Option(names = "--graph-store", paramLabel = "GRAPH_STORE_URI", description = "URI of the Graph Store Protocol endpoint (optional)") + private URI graphStore; + + @Option(names = "--auth-user", paramLabel = "AUTH_USER", description = "Username for HTTP Basic auth (optional)") + private String authUser; + + @Option(names = "--auth-pwd", paramLabel = "AUTH_PASSWORD", description = "Password for HTTP Basic auth (optional)") + private String authPwd; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + post(getClient(), target, buildModel(target, uri, title, endpoint, graphStore, authUser, authPwd, description)); + print(target); + + return 0; + } + + /** + * Builds the service description. + * + * @param target target document URI + * @param uri service URI (optional) + * @param title service title + * @param endpoint SPARQL endpoint URI + * @param graphStore Graph Store Protocol endpoint URI (optional) + * @param authUser HTTP Basic auth username (optional) + * @param authPwd HTTP Basic auth password (optional) + * @param description service description (optional) + * @return service model + */ + public static Model buildModel(URI target, String uri, String title, URI endpoint, URI graphStore, String authUser, String authPwd, String description) + { + Model model = ModelFactory.createDefaultModel(); + + Resource service = createSubject(model, target, uri). + addProperty(RDF.type, SD.Service). + addProperty(DCTerms.title, title). + addProperty(SD.endpoint, model.createResource(endpoint.toString())). + addProperty(SD.supportedLanguage, SD.SPARQL11Query). + addProperty(SD.supportedLanguage, SD.SPARQL11Update); + if (graphStore != null) service.addProperty(A.graphStore, model.createResource(graphStore.toString())); + if (authUser != null) service.addProperty(A.authUser, authUser); + if (authPwd != null) service.addProperty(A.authPwd, authPwd); + if (description != null) service.addProperty(DCTerms.description, description); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddResultSetChart.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddResultSetChart.java new file mode 100644 index 0000000000..9b15553827 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddResultSetChart.java @@ -0,0 +1,110 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.LDH; +import com.atomgraph.linkeddatahub.cli.vocab.SPIN; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Appends a chart of SPARQL SELECT results to a document. Mirrors bin/add-result-set-chart.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-result-set-chart", description = "Appends a result set chart to a document.") +public class AddResultSetChart extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the chart") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the chart (optional)") + private String description; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the chart (optional, blank node if not set)") + private String uri; + + @Option(names = "--query", required = true, paramLabel = "QUERY_URI", description = "URI of the SELECT query") + private URI query; + + @Option(names = "--chart-type", required = true, paramLabel = "TYPE_URI", description = "URI of the chart type") + private URI chartType; + + @Option(names = "--category-var-name", required = true, paramLabel = "VAR_NAME", description = "Name of the category variable") + private String categoryVarName; + + @Option(names = "--series-var-name", required = true, paramLabel = "VAR_NAME", description = "Name of the series variable") + private String seriesVarName; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + post(getClient(), target, buildModel(target, uri, title, query, chartType, categoryVarName, seriesVarName, description)); + print(target); + + return 0; + } + + /** + * Builds the chart description. + * + * @param target target document URI + * @param uri chart URI (optional) + * @param title chart title + * @param query SELECT query URI + * @param chartType chart type URI + * @param categoryVarName category variable name + * @param seriesVarName series variable name + * @param description chart description (optional) + * @return chart model + */ + public static Model buildModel(URI target, String uri, String title, URI query, URI chartType, String categoryVarName, String seriesVarName, String description) + { + Model model = ModelFactory.createDefaultModel(); + + Resource chart = createSubject(model, target, uri). + addProperty(RDF.type, LDH.ResultSetChart). + addProperty(DCTerms.title, title). + addProperty(SPIN.query, model.createResource(query.toString())). + addProperty(LDH.chartType, model.createResource(chartType.toString())). + addProperty(LDH.categoryVarName, categoryVarName). + addProperty(LDH.seriesVarName, seriesVarName); + if (description != null) chart.addProperty(DCTerms.description, description); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddSelect.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddSelect.java new file mode 100644 index 0000000000..63698b3420 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddSelect.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.SP; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds a SPARQL SELECT query to a document. Mirrors bin/add-select.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-select", description = "Adds a SELECT query to a document.") +public class AddSelect extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the query") + private String title; + + @Option(names = "--query-file", required = true, paramLabel = "ABS_PATH", description = "Path to the file with the query string") + private Path queryFile; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the query (optional)") + private String description; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the query (optional, blank node if not set)") + private String uri; + + @Option(names = "--service", paramLabel = "SERVICE_URI", description = "URI of the SPARQL service (optional)") + private URI service; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + post(getClient(), target, AddConstruct.buildModel(target, uri, SP.Select, title, Files.readString(queryFile), service, description)); + print(target); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddView.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddView.java new file mode 100644 index 0000000000..345775f45f --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/AddView.java @@ -0,0 +1,101 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.AC; +import com.atomgraph.linkeddatahub.cli.vocab.LDH; +import com.atomgraph.linkeddatahub.cli.vocab.SPIN; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Appends a view of a SPARQL SELECT query to a document. Mirrors bin/add-view.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-view", description = "Appends a view to a document.") +public class AddView extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--query", required = true, paramLabel = "QUERY_URI", description = "URI of the SELECT query") + private URI query; + + @Option(names = "--title", paramLabel = "TITLE", description = "Title of the view (optional)") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the view (optional)") + private String description; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the view (optional, blank node if not set)") + private String uri; + + @Option(names = "--mode", paramLabel = "MODE_URI", description = "URI of the layout mode (optional)") + private URI mode; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + post(getClient(), target, buildModel(target, uri, query, title, description, mode)); + print(target); + + return 0; + } + + /** + * Builds the view description. + * + * @param target target document URI + * @param uri view URI (optional) + * @param query SELECT query URI + * @param title view title (optional) + * @param description view description (optional) + * @param mode layout mode URI (optional) + * @return view model + */ + public static Model buildModel(URI target, String uri, URI query, String title, String description, URI mode) + { + Model model = ModelFactory.createDefaultModel(); + + Resource view = createSubject(model, target, uri). + addProperty(RDF.type, LDH.View). + addProperty(SPIN.query, model.createResource(query.toString())); + if (title != null) view.addProperty(DCTerms.title, title); + if (description != null) view.addProperty(DCTerms.description, description); + if (mode != null) view.addProperty(AC.mode, model.createResource(mode.toString())); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateContainer.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateContainer.java new file mode 100644 index 0000000000..5e3e327e1d --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateContainer.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.util.Slugs; +import com.atomgraph.linkeddatahub.cli.util.URIRewriter; +import com.atomgraph.linkeddatahub.cli.vocab.AC; +import com.atomgraph.linkeddatahub.cli.vocab.DH; +import com.atomgraph.linkeddatahub.cli.vocab.LDH; +import com.atomgraph.linkeddatahub.cli.vocab.SPIN; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Creates a container document. Mirrors bin/create-container.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "create-container", description = "Creates a container document.") +public class CreateContainer extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the container") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the container (optional)") + private String description; + + @Option(names = "--slug", paramLabel = "STRING", description = "String that will be used as URI path segment (optional)") + private String slug; + + @Option(names = "--parent", required = true, paramLabel = "PARENT_URI", description = "URI of the parent container") + private URI parent; + + @Option(names = "--block", paramLabel = "BLOCK_URI", description = "URI of the content block (optional)") + private URI block; + + @Option(names = "--mode", paramLabel = "MODE_URI", description = "URI of the layout mode of the children view (optional)") + private URI mode; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + URI doc = URIRewriter.childURI(parent, slug != null ? slug : Slugs.defaultSlug()); + put(getClient(), doc, buildModel(doc, title, description, block, mode)); + print(doc); + + return 0; + } + + /** + * Builds the container document model with its first content block: the given block URI, + * a children view with an explicit mode, or the default children view. + * + * @param doc document URI + * @param title document title + * @param description document description (optional) + * @param block content block URI (optional) + * @param mode children view mode URI (optional, ignored when block is given) + * @return document model + */ + public static Model buildModel(URI doc, String title, String description, URI block, URI mode) + { + Model model = ModelFactory.createDefaultModel(); + + Resource container = model.createResource(doc.toString()). + addProperty(RDF.type, DH.Container). + addProperty(DCTerms.title, title); + + if (block != null) container.addProperty(RDF.li(1), model.createResource(block.toString())); + else if (mode != null) container.addProperty(RDF.li(1), model.createResource(). + addProperty(RDF.type, LDH.Object). + addProperty(RDF.value, model.createResource(). + addProperty(RDF.type, LDH.View). + addProperty(SPIN.query, LDH.SelectChildren). + addProperty(AC.mode, model.createResource(mode.toString())))); + else container.addProperty(RDF.li(1), model.createResource(). + addProperty(RDF.type, LDH.Object). + addProperty(RDF.value, LDH.ChildrenView)); + + if (description != null) container.addProperty(DCTerms.description, description); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateItem.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateItem.java new file mode 100644 index 0000000000..6f2e49b830 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/CreateItem.java @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.util.Slugs; +import com.atomgraph.linkeddatahub.cli.util.URIRewriter; +import com.atomgraph.linkeddatahub.cli.vocab.DH; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Creates an item document. Mirrors bin/create-item.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "create-item", description = "Creates an item document.") +public class CreateItem extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the item") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the item (optional)") + private String description; + + @Option(names = "--slug", paramLabel = "STRING", description = "String that will be used as URI path segment (optional)") + private String slug; + + @Option(names = "--container", required = true, paramLabel = "CONTAINER_URI", description = "URI of the parent container") + private URI container; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + URI doc = URIRewriter.childURI(container, slug != null ? slug : Slugs.defaultSlug()); + put(getClient(), doc, buildModel(doc, title, description)); + print(doc); + + return 0; + } + + /** + * Builds the item document model. + * + * @param doc document URI + * @param title document title + * @param description document description (optional) + * @return document model + */ + public static Model buildModel(URI doc, String title, String description) + { + Model model = ModelFactory.createDefaultModel(); + + Resource item = model.createResource(doc.toString()). + addProperty(RDF.type, DH.Item). + addProperty(DCTerms.title, title); + if (description != null) item.addProperty(DCTerms.description, description); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Delete.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Delete.java new file mode 100644 index 0000000000..27988751c1 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Delete.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import java.net.URI; +import picocli.CommandLine.Command; +import picocli.CommandLine.Parameters; + +/** + * Deletes an RDF document. Mirrors bin/delete.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "delete", description = "Deletes an RDF document.") +public class Delete extends BaseCommand +{ + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + HttpException.check(target, getClient().delete(target)).close(); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Get.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Get.java new file mode 100644 index 0000000000..2244ad231e --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Get.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.net.URI; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Retrieves an RDF description. Mirrors bin/get.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "get", description = "Retrieves RDF description.") +public class Get extends BaseCommand +{ + + @Option(names = "--accept", required = true, paramLabel = "MEDIA_TYPE", description = "Requested media type (e.g. text/turtle)") + private String accept; + + @Option(names = "--head", description = "Requested headers only, no body (HEAD method)") + private boolean head; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + MediaType[] acceptedTypes = { MediaType.valueOf(accept) }; + + if (head) + try (Response response = HttpException.check(target, getClient().head(target, acceptedTypes))) + { + print("HTTP " + response.getStatus() + " " + response.getStatusInfo().getReasonPhrase()); + response.getStringHeaders().forEach((name, values) -> values.forEach(value -> print(name + ": " + value))); + } + else + printBody(HttpException.check(target, getClient().get(target, acceptedTypes))); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Patch.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Patch.java new file mode 100644 index 0000000000..f41a9deed0 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Patch.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import org.apache.jena.query.Syntax; +import org.apache.jena.update.UpdateFactory; +import picocli.CommandLine.Command; +import picocli.CommandLine.Parameters; + +/** + * Patches an RDF document with a SPARQL update from standard input. Mirrors bin/patch.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "patch", description = "Patches an RDF document using SPARQL update from stdin.") +public class Patch extends BaseCommand +{ + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + String update = new String(System.in.readAllBytes(), StandardCharsets.UTF_8); + // validate as standard SPARQL 1.1 before sending; the original text is sent unmodified + UpdateFactory.create(update, target.toString(), Syntax.syntaxSPARQL_11); + + HttpException.check(target, getClient().patch(target, update)).close(); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Post.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Post.java new file mode 100644 index 0000000000..8cc1aa393d --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Post.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Creates an RDF document from standard input. Mirrors bin/post.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "post", description = "Creates an RDF document from stdin.") +public class Post extends BaseCommand +{ + + @Option(names = {"-t", "--content-type"}, required = true, paramLabel = "MEDIA_TYPE", description = "Media type of the RDF body (e.g. text/turtle)") + private String contentType; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + Model model = readModel(contentType, target, System.in); + HttpException.check(target, getClient().post(target, Entity.entity(model, MediaType.valueOf(contentType)), ACCEPT_TURTLE)).close(); + print(target); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Put.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Put.java new file mode 100644 index 0000000000..d30706740e --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/Put.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Creates or updates an RDF document from standard input. Mirrors bin/put.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "put", description = "Creates or updates an RDF document from stdin.") +public class Put extends BaseCommand +{ + + @Option(names = {"-t", "--content-type"}, required = true, paramLabel = "MEDIA_TYPE", description = "Media type of the RDF body (e.g. text/turtle)") + private String contentType; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + Model model = readModel(contentType, target, System.in); + HttpException.check(target, getClient().put(target, Entity.entity(model, MediaType.valueOf(contentType)), ACCEPT_TURTLE)).close(); + print(target); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/AddOntologyImport.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/AddOntologyImport.java new file mode 100644 index 0000000000..5a3ffc1498 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/AddOntologyImport.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import com.atomgraph.linkeddatahub.cli.sparql.Updates; +import java.net.URI; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds an owl:imports statement to an ontology. Mirrors bin/admin/add-ontology-import.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-ontology-import", description = "Adds an owl:imports statement to an ontology.") +public class AddOntologyImport extends BaseCommand +{ + + @Option(names = "--import", required = true, paramLabel = "IMPORT_URI", description = "URI of the imported ontology") + private URI importURI; + + @Parameters(paramLabel = "ONTOLOGY_DOC_URI", description = "URI of the ontology document") + private URI target; + + @Override + public Integer call() throws Exception + { + HttpException.check(target, getClient().patch(target, Updates.insertOntologyImport(target, importURI))).close(); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/Admin.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/Admin.java new file mode 100644 index 0000000000..046f9307b9 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/Admin.java @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin; + +import com.atomgraph.linkeddatahub.cli.command.admin.acl.Acl; +import com.atomgraph.linkeddatahub.cli.command.admin.ontologies.Ontologies; +import java.util.concurrent.Callable; +import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.Spec; + +/** + * Administrative command group. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "admin", + description = "Administrative commands.", + subcommands = { Ontologies.class, Acl.class, ClearOntology.class, AddOntologyImport.class }) +public class Admin implements Callable +{ + + @Spec + private CommandSpec spec; + + @Override + public Integer call() + { + throw new ParameterException(spec.commandLine(), "Missing required subcommand"); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ClearOntology.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ClearOntology.java new file mode 100644 index 0000000000..642451bb3e --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ClearOntology.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import jakarta.ws.rs.core.Form; +import java.net.URI; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Clears an ontology from memory so it gets reloaded. Mirrors bin/admin/clear-ontology.sh. + * The base URI is the base of the admin application. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "clear-ontology", description = "Clears an ontology from memory and reloads it.") +public class ClearOntology extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--ontology", required = true, paramLabel = "ONTOLOGY_URI", description = "URI of the ontology") + private URI ontology; + + @Override + public Integer call() throws Exception + { + URI base = baseMixin.require(getSpec()); + URI target = URI.create(base + "clear"); + + printBody(HttpException.check(target, getClient().postForm(target, new Form("uri", ontology.toString()), ACCEPT_TURTLE))); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/Acl.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/Acl.java new file mode 100644 index 0000000000..c44ef168dd --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/Acl.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.acl; + +import java.util.concurrent.Callable; +import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.Spec; + +/** + * Access control command group. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "acl", + description = "Access control commands.", + subcommands = { CreateGroup.class, CreateAuthorization.class, AddAgentToGroup.class, MakePublic.class }) +public class Acl implements Callable +{ + + @Spec + private CommandSpec spec; + + @Override + public Integer call() + { + throw new ParameterException(spec.commandLine(), "Missing required subcommand"); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/AddAgentToGroup.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/AddAgentToGroup.java new file mode 100644 index 0000000000..73abb82fff --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/AddAgentToGroup.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.acl; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import com.atomgraph.linkeddatahub.cli.sparql.Updates; +import java.net.URI; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds an agent to a group. Mirrors bin/admin/acl/add-agent-to-group.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-agent-to-group", description = "Adds an agent to a group.") +public class AddAgentToGroup extends BaseCommand +{ + + @Option(names = "--agent", required = true, paramLabel = "AGENT_URI", description = "URI of the agent") + private URI agent; + + @Parameters(paramLabel = "GROUP_DOC_URI", description = "URI of the group document") + private URI target; + + @Override + public Integer call() throws Exception + { + HttpException.check(target, getClient().patch(target, Updates.insertGroupMember(target, agent))).close(); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateAuthorization.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateAuthorization.java new file mode 100644 index 0000000000..2c43b8ba46 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateAuthorization.java @@ -0,0 +1,163 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.acl; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.util.Slugs; +import com.atomgraph.linkeddatahub.cli.util.URIRewriter; +import com.atomgraph.linkeddatahub.cli.vocab.ACL; +import com.atomgraph.linkeddatahub.cli.vocab.DH; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.sparql.vocabulary.FOAF; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParameterException; + +/** + * Creates an ACL authorization. Mirrors bin/admin/acl/create-authorization.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "create-authorization", description = "Creates an ACL authorization.") +public class CreateAuthorization extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the authorization") + private String label; + + @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the authorization (optional)") + private String comment; + + @Option(names = "--slug", paramLabel = "STRING", description = "String that will be used as URI path segment (optional)") + private String slug; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the authorization (optional, blank node if not set)") + private String uri; + + @Option(names = "--agent", paramLabel = "AGENT_URI", description = "URI of an authorized agent (repeatable)") + private List agents = new ArrayList<>(); + + @Option(names = "--agent-class", paramLabel = "AGENT_CLASS_URI", description = "URI of an authorized agent class (repeatable)") + private List agentClasses = new ArrayList<>(); + + @Option(names = "--agent-group", paramLabel = "AGENT_GROUP_URI", description = "URI of an authorized agent group (repeatable)") + private List agentGroups = new ArrayList<>(); + + @Option(names = "--to", paramLabel = "TO_URI", description = "URI of an accessed document (repeatable)") + private List to = new ArrayList<>(); + + @Option(names = "--to-all-in", paramLabel = "CLASS_URI", description = "URI of an accessed document class (repeatable)") + private List toAllIn = new ArrayList<>(); + + @Option(names = "--append", description = "Grant acl:Append mode") + private boolean append; + + @Option(names = "--control", description = "Grant acl:Control mode") + private boolean control; + + @Option(names = "--read", description = "Grant acl:Read mode") + private boolean read; + + @Option(names = "--write", description = "Grant acl:Write mode") + private boolean write; + + @Override + public Integer call() throws Exception + { + URI base = baseMixin.require(getSpec()); + if (agents.isEmpty() && agentClasses.isEmpty() && agentGroups.isEmpty()) + throw new ParameterException(getSpec().commandLine(), "At least one of '--agent', '--agent-class', '--agent-group' is required"); + if (to.isEmpty() && toAllIn.isEmpty()) + throw new ParameterException(getSpec().commandLine(), "At least one of '--to', '--to-all-in' is required"); + if (!append && !control && !read && !write) + throw new ParameterException(getSpec().commandLine(), "At least one of '--append', '--control', '--read', '--write' is required"); + + URI doc = URIRewriter.childURI(URI.create(base + "acl/authorizations/"), slug != null ? slug : Slugs.defaultSlug()); + + List modes = new ArrayList<>(); + if (append) modes.add(ACL.Append); + if (control) modes.add(ACL.Control); + if (read) modes.add(ACL.Read); + if (write) modes.add(ACL.Write); + + put(getClient(), doc, buildModel(doc, uri, label, comment, agents, agentClasses, agentGroups, to, toAllIn, modes)); + print(doc); + + return 0; + } + + /** + * Builds the authorization document model. + * + * @param doc document URI + * @param uri authorization URI (optional, blank node if null) + * @param label authorization label + * @param comment authorization comment (optional) + * @param agents authorized agent URIs + * @param agentClasses authorized agent class URIs + * @param agentGroups authorized agent group URIs + * @param to accessed document URIs + * @param toAllIn accessed document class URIs + * @param modes granted access modes + * @return document model + */ + public static Model buildModel(URI doc, String uri, String label, String comment, + List agents, List agentClasses, List agentGroups, + List to, List toAllIn, List modes) + { + Model model = ModelFactory.createDefaultModel(); + + Resource auth = createSubject(model, doc, uri). + addProperty(RDF.type, ACL.Authorization). + addProperty(RDFS.label, label); + if (comment != null) auth.addProperty(RDFS.comment, comment); + + model.createResource(doc.toString()). + addProperty(RDF.type, DH.Item). + addProperty(FOAF.primaryTopic, auth). + addProperty(DCTerms.title, label); + + addResourceValues(auth, ACL.agent, agents); + addResourceValues(auth, ACL.agentClass, agentClasses); + addResourceValues(auth, ACL.agentGroup, agentGroups); + addResourceValues(auth, ACL.accessTo, to); + addResourceValues(auth, ACL.accessToClass, toAllIn); + modes.forEach(mode -> auth.addProperty(ACL.mode, mode)); + + return model; + } + + static void addResourceValues(Resource subject, Property property, List values) + { + values.forEach(value -> subject.addProperty(property, subject.getModel().createResource(value.toString()))); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateGroup.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateGroup.java new file mode 100644 index 0000000000..05ec6847e7 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/CreateGroup.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.acl; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.util.Slugs; +import com.atomgraph.linkeddatahub.cli.util.URIRewriter; +import com.atomgraph.linkeddatahub.cli.vocab.DH; +import java.net.URI; +import java.util.List; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.sparql.vocabulary.FOAF; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Creates an agent group. Mirrors bin/admin/acl/create-group.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "create-group", description = "Creates an agent group.") +public class CreateGroup extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--name", required = true, paramLabel = "NAME", description = "Name of the group") + private String name; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the group (optional)") + private String description; + + @Option(names = "--slug", paramLabel = "STRING", description = "String that will be used as URI path segment (optional)") + private String slug; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the group (optional, blank node if not set)") + private String uri; + + @Option(names = "--member", required = true, paramLabel = "MEMBER_URI", description = "URI of a group member (repeatable)") + private List members; + + @Override + public Integer call() throws Exception + { + URI base = baseMixin.require(getSpec()); + URI doc = URIRewriter.childURI(URI.create(base + "acl/groups/"), slug != null ? slug : Slugs.defaultSlug()); + + put(getClient(), doc, buildModel(doc, uri, name, description, members)); + print(doc); + + return 0; + } + + /** + * Builds the group document model. + * + * @param doc document URI + * @param uri group URI (optional, blank node if null) + * @param name group name + * @param description group description (optional) + * @param members member agent URIs + * @return document model + */ + public static Model buildModel(URI doc, String uri, String name, String description, List members) + { + Model model = ModelFactory.createDefaultModel(); + + Resource group = createSubject(model, doc, uri). + addProperty(RDF.type, FOAF.Group). + addProperty(FOAF.name, name); + if (description != null) group.addProperty(DCTerms.description, description); + members.forEach(member -> group.addProperty(FOAF.member, model.createResource(member.toString()))); + + model.createResource(doc.toString()). + addProperty(RDF.type, DH.Item). + addProperty(FOAF.primaryTopic, group). + addProperty(DCTerms.title, name); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/MakePublic.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/MakePublic.java new file mode 100644 index 0000000000..a041e3521f --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/MakePublic.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.acl; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.sparql.Updates; +import com.atomgraph.linkeddatahub.cli.util.URIRewriter; +import java.net.URI; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +/** + * Makes all end-user application documents publicly readable. Mirrors bin/admin/acl/make-public.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "make-public", description = "Makes all end-user application documents publicly readable.") +public class MakePublic extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Override + public Integer call() throws Exception + { + URI base = baseMixin.require(getSpec()); + URI adminBase = URIRewriter.adminBase(base); + URI target = URI.create(adminBase + "acl/authorizations/public/"); + + HttpException.check(target, getClient().patch(target, Updates.makePublic(base, adminBase))).close(); + + return 0; + } + + @Override + protected URI getEffectiveProxy() + { + // the request targets the admin app, so the proxy origin gets the admin subdomain too + return getProxyMixin().getProxy() != null ? URIRewriter.adminBase(getProxyMixin().getProxy()) : null; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddClass.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddClass.java new file mode 100644 index 0000000000..64c097ce2a --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddClass.java @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.ontologies; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.SPIN; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.OWL; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds a class to an ontology. Mirrors bin/admin/ontologies/add-class.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-class", description = "Adds a class to an ontology.") +public class AddClass extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the class") + private String label; + + @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the class (optional)") + private String comment; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the class (optional, blank node if not set)") + private String uri; + + @Option(names = "--constructor", paramLabel = "CONSTRUCT_URI", description = "URI of the constructor query (optional)") + private URI constructor; + + @Option(names = "--constraint", paramLabel = "CONSTRAINT_URI", description = "URI of the constraint (optional)") + private URI constraint; + + @Option(names = "--sub-class-of", paramLabel = "SUPER_CLASS_URI", description = "URI of a superclass (optional, repeatable)") + private List superClasses = new ArrayList<>(); + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the ontology document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + post(getClient(), target, buildModel(target, uri, label, comment, constructor, constraint, superClasses)); + print(target); + + return 0; + } + + /** + * Builds the class description. + * + * @param target target document URI + * @param uri class URI (optional) + * @param label class label + * @param comment class comment (optional) + * @param constructor constructor query URI (optional) + * @param constraint constraint URI (optional) + * @param superClasses superclass URIs + * @return class model + */ + public static Model buildModel(URI target, String uri, String label, String comment, URI constructor, URI constraint, List superClasses) + { + Model model = ModelFactory.createDefaultModel(); + + Resource cls = createSubject(model, target, uri). + addProperty(RDF.type, OWL.Class). + addProperty(RDFS.label, label); + if (comment != null) cls.addProperty(RDFS.comment, comment); + if (constructor != null) cls.addProperty(SPIN.constructor, model.createResource(constructor.toString())); + if (constraint != null) cls.addProperty(SPIN.constraint, model.createResource(constraint.toString())); + superClasses.forEach(superClass -> cls.addProperty(RDFS.subClassOf, model.createResource(superClass.toString()))); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddConstructor.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddConstructor.java new file mode 100644 index 0000000000..d5f222b642 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddConstructor.java @@ -0,0 +1,101 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.ontologies; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.LDH; +import com.atomgraph.linkeddatahub.cli.vocab.SP; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds a SPARQL CONSTRUCT constructor query to an ontology. Mirrors bin/admin/ontologies/add-constructor.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-constructor", description = "Adds a CONSTRUCT query to an ontology.") +public class AddConstructor extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the query") + private String label; + + @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the query (optional)") + private String comment; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the query (optional, blank node if not set)") + private String uri; + + @Option(names = "--query-file", required = true, paramLabel = "ABS_PATH", description = "Path to the file with the query string") + private Path queryFile; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the ontology document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + post(getClient(), target, buildModel(target, uri, SP.Construct, label, Files.readString(queryFile), null, comment)); + print(target); + + return 0; + } + + /** + * Builds an ontology SPIN query description (labeled with rdfs:label, + * unlike the dct:title-based document queries). + * + * @param target target document URI + * @param uri query URI (optional) + * @param queryType SPIN query class (sp:Construct or sp:Select) + * @param label query label + * @param queryText query string + * @param service SPARQL service URI (optional) + * @param comment query comment (optional) + * @return query model + */ + public static Model buildModel(URI target, String uri, Resource queryType, String label, String queryText, URI service, String comment) + { + Model model = ModelFactory.createDefaultModel(); + + Resource query = createSubject(model, target, uri). + addProperty(RDF.type, queryType). + addProperty(RDFS.label, label). + addProperty(SP.text, queryText); + if (comment != null) query.addProperty(RDFS.comment, comment); + if (service != null) query.addProperty(LDH.service, model.createResource(service.toString())); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddPropertyConstraint.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddPropertyConstraint.java new file mode 100644 index 0000000000..74ff2db865 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddPropertyConstraint.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.ontologies; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.LDH; +import com.atomgraph.linkeddatahub.cli.vocab.SP; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds a constraint that makes a property required. Mirrors bin/admin/ontologies/add-property-constraint.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-property-constraint", description = "Adds a constraint that makes a property required.") +public class AddPropertyConstraint extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the constraint") + private String label; + + @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the constraint (optional)") + private String comment; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the constraint (optional, blank node if not set)") + private String uri; + + @Option(names = "--property", required = true, paramLabel = "PROPERTY_URI", description = "URI of the required property") + private URI property; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the ontology document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + post(getClient(), target, buildModel(target, uri, label, property, comment)); + print(target); + + return 0; + } + + /** + * Builds the constraint description. + * + * @param target target document URI + * @param uri constraint URI (optional) + * @param label constraint label + * @param property required property URI + * @param comment constraint comment (optional) + * @return constraint model + */ + public static Model buildModel(URI target, String uri, String label, URI property, String comment) + { + Model model = ModelFactory.createDefaultModel(); + + Resource constraint = createSubject(model, target, uri). + addProperty(RDF.type, LDH.MissingPropertyValue). + addProperty(RDFS.label, label). + addProperty(SP.arg1, model.createResource(property.toString())); + if (comment != null) constraint.addProperty(RDFS.comment, comment); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddRestriction.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddRestriction.java new file mode 100644 index 0000000000..c2b6ee6cea --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddRestriction.java @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.ontologies; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.OWL; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds an OWL restriction to an ontology. Mirrors bin/admin/ontologies/add-restriction.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-restriction", description = "Adds an OWL restriction to an ontology.") +public class AddRestriction extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the restriction") + private String label; + + @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the restriction (optional)") + private String comment; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the restriction (optional, blank node if not set)") + private String uri; + + @Option(names = "--on-property", paramLabel = "PROPERTY_URI", description = "URI of the restricted property (optional)") + private URI onProperty; + + @Option(names = "--all-values-from", paramLabel = "URI", description = "URI of the value class (optional)") + private URI allValuesFrom; + + @Option(names = "--has-value", paramLabel = "URI", description = "URI of the value resource (optional)") + private URI hasValue; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the ontology document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + post(getClient(), target, buildModel(target, uri, label, comment, onProperty, allValuesFrom, hasValue)); + print(target); + + return 0; + } + + /** + * Builds the restriction description. + * + * @param target target document URI + * @param uri restriction URI (optional) + * @param label restriction label + * @param comment restriction comment (optional) + * @param onProperty restricted property URI (optional) + * @param allValuesFrom value class URI (optional) + * @param hasValue value resource URI (optional) + * @return restriction model + */ + public static Model buildModel(URI target, String uri, String label, String comment, URI onProperty, URI allValuesFrom, URI hasValue) + { + Model model = ModelFactory.createDefaultModel(); + + Resource restriction = createSubject(model, target, uri). + addProperty(RDF.type, OWL.Restriction). + addProperty(RDFS.label, label); + if (comment != null) restriction.addProperty(RDFS.comment, comment); + if (onProperty != null) restriction.addProperty(OWL.onProperty, model.createResource(onProperty.toString())); + if (allValuesFrom != null) restriction.addProperty(OWL.allValuesFrom, model.createResource(allValuesFrom.toString())); + if (hasValue != null) restriction.addProperty(OWL.hasValue, model.createResource(hasValue.toString())); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddSelect.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddSelect.java new file mode 100644 index 0000000000..0938ffc9e4 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/AddSelect.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.ontologies; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.SP; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds a SPARQL SELECT query to an ontology. Mirrors bin/admin/ontologies/add-select.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-select", description = "Adds a SELECT query to an ontology.") +public class AddSelect extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the query") + private String label; + + @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the query (optional)") + private String comment; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the query (optional, blank node if not set)") + private String uri; + + @Option(names = "--query-file", required = true, paramLabel = "ABS_PATH", description = "Path to the file with the query string") + private Path queryFile; + + @Option(names = "--service", paramLabel = "SERVICE_URI", description = "URI of the SPARQL service (optional)") + private URI service; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the ontology document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + post(getClient(), target, AddConstructor.buildModel(target, uri, SP.Select, label, Files.readString(queryFile), service, comment)); + print(target); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/CreateOntology.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/CreateOntology.java new file mode 100644 index 0000000000..96cface9ff --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/CreateOntology.java @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.ontologies; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.util.Slugs; +import com.atomgraph.linkeddatahub.cli.util.URIRewriter; +import com.atomgraph.linkeddatahub.cli.vocab.DH; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.sparql.vocabulary.FOAF; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.OWL; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Creates a new ontology document. Mirrors bin/admin/ontologies/create-ontology.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "create-ontology", description = "Creates a new ontology.") +public class CreateOntology extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--label", required = true, paramLabel = "LABEL", description = "Label of the ontology") + private String label; + + @Option(names = "--comment", paramLabel = "COMMENT", description = "Comment of the ontology (optional)") + private String comment; + + @Option(names = "--slug", paramLabel = "STRING", description = "String that will be used as URI path segment (optional)") + private String slug; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the ontology (optional, blank node if not set)") + private String uri; + + @Override + public Integer call() throws Exception + { + URI base = baseMixin.require(getSpec()); + URI doc = URIRewriter.childURI(URI.create(base + "ontologies/"), slug != null ? slug : Slugs.defaultSlug()); + + put(getClient(), doc, buildModel(doc, uri, label, comment)); + print(doc); + + return 0; + } + + /** + * Builds the ontology document model. + * + * @param doc document URI + * @param uri ontology URI (optional, blank node if null) + * @param label ontology label + * @param comment ontology comment (optional) + * @return document model + */ + public static Model buildModel(URI doc, String uri, String label, String comment) + { + Model model = ModelFactory.createDefaultModel(); + + Resource ontology = createSubject(model, doc, uri). + addProperty(RDF.type, OWL.Ontology). + addProperty(RDFS.label, label); + if (comment != null) ontology.addProperty(RDFS.comment, comment); + + model.createResource(doc.toString()). + addProperty(RDF.type, DH.Item). + addProperty(FOAF.primaryTopic, ontology). + addProperty(DCTerms.title, label); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/ImportOntology.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/ImportOntology.java new file mode 100644 index 0000000000..94de67f7e7 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/ImportOntology.java @@ -0,0 +1,209 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.ontologies; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.DH; +import com.atomgraph.linkeddatahub.cli.vocab.SP; +import jakarta.ws.rs.core.Form; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.OWL; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +/** + * Imports an external ontology: derives class constructors from its triples and appends them, + * together with an owl:imports of the source, to a document. + * Mirrors bin/admin/ontologies/import-ontology.sh. + * + * The vocabulary itself is scaffolding: it is fetched through the Linked Data proxy into a scratch + * document that scopes the construct-constructors CONSTRUCT via the SPARQL Protocol + * dataset specification, then deleted - on the error paths too. Only the derived annotations + * persist; the vocabulary resolves live through the graph repository. + * + * The base URI is the base of the admin application. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "import-ontology", description = "Derives class constructors from an external ontology and appends them, with an owl:imports of the source, to a document.") +public class ImportOntology extends BaseCommand +{ + + /** Accepted response media type of the proxied vocabulary and the derived constructors */ + private static final MediaType[] ACCEPT_RDF_XML = { com.atomgraph.core.MediaType.APPLICATION_RDF_XML_TYPE }; + /** Path of the document holding the constructor derivation query, relative to the admin base URI */ + private static final String CONSTRUCT_CONSTRUCTORS_PATH = "queries/construct-constructors/"; + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--source", required = true, paramLabel = "SOURCE_URI", description = "URI of the imported ontology") + private URI source; + + @Option(names = "--graph", required = true, paramLabel = "GRAPH_URI", description = "URI of the document the ontology is imported into") + private URI graph; + + @Override + public Integer call() throws Exception + { + URI base = baseMixin.require(getSpec()); + Model vocabulary = getVocabulary(base, source); + String query = getConstructorQuery(base); + URI scratch = URI.create(base + UUID.randomUUID().toString() + "/"); + + put(getClient(), scratch, buildScratchModel(scratch)); + + try + { + post(getClient(), scratch, vocabulary); + post(getClient(), graph, construct(base, query, scratch)); + post(getClient(), graph, buildAnnotationModel(graph, source)); + } + finally + { + deleteScratch(scratch); + } + + print(graph); + + return 0; + } + + /** + * Fetches the source ontology through the Linked Data proxy, which converts any Jena-parseable + * format to RDF/XML. + * + * @param base admin application base URI + * @param source imported ontology URI + * @return vocabulary model + */ + protected Model getVocabulary(URI base, URI source) + { + URI target = URI.create(base + "?uri=" + URLEncoder.encode(source.toString(), StandardCharsets.UTF_8)); + + try (Response response = HttpException.check(target, getClient().get(target, ACCEPT_RDF_XML))) + { + return response.readEntity(Model.class); + } + } + + /** + * Reads the text of the constructor derivation query from its own document. + * + * @param base admin application base URI + * @return SPARQL CONSTRUCT query string + */ + protected String getConstructorQuery(URI base) + { + URI queryDoc = URI.create(base + CONSTRUCT_CONSTRUCTORS_PATH); + + try (Response response = HttpException.check(queryDoc, getClient().get(queryDoc, ACCEPT_TURTLE))) + { + Resource query = response.readEntity(Model.class).getResource(queryDoc + "#this"); + if (!query.hasProperty(SP.text)) throw new IllegalStateException("Could not load the transformation query from <" + query + ">"); + + return query.getRequiredProperty(SP.text).getString(); + } + } + + /** + * Runs the CONSTRUCT over the scratch graph, scoping it via the SPARQL Protocol dataset specification. + * + * @param base admin application base URI + * @param query SPARQL CONSTRUCT query string + * @param scratch scratch document URI + * @return derived constructor model + */ + protected Model construct(URI base, String query, URI scratch) + { + URI endpoint = URI.create(base + "sparql"); + Form form = new Form("query", query).param("default-graph-uri", scratch.toString()); + + try (Response response = HttpException.check(endpoint, getClient().postForm(endpoint, form, ACCEPT_RDF_XML))) + { + return response.readEntity(Model.class); + } + } + + /** + * Deletes the scratch document, best-effort: a failure here is reported but never masks the + * outcome of the derivation itself. + * + * @param scratch scratch document URI + */ + protected void deleteScratch(URI scratch) + { + try + { + getClient().delete(scratch).close(); + } + catch (Exception e) + { + getSpec().commandLine().getErr().println("Could not delete the scratch document <" + scratch + ">: " + e.getMessage()); + } + } + + /** + * Builds the description of the scratch document that holds the vocabulary during the derivation. + * + * @param scratch scratch document URI + * @return scratch document model + */ + public static Model buildScratchModel(URI scratch) + { + Model model = ModelFactory.createDefaultModel(); + + model.createResource(scratch.toString()). + addProperty(RDF.type, DH.Item). + addProperty(DCTerms.title, "Import ontology scratch"); + + return model; + } + + /** + * Builds the annotation ontology header: the document imports the source vocabulary, which + * resolves live through the graph repository. + * + * @param graph target document URI + * @param source imported ontology URI + * @return annotation header model + */ + public static Model buildAnnotationModel(URI graph, URI source) + { + Model model = ModelFactory.createDefaultModel(); + + model.createResource(graph.toString()). + addProperty(RDF.type, OWL.Ontology). + addProperty(OWL.imports, model.createResource(source.toString())); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/Ontologies.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/Ontologies.java new file mode 100644 index 0000000000..e7fdbdec41 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/Ontologies.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.admin.ontologies; + +import java.util.concurrent.Callable; +import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.Spec; + +/** + * Ontology management command group. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "ontologies", + description = "Ontology management commands.", + subcommands = { CreateOntology.class, ImportOntology.class, AddClass.class, AddConstructor.class, + AddSelect.class, AddPropertyConstraint.class, AddRestriction.class }) +public class Ontologies implements Callable +{ + + @Spec + private CommandSpec spec; + + @Override + public Integer call() + { + throw new ParameterException(spec.commandLine(), "Missing required subcommand"); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddObjectBlock.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddObjectBlock.java new file mode 100644 index 0000000000..b9860d23dc --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddObjectBlock.java @@ -0,0 +1,121 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.content; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.util.SequenceNumbers; +import com.atomgraph.linkeddatahub.cli.vocab.AC; +import com.atomgraph.linkeddatahub.cli.vocab.LDH; +import jakarta.ws.rs.core.Response; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Appends an object content block to a document. Mirrors bin/content/add-object-block.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-object-block", description = "Appends an object content block to a document.") +public class AddObjectBlock extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; // accepted for script interface parity, unused + + @Option(names = "--value", required = true, paramLabel = "RESOURCE_URI", description = "URI of the object resource") + private URI value; + + @Option(names = "--title", paramLabel = "TITLE", description = "Title of the block (optional)") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the block (optional)") + private String description; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the block (optional, blank node if not set)") + private String uri; + + @Option(names = "--mode", paramLabel = "MODE_URI", description = "URI of the layout mode (optional)") + private URI mode; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + post(getClient(), target, buildModel(target, nextSequenceProperty(), uri, value, title, description, mode)); + print(target); + + return 0; + } + + /** + * Fetches the target document and returns the next free rdf:_N membership property. + * + * @return membership property + */ + protected Property nextSequenceProperty() + { + Model current; + try (Response response = HttpException.check(target, getClient().get(target, ACCEPT_NTRIPLES))) + { + current = response.readEntity(Model.class); + } + + return SequenceNumbers.nextSequenceProperty(current, ResourceFactory.createResource(target.toString())); + } + + /** + * Builds the object block description. + * + * @param target target document URI + * @param seq membership property (rdf:_N) + * @param uri block URI (optional) + * @param value object resource URI + * @param title block title (optional) + * @param description block description (optional) + * @param mode layout mode URI (optional) + * @return block model + */ + public static Model buildModel(URI target, Property seq, String uri, URI value, String title, String description, URI mode) + { + Model model = ModelFactory.createDefaultModel(); + + Resource block = createSubject(model, target, uri). + addProperty(RDF.type, LDH.Object). + addProperty(RDF.value, model.createResource(value.toString())); + model.createResource(target.toString()).addProperty(seq, block); + if (title != null) block.addProperty(DCTerms.title, title); + if (description != null) block.addProperty(DCTerms.description, description); + if (mode != null) block.addProperty(AC.mode, model.createResource(mode.toString())); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddXHTMLBlock.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddXHTMLBlock.java new file mode 100644 index 0000000000..7995fd0c2b --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/AddXHTMLBlock.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.content; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.util.SequenceNumbers; +import com.atomgraph.linkeddatahub.cli.vocab.LDH; +import jakarta.ws.rs.core.Response; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Appends an XHTML content block to a document. Mirrors bin/content/add-xhtml-block.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-xhtml-block", description = "Appends an XHTML content block to a document.") +public class AddXHTMLBlock extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; // accepted for script interface parity, unused + + @Option(names = "--value", required = true, paramLabel = "XHTML", description = "XHTML content (must be well-formed XML)") + private String value; + + @Option(names = "--title", paramLabel = "TITLE", description = "Title of the block (optional)") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the block (optional)") + private String description; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the block (optional, blank node if not set)") + private String uri; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + Model current; + try (Response response = HttpException.check(target, getClient().get(target, ACCEPT_NTRIPLES))) + { + current = response.readEntity(Model.class); + } + Property seq = SequenceNumbers.nextSequenceProperty(current, ResourceFactory.createResource(target.toString())); + + post(getClient(), target, buildModel(target, seq, uri, value, title, description)); + print(target); + + return 0; + } + + /** + * Builds the XHTML block description. + * + * @param target target document URI + * @param seq membership property (rdf:_N) + * @param uri block URI (optional) + * @param value XHTML content + * @param title block title (optional) + * @param description block description (optional) + * @return block model + */ + public static Model buildModel(URI target, Property seq, String uri, String value, String title, String description) + { + Model model = ModelFactory.createDefaultModel(); + + Resource block = createSubject(model, target, uri). + addProperty(RDF.type, LDH.XHTML). + addProperty(RDF.value, model.createTypedLiteral(value, RDF.dtXMLLiteral)); + model.createResource(target.toString()).addProperty(seq, block); + if (title != null) block.addProperty(DCTerms.title, title); + if (description != null) block.addProperty(DCTerms.description, description); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/Content.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/Content.java new file mode 100644 index 0000000000..900db7912c --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/Content.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.content; + +import java.util.concurrent.Callable; +import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.Spec; + +/** + * Content block command group. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "content", + description = "Content block commands.", + subcommands = { AddObjectBlock.class, AddXHTMLBlock.class, RemoveBlock.class }) +public class Content implements Callable +{ + + @Spec + private CommandSpec spec; + + @Override + public Integer call() + { + throw new ParameterException(spec.commandLine(), "Missing required subcommand"); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/RemoveBlock.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/RemoveBlock.java new file mode 100644 index 0000000000..dd6bfee7cd --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/RemoveBlock.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.content; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.HttpException; +import com.atomgraph.linkeddatahub.cli.sparql.Updates; +import java.net.URI; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Removes a content block (or all content blocks) from a document. Mirrors bin/content/remove-block.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "remove-block", description = "Removes a content block from a document.") +public class RemoveBlock extends BaseCommand +{ + + @Option(names = "--block", paramLabel = "BLOCK_URI", description = "URI of the content block (optional, all blocks if not set)") + private URI block; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + HttpException.check(target, getClient().patch(target, Updates.removeBlock(target, block))).close(); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddCSVImport.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddCSVImport.java new file mode 100644 index 0000000000..0decc0d175 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddCSVImport.java @@ -0,0 +1,123 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.imports; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.LDHClient; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.LDH; +import com.atomgraph.linkeddatahub.cli.vocab.SPIN; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds CSV import metadata to a document. Mirrors bin/imports/add-csv-import.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-csv-import", description = "Adds CSV import metadata to a document.") +public class AddCSVImport extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the import") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the import (optional)") + private String description; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the import (optional, blank node if not set)") + private String uri; + + @Option(names = "--query", required = true, paramLabel = "QUERY_URI", description = "URI of the transformation CONSTRUCT query") + private URI query; + + @Option(names = "--file", required = true, paramLabel = "FILE_URI", description = "URI of the uploaded CSV file") + private URI file; + + @Option(names = "--delimiter", defaultValue = ",", paramLabel = "CHAR", description = "CSV delimiter character (default: ${DEFAULT-VALUE})") + private String delimiter; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + core(getClient(), target, uri, title, query, file, delimiter, description); + print(target); + + return 0; + } + + /** + * Appends the CSV import metadata to the target document. + * + * @param client client instance + * @param target target document URI + * @param uri import URI (optional) + * @param title import title + * @param query transformation query URI + * @param file uploaded file URI + * @param delimiter CSV delimiter + * @param description import description (optional) + */ + public static void core(LDHClient client, URI target, String uri, String title, URI query, URI file, String delimiter, String description) + { + post(client, target, buildModel(target, uri, title, query, file, delimiter, description)); + } + + /** + * Builds the CSV import description. + * + * @param target target document URI + * @param uri import URI (optional) + * @param title import title + * @param query transformation query URI + * @param file uploaded file URI + * @param delimiter CSV delimiter + * @param description import description (optional) + * @return import model + */ + public static Model buildModel(URI target, String uri, String title, URI query, URI file, String delimiter, String description) + { + Model model = ModelFactory.createDefaultModel(); + + Resource csvImport = createSubject(model, target, uri). + addProperty(RDF.type, LDH.CSVImport). + addProperty(DCTerms.title, title). + addProperty(SPIN.query, model.createResource(query.toString())). + addProperty(LDH.file, model.createResource(file.toString())). + addProperty(LDH.delimiter, delimiter); + if (description != null) csvImport.addProperty(DCTerms.description, description); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddRDFImport.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddRDFImport.java new file mode 100644 index 0000000000..4b91effcf6 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/AddRDFImport.java @@ -0,0 +1,124 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.imports; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.http.LDHClient; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.vocab.LDH; +import com.atomgraph.linkeddatahub.cli.vocab.SD; +import com.atomgraph.linkeddatahub.cli.vocab.SPIN; +import java.net.URI; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.DCTerms; +import org.apache.jena.vocabulary.RDF; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Adds RDF import metadata to a document. Mirrors bin/imports/add-rdf-import.sh. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "add-rdf-import", description = "Adds RDF import metadata to a document.") +public class AddRDFImport extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the import") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the import (optional)") + private String description; + + @Option(names = "--uri", paramLabel = "URI", description = "URI of the import (optional, blank node if not set)") + private String uri; + + @Option(names = "--query", paramLabel = "QUERY_URI", description = "URI of the transformation CONSTRUCT query (optional)") + private URI query; + + @Option(names = "--graph", paramLabel = "GRAPH_URI", description = "URI of the target named graph (optional)") + private URI graph; + + @Option(names = "--file", required = true, paramLabel = "FILE_URI", description = "URI of the uploaded RDF file") + private URI file; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the document") + private URI target; + + @Override + public Integer call() throws Exception + { + baseMixin.require(getSpec()); // required by the script interface + + core(getClient(), target, uri, title, file, query, graph, description); + print(target); + + return 0; + } + + /** + * Appends the RDF import metadata to the target document. + * + * @param client client instance + * @param target target document URI + * @param uri import URI (optional) + * @param title import title + * @param file uploaded file URI + * @param query transformation query URI (optional) + * @param graph target named graph URI (optional) + * @param description import description (optional) + */ + public static void core(LDHClient client, URI target, String uri, String title, URI file, URI query, URI graph, String description) + { + post(client, target, buildModel(target, uri, title, file, query, graph, description)); + } + + /** + * Builds the RDF import description. + * + * @param target target document URI + * @param uri import URI (optional) + * @param title import title + * @param file uploaded file URI + * @param query transformation query URI (optional) + * @param graph target named graph URI (optional) + * @param description import description (optional) + * @return import model + */ + public static Model buildModel(URI target, String uri, String title, URI file, URI query, URI graph, String description) + { + Model model = ModelFactory.createDefaultModel(); + + Resource rdfImport = createSubject(model, target, uri). + addProperty(RDF.type, LDH.RDFImport). + addProperty(DCTerms.title, title). + addProperty(LDH.file, model.createResource(file.toString())); + if (graph != null) rdfImport.addProperty(SD.name, model.createResource(graph.toString())); + if (query != null) rdfImport.addProperty(SPIN.query, model.createResource(query.toString())); + if (description != null) rdfImport.addProperty(DCTerms.description, description); + + return model; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportCSV.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportCSV.java new file mode 100644 index 0000000000..ea45095184 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportCSV.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.imports; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.command.AddConstruct; +import com.atomgraph.linkeddatahub.cli.command.AddFile; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.util.Slugs; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Imports CSV data: adds the transformation query, uploads the CSV file and creates the + * import metadata on the target document. Mirrors bin/imports/import-csv.sh, + * calling the same steps in-process instead of via subscripts. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "import-csv", description = "Imports CSV data using a transformation query.") +public class ImportCSV extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the import") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the import (optional)") + private String description; + + @Option(names = "--query-file", required = true, paramLabel = "ABS_PATH", description = "Path to the file with the transformation CONSTRUCT query") + private Path queryFile; + + @Option(names = "--csv-file", required = true, paramLabel = "ABS_PATH", description = "Path to the CSV file") + private Path csvFile; + + @Option(names = "--delimiter", defaultValue = ",", paramLabel = "CHAR", description = "CSV delimiter character (default: ${DEFAULT-VALUE})") + private String delimiter; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the import document") + private URI target; + + @Override + public Integer call() throws Exception + { + URI base = baseMixin.require(getSpec()); + + String queryId = Slugs.defaultSlug(); + AddConstruct.core(getClient(), target, "#" + queryId, title, Files.readString(queryFile), null, null); + URI query = target.resolve("#" + queryId); + + URI fileURI = AddFile.core(getClient(), base, target, csvFile, "text/csv", title, null); + + AddCSVImport.core(getClient(), target, null, title, query, fileURI, delimiter, description); + print(target); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportRDF.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportRDF.java new file mode 100644 index 0000000000..db3cbed84e --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/ImportRDF.java @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.imports; + +import com.atomgraph.linkeddatahub.cli.BaseCommand; +import com.atomgraph.linkeddatahub.cli.command.AddConstruct; +import com.atomgraph.linkeddatahub.cli.command.AddFile; +import com.atomgraph.linkeddatahub.cli.mixin.BaseMixin; +import com.atomgraph.linkeddatahub.cli.util.Slugs; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** + * Imports RDF data: optionally adds a transformation query, uploads the RDF file and creates + * the import metadata on the target document. Mirrors bin/imports/import-rdf.sh, + * calling the same steps in-process instead of via subscripts. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "import-rdf", description = "Imports RDF data, optionally using a transformation query.") +public class ImportRDF extends BaseCommand +{ + + @Mixin + private BaseMixin baseMixin; + + @Option(names = "--title", required = true, paramLabel = "TITLE", description = "Title of the import") + private String title; + + @Option(names = "--description", paramLabel = "DESCRIPTION", description = "Description of the import (optional)") + private String description; + + @Option(names = "--query-file", paramLabel = "ABS_PATH", description = "Path to the file with the transformation CONSTRUCT query (optional)") + private Path queryFile; + + @Option(names = "--rdf-file", required = true, paramLabel = "ABS_PATH", description = "Path to the RDF file") + private Path rdfFile; + + @Option(names = "--content-type", required = true, paramLabel = "MEDIA_TYPE", description = "Media type of the RDF file (e.g. text/turtle)") + private String contentType; + + @Option(names = "--graph", paramLabel = "GRAPH_URI", description = "URI of the target named graph (optional)") + private URI graph; + + @Parameters(paramLabel = "TARGET_URI", description = "URI of the import document") + private URI target; + + @Override + public Integer call() throws Exception + { + URI base = baseMixin.require(getSpec()); + + URI query = null; + if (queryFile != null) + { + String queryId = Slugs.defaultSlug(); + AddConstruct.core(getClient(), target, "#" + queryId, title, Files.readString(queryFile), null, null); + query = target.resolve("#" + queryId); + } + + URI fileURI = AddFile.core(getClient(), base, target, rdfFile, contentType, title, null); + + String importId = Slugs.defaultSlug(); + AddRDFImport.core(getClient(), target, "#" + importId, title, fileURI, query, query == null ? graph : null, description); + print(target); + + return 0; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/Imports.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/Imports.java new file mode 100644 index 0000000000..65b084d19c --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/Imports.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command.imports; + +import java.util.concurrent.Callable; +import picocli.CommandLine.Command; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.Spec; + +/** + * Data import command group. + * + * @author Martynas Jusevičius {@literal } + */ +@Command(name = "imports", + description = "Data import commands.", + subcommands = { AddCSVImport.class, AddRDFImport.class, ImportCSV.class, ImportRDF.class }) +public class Imports implements Callable +{ + + @Spec + private CommandSpec spec; + + @Override + public Integer call() + { + throw new ParameterException(spec.commandLine(), "Missing required subcommand"); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/ClientFactory.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/ClientFactory.java new file mode 100644 index 0000000000..2a7cf1f24c --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/ClientFactory.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.http; + +import com.atomgraph.core.io.ModelProvider; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import org.apache.http.config.Registry; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.conn.socket.ConnectionSocketFactory; +import org.apache.http.conn.socket.PlainConnectionSocketFactory; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.glassfish.jersey.apache.connector.ApacheClientProperties; +import org.glassfish.jersey.apache.connector.ApacheConnectorProvider; +import org.glassfish.jersey.client.ClientConfig; +import org.glassfish.jersey.client.ClientProperties; +import org.glassfish.jersey.client.RequestEntityProcessing; +import org.glassfish.jersey.media.multipart.MultiPartFeature; + +/** + * Builds a Jersey HTTP client authenticated with a WebID client certificate from a PKCS12 keystore. + * Mirrors Application.getClient() in LinkedDataHub, with server certificate checks + * disabled (equivalent of curl -k against self-signed dev instances). + * + * @author Martynas Jusevičius {@literal } + */ +public final class ClientFactory +{ + + private ClientFactory() { } + + /** + * Builds the client instance. + * + * @param keyStoreFile PKCS12 (.p12) keystore file with the WebID certificate + * @param keyStorePassword keystore password + * @return client instance + */ + public static Client createClient(Path keyStoreFile, String keyStorePassword) + { + SSLContext ctx; + try + { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (InputStream is = Files.newInputStream(keyStoreFile)) + { + keyStore.load(is, keyStorePassword.toCharArray()); + } + + // for client authentication + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, keyStorePassword.toCharArray()); + + ctx = SSLContext.getInstance("TLS"); + ctx.init(kmf.getKeyManagers(), new TrustManager[] { TRUST_ALL }, new SecureRandom()); + } + catch (IOException | GeneralSecurityException ex) + { + throw new IllegalArgumentException("Could not load PKCS12 keystore '" + keyStoreFile + "': " + ex.getMessage() + " (wrong password?)", ex); + } + + Registry socketFactoryRegistry = RegistryBuilder.create(). + register("https", new SSLConnectionSocketFactory(ctx, NoopHostnameVerifier.INSTANCE)). + register("http", new PlainConnectionSocketFactory()). + build(); + + ClientConfig config = new ClientConfig(); + config.connectorProvider(new ApacheConnectorProvider()); + config.register(MultiPartFeature.class); + config.register(new ModelProvider()); + config.property(ClientProperties.FOLLOW_REDIRECTS, false); // scripts use curl without -L + config.property(ClientProperties.REQUEST_ENTITY_PROCESSING, RequestEntityProcessing.BUFFERED); + config.property(ApacheClientProperties.CONNECTION_MANAGER, new PoolingHttpClientConnectionManager(socketFactoryRegistry)); + + return ClientBuilder.newBuilder(). + withConfig(config). + sslContext(ctx). + hostnameVerifier(NoopHostnameVerifier.INSTANCE). + build(); + } + + private static final X509TrustManager TRUST_ALL = new X509TrustManager() + { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { } + + @Override + public X509Certificate[] getAcceptedIssuers() + { + return new X509Certificate[0]; + } + + }; + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/HttpException.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/HttpException.java new file mode 100644 index 0000000000..a4bf717018 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/HttpException.java @@ -0,0 +1,102 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.http; + +import jakarta.ws.rs.core.Response; +import java.net.URI; + +/** + * Thrown when the server responds with an error status, mirroring curl -f. + * + * @author Martynas Jusevičius {@literal } + */ +public class HttpException extends RuntimeException +{ + + private static final int BODY_EXCERPT_LENGTH = 1024; + + private final int status; + private final URI uri; + + /** + * Constructs the exception. + * + * @param status HTTP status code + * @param reasonPhrase HTTP reason phrase + * @param uri request URI + * @param body response body excerpt (can be empty) + */ + public HttpException(int status, String reasonPhrase, URI uri, String body) + { + super("HTTP " + status + " " + reasonPhrase + " — " + uri + (body == null || body.isBlank() ? "" : "\n" + body)); + this.status = status; + this.uri = uri; + } + + /** + * Throws if the response has an error status (≥ 400), otherwise returns it. + * + * @param uri request URI (for the error message) + * @param response response to check + * @return the same response + */ + public static Response check(URI uri, Response response) + { + if (response.getStatus() >= 400) + { + String body = ""; + try + { + body = response.readEntity(String.class); + if (body.length() > BODY_EXCERPT_LENGTH) body = body.substring(0, BODY_EXCERPT_LENGTH) + "…"; + } + catch (Exception ex) + { + // body is unreadable - leave the excerpt empty + } + finally + { + response.close(); + } + + throw new HttpException(response.getStatus(), response.getStatusInfo().getReasonPhrase(), uri, body); + } + + return response; + } + + /** + * Returns the HTTP status code. + * + * @return status code + */ + public int getStatus() + { + return status; + } + + /** + * Returns the request URI. + * + * @return request URI + */ + public URI getUri() + { + return uri; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/LDHClient.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/LDHClient.java new file mode 100644 index 0000000000..c62a0a4dd4 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/http/LDHClient.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.http; + +import com.atomgraph.core.MediaTypes; +import com.atomgraph.core.client.GraphStoreClient; +import com.atomgraph.linkeddatahub.cli.util.URIRewriter; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Form; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.net.URI; + +/** + * Graph Store Protocol client for LinkedDataHub documents (direct graph identification), + * extended with SPARQL update over PATCH and form-encoded POST. When a proxy URI is given, + * every request URI has its origin rewritten to the proxy's origin, matching the + * --proxy handling of the bin/ shell scripts. + * + * @author Martynas Jusevičius {@literal } + */ +public class LDHClient extends GraphStoreClient +{ + + /** SPARQL update media type */ + public static final MediaType APPLICATION_SPARQL_UPDATE_TYPE = MediaType.valueOf("application/sparql-update"); + + private final URI proxy; + + /** + * Constructs the client. + * + * @param client Jersey client with WebID client certificate + * @param mediaTypes registry of readable/writable media types + * @param proxy proxy URI whose origin replaces the request URI origin (optional, can be null) + */ + public LDHClient(Client client, MediaTypes mediaTypes, URI proxy) + { + super(client, mediaTypes); + this.proxy = proxy; + } + + @Override + protected WebTarget getWebTarget(URI uri) + { + return super.getWebTarget(getProxy() != null ? URIRewriter.rewrite(uri, getProxy()) : uri); + } + + /** + * Patches a document with a SPARQL update. + * + * @param uri document URI + * @param update SPARQL update string + * @return response + */ + public Response patch(URI uri, String update) + { + return getWebTarget(uri).request().method("PATCH", Entity.entity(update, APPLICATION_SPARQL_UPDATE_TYPE)); + } + + /** + * Posts a form-encoded request. + * + * @param uri target URI + * @param form form params + * @param acceptedTypes accepted response media types + * @return response + */ + public Response postForm(URI uri, Form form, MediaType... acceptedTypes) + { + return getWebTarget(uri).request(acceptedTypes).post(Entity.form(form)); + } + + /** + * Returns the proxy URI, if any. + * + * @return proxy URI or null + */ + public URI getProxy() + { + return proxy; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/BaseMixin.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/BaseMixin.java new file mode 100644 index 0000000000..5b56680cf4 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/BaseMixin.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.mixin; + +import java.net.URI; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParameterException; + +/** + * Application base URI option shared by commands whose script counterpart takes -b. + * + * @author Martynas Jusevičius {@literal } + */ +public class BaseMixin +{ + + @Option(names = {"-b", "--base"}, defaultValue = "${env:LDH_BASE}", paramLabel = "BASE_URI", + description = "Base URI of the application (env: LDH_BASE)") + private URI base; + + /** + * Validates presence of the base URI and returns it. + * + * @param spec command spec used to raise usage errors + * @return base URI + */ + public URI require(CommandSpec spec) + { + if (base == null) throw new ParameterException(spec.commandLine(), "Missing required option: '--base=BASE_URI' (or set LDH_BASE)"); + + return base; + } + + /** + * Returns the base URI, if any. + * + * @return base URI or null + */ + public URI getBase() + { + return base; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/CertAuthMixin.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/CertAuthMixin.java new file mode 100644 index 0000000000..23ac29efaa --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/CertAuthMixin.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.mixin; + +import java.nio.file.Path; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParameterException; + +/** + * WebID client certificate options shared by all commands. + * + * @author Martynas Jusevičius {@literal } + */ +public class CertAuthMixin +{ + + @Option(names = {"-f", "--cert-file"}, defaultValue = "${env:LDH_CERT_FILE}", paramLabel = "CERT_FILE", + description = ".p12 (PKCS12) keystore with the WebID certificate of the agent (env: LDH_CERT_FILE)") + private Path certFile; + + @Option(names = {"-p", "--cert-password"}, defaultValue = "${env:LDH_CERT_PASSWORD}", paramLabel = "CERT_PASSWORD", + description = "Password of the WebID certificate (env: LDH_CERT_PASSWORD)") + private String certPassword; + + /** + * Validates that both certificate options are present. + * + * @param spec command spec used to raise usage errors + */ + public void validate(CommandSpec spec) + { + if (certFile == null) throw new ParameterException(spec.commandLine(), "Missing required option: '--cert-file=CERT_FILE' (or set LDH_CERT_FILE)"); + if (certPassword == null) throw new ParameterException(spec.commandLine(), "Missing required option: '--cert-password=CERT_PASSWORD' (or set LDH_CERT_PASSWORD)"); + } + + /** + * Returns the keystore path. + * + * @return keystore path + */ + public Path getCertFile() + { + return certFile; + } + + /** + * Returns the keystore password. + * + * @return keystore password + */ + public String getCertPassword() + { + return certPassword; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/ProxyMixin.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/ProxyMixin.java new file mode 100644 index 0000000000..876a8d54a5 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/mixin/ProxyMixin.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.mixin; + +import java.net.URI; +import picocli.CommandLine.Option; + +/** + * Proxy option shared by all commands. + * + * @author Martynas Jusevičius {@literal } + */ +public class ProxyMixin +{ + + @Option(names = "--proxy", defaultValue = "${env:LDH_PROXY}", paramLabel = "PROXY_URL", + description = "The host this request will be proxied through (optional) (env: LDH_PROXY)") + private URI proxy; + + /** + * Returns the proxy URI, if any. + * + * @return proxy URI or null + */ + public URI getProxy() + { + return proxy; + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/sparql/Updates.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/sparql/Updates.java new file mode 100644 index 0000000000..cb621e377b --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/sparql/Updates.java @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.sparql; + +import java.net.URI; +import org.apache.jena.query.ParameterizedSparqlString; + +/** + * SPARQL update templates for the PATCH-based commands. All templates are standard SPARQL 1.1; + * IRIs are injected via {@link ParameterizedSparqlString} to guarantee correct escaping. + * + * @author Martynas Jusevičius {@literal } + */ +public final class Updates +{ + + private Updates() { } + + /** + * Adds an owl:imports statement to the ontology that is the primary topic + * of the given document. + * + * @param ontologyDoc ontology document URI + * @param importURI imported ontology URI + * @return SPARQL update string + */ + public static String insertOntologyImport(URI ontologyDoc, URI importURI) + { + ParameterizedSparqlString pss = new ParameterizedSparqlString(""" + PREFIX owl: + PREFIX foaf: + INSERT { + ?ontology owl:imports ?import . + } + WHERE { + ?doc foaf:primaryTopic ?ontology . + } + """); + pss.setIri("doc", ontologyDoc.toString()); + pss.setIri("import", importURI.toString()); + return pss.toString(); + } + + /** + * Adds a foaf:member statement to the group that is the primary topic + * of the given document. + * + * @param groupDoc group document URI + * @param agent agent URI + * @return SPARQL update string + */ + public static String insertGroupMember(URI groupDoc, URI agent) + { + ParameterizedSparqlString pss = new ParameterizedSparqlString(""" + PREFIX foaf: + INSERT { + ?group foaf:member ?agent . + } + WHERE { + ?doc foaf:primaryTopic ?group . + } + """); + pss.setIri("doc", groupDoc.toString()); + pss.setIri("agent", agent.toString()); + return pss.toString(); + } + + /** + * Removes a content block (or all content blocks) from a document, together with the + * block's own description. + * + * @param doc document URI + * @param block block URI, or null to remove all blocks + * @return SPARQL update string + */ + public static String removeBlock(URI doc, URI block) + { + ParameterizedSparqlString pss = new ParameterizedSparqlString(""" + PREFIX rdf: + + DELETE + { + ?doc ?seq ?block . + ?block ?p ?o . + } + WHERE + { + ?doc ?seq ?block . + FILTER(strstarts(str(?seq), concat(str(rdf:), "_"))) + OPTIONAL + { + ?block ?p ?o + } + } + """); + pss.setIri("doc", doc.toString()); + if (block != null) pss.setIri("block", block.toString()); + return pss.toString(); + } + + /** + * Makes all end-user application documents publicly readable and allows queries over POST, + * by extending the built-in public authorization. + * + * @param base end-user application base URI + * @param adminBase admin application base URI + * @return SPARQL update string + */ + public static String makePublic(URI base, URI adminBase) + { + ParameterizedSparqlString pss = new ParameterizedSparqlString(""" + PREFIX acl: + PREFIX def: + PREFIX dh: + PREFIX nfo: + PREFIX foaf: + + INSERT + { + ?public acl:accessToClass def:Root, dh:Container, dh:Item, nfo:FileDataObject ; + acl:accessTo ?sparql . + + ?sparqlPost a acl:Authorization ; + acl:accessTo ?sparql ; + acl:mode acl:Append ; + acl:agentClass foaf:Agent, acl:AuthenticatedAgent . # hacky way to allow queries over POST + } + WHERE + {} + """); + pss.setIri("public", adminBase + "acl/authorizations/public/#this"); + pss.setIri("sparqlPost", adminBase + "acl/authorizations/public/#sparql-post"); + pss.setIri("sparql", base + "sparql"); + return pss.toString(); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Digests.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Digests.java new file mode 100644 index 0000000000..dd1638251b --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Digests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * File digest helpers. + * + * @author Martynas Jusevičius {@literal } + */ +public final class Digests +{ + + private Digests() { } + + /** + * Returns the lowercase hex SHA1 digest of a file, matching shasum -a 1. + * + * @param file file path + * @return hex digest string + */ + public static String sha1Hex(Path file) + { + try + { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + + try (InputStream is = Files.newInputStream(file)) + { + byte[] buffer = new byte[8192]; + int read; + while ((read = is.read(buffer)) != -1) md.update(buffer, 0, read); + } + + return HexFormat.of().formatHex(md.digest()); + } + catch (NoSuchAlgorithmException ex) + { + throw new IllegalStateException(ex); + } + catch (IOException ex) + { + throw new UncheckedIOException("Could not read file '" + file + "'", ex); + } + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbers.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbers.java new file mode 100644 index 0000000000..22fcc37387 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbers.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.util; + +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; +import org.apache.jena.rdf.model.StmtIterator; +import org.apache.jena.vocabulary.RDF; + +/** + * RDF container membership property (rdf:_N) helpers for content blocks. + * + * @author Martynas Jusevičius {@literal } + */ +public final class SequenceNumbers +{ + + private SequenceNumbers() { } + + /** + * Returns the next free membership property rdf:_(max + 1) for the given subject, + * where max is the highest existing rdf:_N predicate (0 if none). + * + * @param model model with the subject's description + * @param subject resource whose membership properties are scanned + * @return next membership property + */ + public static Property nextSequenceProperty(Model model, Resource subject) + { + String prefix = RDF.getURI() + "_"; + int max = 0; + + StmtIterator it = model.listStatements(subject, null, (RDFNode)null); + try + { + while (it.hasNext()) + { + String uri = it.next().getPredicate().getURI(); + if (uri.startsWith(prefix)) + try + { + max = Math.max(max, Integer.parseInt(uri.substring(prefix.length()))); + } + catch (NumberFormatException ex) + { + // not a membership property, e.g. rdf:_x + } + } + } + finally + { + it.close(); + } + + return ResourceFactory.createProperty(prefix + (max + 1)); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Slugs.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Slugs.java new file mode 100644 index 0000000000..6d77b04ed8 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/Slugs.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.util; + +import java.util.Locale; +import java.util.UUID; + +/** + * Default URI path segment generation. + * + * @author Martynas Jusevičius {@literal } + */ +public final class Slugs +{ + + private Slugs() { } + + /** + * Returns a random lowercase UUID slug, matching uuidgen | tr '[:upper:]' '[:lower:]'. + * + * @return slug string + */ + public static String defaultSlug() + { + return UUID.randomUUID().toString().toLowerCase(Locale.ROOT); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/URIRewriter.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/URIRewriter.java new file mode 100644 index 0000000000..593181c035 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/util/URIRewriter.java @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.util; + +import java.net.URI; +import java.nio.charset.StandardCharsets; + +/** + * URI manipulation helpers matching the conventions of the bin/ shell scripts. + * + * @author Martynas Jusevičius {@literal } + */ +public final class URIRewriter +{ + + private URIRewriter() { } + + /** + * Replaces the origin (scheme and authority) of a URI with the origin of the proxy URI, + * keeping the path, query and fragment. + * + * @param uri logical URI + * @param proxy proxy URI whose origin is substituted + * @return rewritten URI + */ + public static URI rewrite(URI uri, URI proxy) + { + return URI.create(origin(proxy) + uri.toString().substring(origin(uri).length())); + } + + /** + * Returns the origin (scheme and authority) of a URI. + * + * @param uri URI + * @return origin string, e.g. https://localhost:4443 + */ + public static String origin(URI uri) + { + if (uri.getScheme() == null || uri.getRawAuthority() == null) throw new IllegalArgumentException("URI '" + uri + "' is not absolute"); + + return uri.getScheme() + "://" + uri.getRawAuthority(); + } + + /** + * Converts an end-user application base URI to the base URI of its admin application + * by prefixing the host with the admin. subdomain. + * + * @param base end-user base URI + * @return admin base URI + */ + public static URI adminBase(URI base) + { + return URI.create(base.toString().replaceFirst("://", "://admin.")); + } + + /** + * Percent-encodes a string as a URI path segment. All characters except RFC 3986 + * unreserved ones are encoded, including /. + * + * @param slug path segment + * @return encoded path segment + */ + public static String encodeSlug(String slug) + { + StringBuilder sb = new StringBuilder(); + + for (byte b : slug.getBytes(StandardCharsets.UTF_8)) + { + char c = (char)(b & 0xFF); + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~') sb.append(c); + else sb.append('%').append(String.format("%02X", b & 0xFF)); + } + + return sb.toString(); + } + + /** + * Builds the URI of a child document from the parent container URI and a path segment slug. + * + * @param parent parent container URI (with trailing slash) + * @param slug path segment + * @return child document URI (with trailing slash) + */ + public static URI childURI(URI parent, String slug) + { + return URI.create(parent.toString() + encodeSlug(slug) + "/"); + } + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/A.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/A.java new file mode 100644 index 0000000000..73dd5abd3f --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/A.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.vocab; + +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.ResourceFactory; + +/** + * AtomGraph Core vocabulary. + * + * @author Martynas Jusevičius {@literal } + */ +public final class A +{ + + /** Namespace URI */ + public static final String NS = "https://w3id.org/atomgraph/core#"; + + private A() { } + + /** a:graphStore property */ + public static final Property graphStore = ResourceFactory.createProperty(NS + "graphStore"); + /** a:authUser property */ + public static final Property authUser = ResourceFactory.createProperty(NS + "authUser"); + /** a:authPwd property */ + public static final Property authPwd = ResourceFactory.createProperty(NS + "authPwd"); + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/AC.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/AC.java new file mode 100644 index 0000000000..9de56512ea --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/AC.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.vocab; + +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.ResourceFactory; + +/** + * AtomGraph Client vocabulary. + * + * @author Martynas Jusevičius {@literal } + */ +public final class AC +{ + + /** Namespace URI */ + public static final String NS = "https://w3id.org/atomgraph/client#"; + + private AC() { } + + /** ac:mode property */ + public static final Property mode = ResourceFactory.createProperty(NS + "mode"); + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/ACL.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/ACL.java new file mode 100644 index 0000000000..e7f3ff7096 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/ACL.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.vocab; + +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; + +/** + * W3C Web Access Control vocabulary. + * + * @author Martynas Jusevičius {@literal } + */ +public final class ACL +{ + + /** Namespace URI */ + public static final String NS = "http://www.w3.org/ns/auth/acl#"; + + private ACL() { } + + /** acl:Authorization class */ + public static final Resource Authorization = ResourceFactory.createResource(NS + "Authorization"); + /** acl:Append mode */ + public static final Resource Append = ResourceFactory.createResource(NS + "Append"); + /** acl:Control mode */ + public static final Resource Control = ResourceFactory.createResource(NS + "Control"); + /** acl:Read mode */ + public static final Resource Read = ResourceFactory.createResource(NS + "Read"); + /** acl:Write mode */ + public static final Resource Write = ResourceFactory.createResource(NS + "Write"); + + /** acl:agent property */ + public static final Property agent = ResourceFactory.createProperty(NS + "agent"); + /** acl:agentClass property */ + public static final Property agentClass = ResourceFactory.createProperty(NS + "agentClass"); + /** acl:agentGroup property */ + public static final Property agentGroup = ResourceFactory.createProperty(NS + "agentGroup"); + /** acl:accessTo property */ + public static final Property accessTo = ResourceFactory.createProperty(NS + "accessTo"); + /** acl:accessToClass property */ + public static final Property accessToClass = ResourceFactory.createProperty(NS + "accessToClass"); + /** acl:mode property */ + public static final Property mode = ResourceFactory.createProperty(NS + "mode"); + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/DH.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/DH.java new file mode 100644 index 0000000000..5829aae8e4 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/DH.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.vocab; + +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; + +/** + * Document hierarchy vocabulary. + * + * @author Martynas Jusevičius {@literal } + */ +public final class DH +{ + + /** Namespace URI */ + public static final String NS = "https://www.w3.org/ns/ldt/document-hierarchy#"; + + private DH() { } + + /** dh:Item class */ + public static final Resource Item = ResourceFactory.createResource(NS + "Item"); + /** dh:Container class */ + public static final Resource Container = ResourceFactory.createResource(NS + "Container"); + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/LDH.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/LDH.java new file mode 100644 index 0000000000..d256327514 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/LDH.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.vocab; + +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; + +/** + * LinkedDataHub vocabulary. + * + * @author Martynas Jusevičius {@literal } + */ +public final class LDH +{ + + /** Namespace URI */ + public static final String NS = "https://w3id.org/atomgraph/linkeddatahub#"; + + private LDH() { } + + /** ldh:Object class */ + public static final Resource Object = ResourceFactory.createResource(NS + "Object"); + /** ldh:XHTML class */ + public static final Resource XHTML = ResourceFactory.createResource(NS + "XHTML"); + /** ldh:View class */ + public static final Resource View = ResourceFactory.createResource(NS + "View"); + /** ldh:ResultSetChart class */ + public static final Resource ResultSetChart = ResourceFactory.createResource(NS + "ResultSetChart"); + /** ldh:CSVImport class */ + public static final Resource CSVImport = ResourceFactory.createResource(NS + "CSVImport"); + /** ldh:RDFImport class */ + public static final Resource RDFImport = ResourceFactory.createResource(NS + "RDFImport"); + /** ldh:MissingPropertyValue constraint class */ + public static final Resource MissingPropertyValue = ResourceFactory.createResource(NS + "MissingPropertyValue"); + /** ldh:ChildrenView resource */ + public static final Resource ChildrenView = ResourceFactory.createResource(NS + "ChildrenView"); + /** ldh:SelectChildren query resource */ + public static final Resource SelectChildren = ResourceFactory.createResource(NS + "SelectChildren"); + + /** ldh:service property */ + public static final Property service = ResourceFactory.createProperty(NS + "service"); + /** ldh:file property */ + public static final Property file = ResourceFactory.createProperty(NS + "file"); + /** ldh:delimiter property */ + public static final Property delimiter = ResourceFactory.createProperty(NS + "delimiter"); + /** ldh:chartType property */ + public static final Property chartType = ResourceFactory.createProperty(NS + "chartType"); + /** ldh:categoryVarName property */ + public static final Property categoryVarName = ResourceFactory.createProperty(NS + "categoryVarName"); + /** ldh:seriesVarName property */ + public static final Property seriesVarName = ResourceFactory.createProperty(NS + "seriesVarName"); + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/NFO.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/NFO.java new file mode 100644 index 0000000000..de6a53c654 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/NFO.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.vocab; + +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; + +/** + * NEPOMUK File Ontology vocabulary. + * + * @author Martynas Jusevičius {@literal } + */ +public final class NFO +{ + + /** Namespace URI */ + public static final String NS = "http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#"; + + private NFO() { } + + /** nfo:FileDataObject class */ + public static final Resource FileDataObject = ResourceFactory.createResource(NS + "FileDataObject"); + + /** nfo:fileName property */ + public static final Property fileName = ResourceFactory.createProperty(NS + "fileName"); + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SD.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SD.java new file mode 100644 index 0000000000..4bec9d26fe --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SD.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.vocab; + +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; + +/** + * SPARQL 1.1 Service Description vocabulary. + * + * @author Martynas Jusevičius {@literal } + */ +public final class SD +{ + + /** Namespace URI */ + public static final String NS = "http://www.w3.org/ns/sparql-service-description#"; + + private SD() { } + + /** sd:Service class */ + public static final Resource Service = ResourceFactory.createResource(NS + "Service"); + /** sd:SPARQL11Query language */ + public static final Resource SPARQL11Query = ResourceFactory.createResource(NS + "SPARQL11Query"); + /** sd:SPARQL11Update language */ + public static final Resource SPARQL11Update = ResourceFactory.createResource(NS + "SPARQL11Update"); + + /** sd:endpoint property */ + public static final Property endpoint = ResourceFactory.createProperty(NS + "endpoint"); + /** sd:supportedLanguage property */ + public static final Property supportedLanguage = ResourceFactory.createProperty(NS + "supportedLanguage"); + /** sd:name property */ + public static final Property name = ResourceFactory.createProperty(NS + "name"); + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SP.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SP.java new file mode 100644 index 0000000000..6634441f9b --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SP.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.vocab; + +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; + +/** + * SPIN SPARQL syntax vocabulary. + * + * @author Martynas Jusevičius {@literal } + */ +public final class SP +{ + + /** Namespace URI */ + public static final String NS = "http://spinrdf.org/sp#"; + + private SP() { } + + /** sp:Construct class */ + public static final Resource Construct = ResourceFactory.createResource(NS + "Construct"); + /** sp:Select class */ + public static final Resource Select = ResourceFactory.createResource(NS + "Select"); + + /** sp:text property */ + public static final Property text = ResourceFactory.createProperty(NS + "text"); + /** sp:arg1 property */ + public static final Property arg1 = ResourceFactory.createProperty(NS + "arg1"); + +} diff --git a/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SPIN.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SPIN.java new file mode 100644 index 0000000000..a4cbec33d3 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/vocab/SPIN.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.vocab; + +import org.apache.jena.rdf.model.Property; +import org.apache.jena.rdf.model.ResourceFactory; + +/** + * SPIN modeling vocabulary. + * + * @author Martynas Jusevičius {@literal } + */ +public final class SPIN +{ + + /** Namespace URI */ + public static final String NS = "http://spinrdf.org/spin#"; + + private SPIN() { } + + /** spin:query property */ + public static final Property query = ResourceFactory.createProperty(NS + "query"); + /** spin:constructor property */ + public static final Property constructor = ResourceFactory.createProperty(NS + "constructor"); + /** spin:constraint property */ + public static final Property constraint = ResourceFactory.createProperty(NS + "constraint"); + +} diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/CommandOutputTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/CommandOutputTest.java new file mode 100644 index 0000000000..196fe31979 --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/CommandOutputTest.java @@ -0,0 +1,235 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli; + +import com.atomgraph.linkeddatahub.cli.http.StubServer; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the output contract the shell pipelines depend on: a command that creates or appends to a + * document prints its URL as the only line on stdout, diagnostics go to stderr, and an HTTP error + * status leaves stdout empty and exits 1. + * + * item=$(ldh create-item ...) breaks the moment anything else reaches stdout, and the + * http-tests consume that substitution in dozens of places. + */ +public class CommandOutputTest +{ + + static Path keyStorePath() throws Exception + { + return Paths.get(CommandOutputTest.class.getResource("/test-keystore.p12").toURI()); + } + + static CommandLine commandLine(StringWriter out, StringWriter err) + { + CommandLine cmd = new CommandLine(new LDH()); + cmd.setOut(new PrintWriter(out, true)); + cmd.setErr(new PrintWriter(err, true)); + cmd.setExecutionExceptionHandler(LDH::handleExecutionException); + return cmd; + } + + @Test + public void createdURLIsTheOnlyLineOnStdout() throws Exception + { + try (StubServer server = new StubServer()) + { + server.responds(201, ""); + URI base = server.baseURI(); + StringWriter out = new StringWriter(), err = new StringWriter(); + + int code = commandLine(out, err).execute("create-container", + "-f", keyStorePath().toString(), "-p", "changeit", "-b", base.toString(), + "--title", "Test", "--slug", "test", "--parent", base.toString()); + + assertEquals(0, code); + assertEquals(base + "test/", out.toString().strip()); + assertEquals(1, out.toString().strip().lines().count(), "stdout carries more than the created URL"); + assertEquals("", err.toString(), "stderr is not empty on success"); + } + } + + @Test + public void createdURLMatchesTheDocumentActuallyWritten() throws Exception + { + try (StubServer server = new StubServer()) + { + server.responds(201, ""); + URI base = server.baseURI(); + StringWriter out = new StringWriter(), err = new StringWriter(); + + commandLine(out, err).execute("create-container", + "-f", keyStorePath().toString(), "-p", "changeit", "-b", base.toString(), + "--title", "Test", "--slug", "test", "--parent", base.toString()); + + assertEquals("PUT", server.getLastMethod()); + assertEquals("/test/", server.getLastTarget()); + assertTrue(server.getLastBody().contains("Test"), server.getLastBody()); + } + } + + @Test + public void slugIsPercentEncodedInBothURLAndRequest() throws Exception + { + try (StubServer server = new StubServer()) + { + server.responds(201, ""); + URI base = server.baseURI(); + StringWriter out = new StringWriter(), err = new StringWriter(); + + int code = commandLine(out, err).execute("create-container", + "-f", keyStorePath().toString(), "-p", "changeit", "-b", base.toString(), + "--title", "Ö", "--slug", "ö x", "--parent", base.toString()); + + assertEquals(0, code); + assertEquals(base + "%C3%B6%20x/", out.toString().strip()); + assertEquals("/%C3%B6%20x/", server.getLastTarget()); + } + } + + @Test + public void putReadsRDFFromStdinResolvingAgainstTheTarget() throws Exception + { + InputStream in = System.in; + try (StubServer server = new StubServer()) + { + server.responds(201, ""); + URI target = server.baseURI().resolve("some/"); + StringWriter out = new StringWriter(), err = new StringWriter(); + + // a relative subject, as the scripts piped through `turtle --base` + System.setIn(new ByteArrayInputStream("<> \"Piped\" ." + .getBytes(StandardCharsets.UTF_8))); + + int code = commandLine(out, err).execute("put", + "-f", keyStorePath().toString(), "-p", "changeit", "-t", "text/turtle", target.toString()); + + assertEquals(0, code); + assertEquals("PUT", server.getLastMethod()); + assertTrue(server.getLastBody().contains(target.toString()), "relative subject was not resolved against the target: " + server.getLastBody()); + assertTrue(server.getLastBody().contains("Piped"), server.getLastBody()); + assertEquals(target.toString(), out.toString().strip()); + } + finally + { + System.setIn(in); + } + } + + @Test + public void patchSendsTheStdinUpdateVerbatim() throws Exception + { + InputStream in = System.in; + try (StubServer server = new StubServer()) + { + server.responds(204, ""); + URI target = server.baseURI().resolve("some/"); + StringWriter out = new StringWriter(), err = new StringWriter(); + + String update = "PREFIX dct: \nDELETE WHERE { ?s dct:title ?o }"; + System.setIn(new ByteArrayInputStream(update.getBytes(StandardCharsets.UTF_8))); + + int code = commandLine(out, err).execute("patch", + "-f", keyStorePath().toString(), "-p", "changeit", target.toString()); + + assertEquals(0, code); + assertEquals("PATCH", server.getLastMethod()); + assertEquals(update, server.getLastBody()); + } + finally + { + System.setIn(in); + } + } + + @Test + public void patchRejectsAMalformedUpdateBeforeSending() throws Exception + { + InputStream in = System.in; + try (StubServer server = new StubServer()) + { + server.responds(204, ""); + URI target = server.baseURI().resolve("some/"); + StringWriter out = new StringWriter(), err = new StringWriter(); + + System.setIn(new ByteArrayInputStream("DELETE WHERE { this is not SPARQL".getBytes(StandardCharsets.UTF_8))); + + int code = commandLine(out, err).execute("patch", + "-f", keyStorePath().toString(), "-p", "changeit", target.toString()); + + assertEquals(CommandLine.ExitCode.SOFTWARE, code); + assertNull(server.getLastMethod(), "a malformed update must not reach the server"); + } + finally + { + System.setIn(in); + } + } + + @Test + public void httpErrorStatusExitsOneWithEmptyStdout() throws Exception + { + try (StubServer server = new StubServer()) + { + server.responds(403, "Forbidden by authorization"); + URI base = server.baseURI(); + StringWriter out = new StringWriter(), err = new StringWriter(); + + int code = commandLine(out, err).execute("create-container", + "-f", keyStorePath().toString(), "-p", "changeit", "-b", base.toString(), + "--title", "Test", "--slug", "test", "--parent", base.toString()); + + assertEquals(CommandLine.ExitCode.SOFTWARE, code); + assertEquals("", out.toString(), "a failed command must print nothing on stdout"); + assertTrue(err.toString().contains("HTTP 403"), err.toString()); + } + } + + @Test + public void connectionFailureExitsOneWithEmptyStdout() throws Exception + { + URI base; + try (StubServer server = new StubServer()) + { + base = server.baseURI(); // port is free again once the server is closed + } + + StringWriter out = new StringWriter(), err = new StringWriter(); + + int code = commandLine(out, err).execute("create-container", + "-f", keyStorePath().toString(), "-p", "changeit", "-b", base.toString(), + "--title", "Test", "--slug", "test", "--parent", base.toString()); + + assertEquals(CommandLine.ExitCode.SOFTWARE, code); + assertEquals("", out.toString(), "a failed command must print nothing on stdout"); + assertTrue(err.toString().contains("Connection refused"), err.toString()); + } + +} diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/CommandParsingTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/CommandParsingTest.java new file mode 100644 index 0000000000..2c9bc2cd5f --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/CommandParsingTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli; + +import java.io.PrintWriter; +import java.io.Writer; +import java.net.URI; +import java.util.List; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; +import picocli.CommandLine.ParseResult; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for the command tree and argument parsing. + */ +public class CommandParsingTest +{ + + static CommandLine commandLine() + { + CommandLine cmd = new CommandLine(new LDH()); + cmd.setOut(new PrintWriter(Writer.nullWriter())); + cmd.setErr(new PrintWriter(Writer.nullWriter())); + return cmd; + } + + @Test + public void commandTreeMirrorsScriptLayout() + { + CommandLine root = commandLine(); + + List.of("get", "post", "put", "patch", "delete", "create-item", "create-container", + "add-view", "add-construct", "add-select", "add-result-set-chart", "add-file", "add-generic-service", + "admin", "content", "imports"). + forEach(name -> assertTrue(root.getSubcommands().containsKey(name), name)); + + CommandLine admin = root.getSubcommands().get("admin"); + List.of("ontologies", "acl", "clear-ontology", "add-ontology-import"). + forEach(name -> assertTrue(admin.getSubcommands().containsKey(name), name)); + + List.of("create-ontology", "import-ontology", "add-class", "add-constructor", "add-select", + "add-property-constraint", "add-restriction"). + forEach(name -> assertTrue(admin.getSubcommands().get("ontologies").getSubcommands().containsKey(name), name)); + + List.of("create-group", "create-authorization", "add-agent-to-group", "make-public"). + forEach(name -> assertTrue(admin.getSubcommands().get("acl").getSubcommands().containsKey(name), name)); + + List.of("add-object-block", "add-xhtml-block", "remove-block"). + forEach(name -> assertTrue(root.getSubcommands().get("content").getSubcommands().containsKey(name), name)); + + List.of("add-csv-import", "add-rdf-import", "import-csv", "import-rdf"). + forEach(name -> assertTrue(root.getSubcommands().get("imports").getSubcommands().containsKey(name), name)); + } + + @Test + public void missingRequiredOptionIsUsageError() + { + assertEquals(CommandLine.ExitCode.USAGE, commandLine().execute("create-item", "--container", "https://localhost:4443/some/")); + } + + @Test + public void unknownOptionIsUsageError() + { + assertEquals(CommandLine.ExitCode.USAGE, commandLine().execute("get", "--bogus")); + } + + @Test + public void bareGroupCommandIsUsageError() + { + assertEquals(CommandLine.ExitCode.USAGE, commandLine().execute("admin")); + assertEquals(CommandLine.ExitCode.USAGE, commandLine().execute("admin", "acl")); + } + + @Test + public void repeatableOptionsAccumulate() + { + ParseResult parseResult = commandLine().parseArgs("admin", "acl", "create-group", + "-f", "cert.p12", "-p", "secret", "-b", "https://admin.localhost:4443/", + "--name", "Editors", + "--member", "https://localhost:4443/acl/agents/a/#this", + "--member", "https://localhost:4443/acl/agents/b/#this"); + + ParseResult createGroup = parseResult.subcommand().subcommand().subcommand(); + List members = createGroup.matchedOption("--member").getValue(); + assertEquals(2, members.size()); + } + + @Test + public void missingCertOptionsFailValidationAtExecutionTime() + { + // cert options have env-var defaults, so they are validated at execution time, not parse time + assertNotNull(commandLine().parseArgs("delete", "https://localhost:4443/some/")); + assertEquals(CommandLine.ExitCode.USAGE, commandLine().execute("delete", "https://localhost:4443/some/")); + } + +} diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/AddFileMultiPartTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/AddFileMultiPartTest.java new file mode 100644 index 0000000000..a40c4ae8f7 --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/AddFileMultiPartTest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.glassfish.jersey.media.multipart.FormDataBodyPart; +import org.glassfish.jersey.media.multipart.FormDataMultiPart; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests the RDF/POST multipart encoding of {@link AddFile}: the field order is positional, + * each pu must immediately precede its ol/ou value. + */ +public class AddFileMultiPartTest +{ + + @TempDir + Path tempDir; + + @Test + public void fieldOrderIsPositional() throws Exception + { + Path file = tempDir.resolve("data.csv"); + Files.writeString(file, "a,b\n1,2\n"); + + try (FormDataMultiPart multiPart = AddFile.buildMultiPart(file, "text/csv", "Data", null)) + { + List names = multiPart.getBodyParts().stream(). + map(part -> ((FormDataBodyPart)part).getName()). + toList(); + + assertEquals(List.of("rdf", "sb", "pu", "ol", "pu", "ol", "pu", "ou"), names); + assertEquals("text/csv", multiPart.getBodyParts().get(3).getMediaType().toString()); + } + } + + @Test + public void descriptionAppendsTrailingPair() throws Exception + { + Path file = tempDir.resolve("data.csv"); + Files.writeString(file, "a,b\n1,2\n"); + + try (FormDataMultiPart multiPart = AddFile.buildMultiPart(file, "text/csv", "Data", "Description")) + { + List names = multiPart.getBodyParts().stream(). + map(part -> ((FormDataBodyPart)part).getName()). + toList(); + + assertEquals(List.of("rdf", "sb", "pu", "ol", "pu", "ol", "pu", "ou", "pu", "ol"), names); + } + } + +} diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/ModelBuildersTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/ModelBuildersTest.java new file mode 100644 index 0000000000..6b049c34c8 --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/command/ModelBuildersTest.java @@ -0,0 +1,400 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.command; + +import com.atomgraph.linkeddatahub.cli.command.admin.acl.CreateAuthorization; +import com.atomgraph.linkeddatahub.cli.command.admin.acl.CreateGroup; +import com.atomgraph.linkeddatahub.cli.command.admin.ontologies.AddClass; +import com.atomgraph.linkeddatahub.cli.command.admin.ontologies.AddConstructor; +import com.atomgraph.linkeddatahub.cli.command.admin.ontologies.AddPropertyConstraint; +import com.atomgraph.linkeddatahub.cli.command.admin.ontologies.AddRestriction; +import com.atomgraph.linkeddatahub.cli.command.admin.ontologies.CreateOntology; +import com.atomgraph.linkeddatahub.cli.command.admin.ontologies.ImportOntology; +import com.atomgraph.linkeddatahub.cli.command.content.AddObjectBlock; +import com.atomgraph.linkeddatahub.cli.command.content.AddXHTMLBlock; +import com.atomgraph.linkeddatahub.cli.command.imports.AddCSVImport; +import com.atomgraph.linkeddatahub.cli.command.imports.AddRDFImport; +import com.atomgraph.linkeddatahub.cli.vocab.ACL; +import com.atomgraph.linkeddatahub.cli.vocab.SP; +import java.io.StringReader; +import java.net.URI; +import java.util.List; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.riot.Lang; +import org.apache.jena.riot.RDFParser; +import org.apache.jena.vocabulary.RDF; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Compares command model builders against the Turtle bodies of the original shell scripts. + */ +public class ModelBuildersTest +{ + + private static final URI TARGET = URI.create("https://localhost:4443/some/"); + private static final String PREFIXES = """ + @prefix dh: . + @prefix ldh: . + @prefix rdf: . + @prefix dct: . + @prefix spin: . + @prefix sp: . + @prefix ac: . + @prefix a: . + @prefix acl: . + @prefix sd: . + @prefix owl: . + @prefix rdfs: . + @prefix foaf: . + """; + + static Model parse(String turtle) + { + Model model = ModelFactory.createDefaultModel(); + RDFParser.create().source(new StringReader(PREFIXES + turtle)).lang(Lang.TURTLE).base(TARGET.toString()).parse(model); + return model; + } + + static void assertIsomorphic(Model expected, Model actual) + { + assertTrue(actual.isIsomorphicWith(expected), + "Expected:\n" + expected.toString() + "\nActual:\n" + actual.toString()); + } + + @Test + public void createItem() + { + URI doc = URI.create("https://localhost:4443/some/my-item/"); + + assertIsomorphic(parse(""" + a dh:Item ; + dct:title "My item" ; + dct:description "Desc" . + """), + CreateItem.buildModel(doc, "My item", "Desc")); + } + + @Test + public void createContainerDefaultChildrenView() + { + assertIsomorphic(parse(""" + <> a dh:Container ; + dct:title "Some" ; + rdf:_1 [ a ldh:Object ; rdf:value ldh:ChildrenView ] . + """), + CreateContainer.buildModel(TARGET, "Some", null, null, null)); + } + + @Test + public void createContainerWithMode() + { + assertIsomorphic(parse(""" + <> a dh:Container ; + dct:title "Some" ; + rdf:_1 [ a ldh:Object ; rdf:value [ a ldh:View ; spin:query ldh:SelectChildren ; ac:mode ] ] . + """), + CreateContainer.buildModel(TARGET, "Some", null, null, URI.create("https://w3id.org/atomgraph/client#GridMode"))); + } + + @Test + public void createContainerWithBlock() + { + assertIsomorphic(parse(""" + <> a dh:Container ; + dct:title "Some" ; + rdf:_1 . + """), + CreateContainer.buildModel(TARGET, "Some", null, URI.create("https://localhost:4443/some/#block"), null)); + } + + @Test + public void addViewWithURIAndMode() + { + assertIsomorphic(parse(""" + <#view> a ldh:View ; + spin:query ; + dct:title "View" ; + ac:mode . + """), + AddView.buildModel(TARGET, "#view", URI.create("https://localhost:4443/queries/q/#this"), "View", null, + URI.create("https://w3id.org/atomgraph/client#GridMode"))); + } + + @Test + public void addConstructQuery() + { + assertIsomorphic(parse(""" + _:q a sp:Construct ; + dct:title "Query" ; + sp:text \"""CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }\""" ; + ldh:service . + """), + AddConstruct.buildModel(TARGET, null, SP.Construct, "Query", "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + URI.create("https://localhost:4443/services/s/#this"), null)); + } + + @Test + public void createOntology() + { + URI doc = URI.create("https://admin.localhost:4443/ontologies/my-ont/"); + + assertIsomorphic(parse(""" + @base . + _:ontology a owl:Ontology ; + rdfs:label "My ontology" ; + rdfs:comment "Comment" . + <> a dh:Item ; + foaf:primaryTopic _:ontology ; + dct:title "My ontology" . + """), + CreateOntology.buildModel(doc, null, "My ontology", "Comment")); + } + + @Test + public void createGroup() + { + URI doc = URI.create("https://admin.localhost:4443/acl/groups/editors/"); + + assertIsomorphic(parse(""" + @base . + _:group a foaf:Group ; + foaf:name "Editors" ; + foaf:member , . + <> a dh:Item ; + foaf:primaryTopic _:group ; + dct:title "Editors" . + """), + CreateGroup.buildModel(doc, null, "Editors", null, + List.of(URI.create("https://localhost:4443/acl/agents/a/#this"), URI.create("https://localhost:4443/acl/agents/b/#this")))); + } + + @Test + public void createAuthorization() + { + URI doc = URI.create("https://admin.localhost:4443/acl/authorizations/auth/"); + + assertIsomorphic(parse(""" + @base . + _:auth a acl:Authorization ; + rdfs:label "Auth" ; + acl:agent ; + acl:accessTo ; + acl:mode acl:Read, acl:Write . + <> a dh:Item ; + foaf:primaryTopic _:auth ; + dct:title "Auth" . + """), + CreateAuthorization.buildModel(doc, null, "Auth", null, + List.of(URI.create("https://localhost:4443/acl/agents/a/#this")), List.of(), List.of(), + List.of(URI.create("https://localhost:4443/some/")), List.of(), + List.of(ACL.Read, ACL.Write))); + } + + @Test + public void addCSVImport() + { + assertIsomorphic(parse(""" + _:import a ldh:CSVImport ; + dct:title "Cities" ; + spin:query ; + ldh:file ; + ldh:delimiter "," . + """), + AddCSVImport.buildModel(TARGET, null, "Cities", URI.create("https://localhost:4443/some/#query"), + URI.create("https://localhost:4443/uploads/abc"), ",", null)); + } + + @Test + public void addRDFImportWithGraph() + { + assertIsomorphic(parse(""" + <#import> a ldh:RDFImport ; + dct:title "Data" ; + ldh:file ; + sd:name . + """), + AddRDFImport.buildModel(TARGET, "#import", "Data", URI.create("https://localhost:4443/uploads/abc"), + null, URI.create("https://localhost:4443/graphs/g/"), null)); + } + + @Test + public void addXHTMLBlock() + { + assertIsomorphic(parse(""" + <> rdf:_4 _:block . + _:block a ldh:XHTML ; + rdf:value "

Hello

"^^rdf:XMLLiteral . + """), + AddXHTMLBlock.buildModel(TARGET, RDF.li(4), null, "

Hello

", null, null)); + } + + @Test + public void addGenericServiceWithGraphStoreAndAuth() + { + assertIsomorphic(parse(""" + <#service> a sd:Service ; + dct:title "Remote" ; + sd:endpoint ; + sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; + a:graphStore ; + a:authUser "user" ; + a:authPwd "pwd" . + """), + AddGenericService.buildModel(TARGET, "#service", "Remote", URI.create("https://remote.example/sparql"), + URI.create("https://remote.example/service"), "user", "pwd", null)); + } + + @Test + public void addGenericServiceMinimal() + { + assertIsomorphic(parse(""" + <#service> a sd:Service ; + dct:title "Remote" ; + sd:endpoint ; + sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update . + """), + AddGenericService.buildModel(TARGET, "#service", "Remote", URI.create("https://remote.example/sparql"), + null, null, null, null)); + } + + @Test + public void addResultSetChart() + { + assertIsomorphic(parse(""" + <#chart> a ldh:ResultSetChart ; + dct:title "Chart" ; + dct:description "Desc" ; + spin:query ; + ldh:chartType ; + ldh:categoryVarName "category" ; + ldh:seriesVarName "series" . + """), + AddResultSetChart.buildModel(TARGET, "#chart", "Chart", URI.create("https://localhost:4443/queries/select/#this"), + URI.create("https://w3id.org/atomgraph/client#BarChart"), "category", "series", "Desc")); + } + + @Test + public void addClassWithSuperClasses() + { + assertIsomorphic(parse(""" + <#Concept> a owl:Class ; + rdfs:label "Concept" ; + rdfs:comment "A concept" ; + spin:constructor ; + spin:constraint ; + rdfs:subClassOf , . + """), + AddClass.buildModel(TARGET, "#Concept", "Concept", "A concept", + URI.create("https://localhost:4443/queries/construct/#this"), + URI.create("https://localhost:4443/constraints/#this"), + List.of(URI.create("https://localhost:4443/ns#Thing"), URI.create("https://localhost:4443/ns#Other")))); + } + + @Test + public void addClassMinimal() + { + assertIsomorphic(parse(""" + <#Concept> a owl:Class ; + rdfs:label "Concept" . + """), + AddClass.buildModel(TARGET, "#Concept", "Concept", null, null, null, List.of())); + } + + @Test + public void addConstructor() + { + assertIsomorphic(parse(""" + <#constructor> a sp:Construct ; + rdfs:label "Constructor" ; + rdfs:comment "Builds a Concept" ; + sp:text "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }" ; + ldh:service . + """), + AddConstructor.buildModel(TARGET, "#constructor", SP.Construct, "Constructor", + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + URI.create("https://localhost:4443/services/remote/#this"), "Builds a Concept")); + } + + @Test + public void addPropertyConstraint() + { + assertIsomorphic(parse(""" + <#constraint> a ldh:MissingPropertyValue ; + rdfs:label "Title required" ; + sp:arg1 dct:title . + """), + AddPropertyConstraint.buildModel(TARGET, "#constraint", "Title required", + URI.create("http://purl.org/dc/terms/title"), null)); + } + + @Test + public void addRestriction() + { + assertIsomorphic(parse(""" + <#restriction> a owl:Restriction ; + rdfs:label "Has title" ; + rdfs:comment "Every instance carries a title" ; + owl:onProperty dct:title ; + owl:allValuesFrom rdfs:Literal ; + owl:hasValue . + """), + AddRestriction.buildModel(TARGET, "#restriction", "Has title", "Every instance carries a title", + URI.create("http://purl.org/dc/terms/title"), + URI.create("http://www.w3.org/2000/01/rdf-schema#Literal"), + URI.create("https://localhost:4443/values/default/"))); + } + + @Test + public void addObjectBlock() + { + assertIsomorphic(parse(""" + <> rdf:_2 _:block . + _:block a ldh:Object ; + rdf:value ; + dct:title "Block" ; + dct:description "Desc" ; + ac:mode . + """), + AddObjectBlock.buildModel(TARGET, RDF.li(2), null, URI.create("https://localhost:4443/other/"), + "Block", "Desc", URI.create("https://w3id.org/atomgraph/client#ReadMode"))); + } + + @Test + public void importOntologyScratch() + { + URI scratch = URI.create("https://admin.localhost:4443/9a1e4b7c-0d2f-4a63-8b51-6c7d8e9f0a1b/"); + + assertIsomorphic(parse(""" + a dh:Item ; + dct:title "Import ontology scratch" . + """), + ImportOntology.buildScratchModel(scratch)); + } + + @Test + public void importOntologyAnnotation() + { + assertIsomorphic(parse(""" + <> a owl:Ontology ; + owl:imports . + """), + ImportOntology.buildAnnotationModel(TARGET, URI.create("http://www.w3.org/2004/02/skos/core#"))); + } + +} diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/ClientFactoryTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/ClientFactoryTest.java new file mode 100644 index 0000000000..9fd10ba3d6 --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/ClientFactoryTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.http; + +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link ClientFactory}. + */ +public class ClientFactoryTest +{ + + static Path keyStorePath() throws Exception + { + return Paths.get(ClientFactoryTest.class.getResource("/test-keystore.p12").toURI()); + } + + @Test + public void createsClientFromPKCS12Keystore() throws Exception + { + assertNotNull(ClientFactory.createClient(keyStorePath(), "changeit")); + } + + @Test + public void failsCleanlyOnWrongPassword() throws Exception + { + Path keyStore = keyStorePath(); + + assertThrows(IllegalArgumentException.class, () -> ClientFactory.createClient(keyStore, "wrong")); + } + + @Test + public void failsCleanlyOnMissingFile() + { + assertThrows(IllegalArgumentException.class, () -> ClientFactory.createClient(Path.of("/nonexistent.p12"), "changeit")); + } + +} diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/HttpExceptionTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/HttpExceptionTest.java new file mode 100644 index 0000000000..73b62e5bb0 --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/HttpExceptionTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.http; + +import com.atomgraph.core.MediaTypes; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.net.URI; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the curl -f equivalent: error statuses become exceptions, success statuses + * pass through. Driven against {@link StubServer} so the responses are real inbound ones - an + * outbound {@code Response} built in-process cannot be read back. + */ +public class HttpExceptionTest +{ + + private static final MediaType[] ACCEPT_TURTLE = { com.atomgraph.core.MediaType.TEXT_TURTLE_TYPE }; + + static LDHClient client() throws Exception + { + return new LDHClient(ClientFactory.createClient(ClientFactoryTest.keyStorePath(), "changeit"), new MediaTypes(), null); + } + + @Test + public void passesThroughSuccessStatus() throws Exception + { + try (StubServer server = new StubServer()) + { + server.responds(200, " a ."); + URI target = server.baseURI().resolve("some/"); + + try (Response response = HttpException.check(target, client().get(target, ACCEPT_TURTLE))) + { + assertEquals(200, response.getStatus()); + } + } + } + + @Test + public void passesThroughNoContent() throws Exception + { + try (StubServer server = new StubServer()) + { + server.responds(204, ""); + URI target = server.baseURI().resolve("some/"); + + try (Response response = HttpException.check(target, client().get(target, ACCEPT_TURTLE))) + { + assertEquals(204, response.getStatus()); + } + } + } + + @Test + public void throwsOnErrorStatusCarryingStatusAndURI() throws Exception + { + try (StubServer server = new StubServer()) + { + server.responds(403, ""); + URI target = server.baseURI().resolve("some/"); + + HttpException e = assertThrows(HttpException.class, () -> HttpException.check(target, client().get(target, ACCEPT_TURTLE))); + + assertEquals(403, e.getStatus()); + assertEquals(target, e.getUri()); + assertTrue(e.getMessage().startsWith("HTTP 403 Forbidden"), e.getMessage()); + assertTrue(e.getMessage().contains(target.toString()), e.getMessage()); + } + } + + @Test + public void errorMessageCarriesBodyExcerpt() throws Exception + { + try (StubServer server = new StubServer()) + { + server.responds(422, "Constraint violation on dct:title"); + URI target = server.baseURI().resolve("some/"); + + HttpException e = assertThrows(HttpException.class, () -> HttpException.check(target, client().get(target, ACCEPT_TURTLE))); + + assertEquals(422, e.getStatus()); + assertTrue(e.getMessage().endsWith("\nConstraint violation on dct:title"), e.getMessage()); + } + } + + @Test + public void longErrorBodyIsTruncated() throws Exception + { + try (StubServer server = new StubServer()) + { + server.responds(500, "x".repeat(2000)); + URI target = server.baseURI().resolve("some/"); + + HttpException e = assertThrows(HttpException.class, () -> HttpException.check(target, client().get(target, ACCEPT_TURTLE))); + + assertTrue(e.getMessage().endsWith("…"), "excerpt is not marked as truncated"); + assertTrue(e.getMessage().contains("x".repeat(1024)), "excerpt is shorter than the limit"); + assertFalse(e.getMessage().contains("x".repeat(1025)), "excerpt exceeds the limit"); + } + } + +} diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/StubServer.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/StubServer.java new file mode 100644 index 0000000000..0adfe63540 --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/http/StubServer.java @@ -0,0 +1,140 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.http; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +/** + * In-process HTTP server that answers every request with a canned status and body, and records + * the last request it saw. Lets the tests drive the real client stack - Jersey, the Apache + * connector, the PKCS12 client certificate - without a LinkedDataHub instance. + */ +public class StubServer implements AutoCloseable +{ + + private final HttpServer server; + + private volatile int status = 200; + private volatile String body = ""; + private volatile String contentType = "text/turtle"; + + private volatile String lastMethod; + private volatile String lastTarget; + private volatile String lastBody; + + /** + * Starts the server on an ephemeral loopback port. + * + * @throws IOException if the socket cannot be bound + */ + public StubServer() throws IOException + { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + + server.createContext("/", exchange -> + { + lastMethod = exchange.getRequestMethod(); + lastTarget = exchange.getRequestURI().toString(); + lastBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + + byte[] out = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", contentType); + exchange.sendResponseHeaders(status, out.length == 0 ? -1 : out.length); + if (out.length > 0) exchange.getResponseBody().write(out); + exchange.close(); + }); + + server.start(); + } + + /** + * Returns the base URI the server is listening on. + * + * @return base URI with a trailing slash + */ + public URI baseURI() + { + return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"); + } + + /** + * Sets the canned response. + * + * @param status HTTP status code + * @param body response body + * @return this server + */ + public StubServer responds(int status, String body) + { + this.status = status; + this.body = body; + return this; + } + + /** + * Sets the canned response content type. + * + * @param contentType response media type + * @return this server + */ + public StubServer respondsWithType(String contentType) + { + this.contentType = contentType; + return this; + } + + /** + * Returns the method of the last request. + * + * @return HTTP method, or null if no request was made + */ + public String getLastMethod() + { + return lastMethod; + } + + /** + * Returns the request target (path and query) of the last request. + * + * @return request target, or null if no request was made + */ + public String getLastTarget() + { + return lastTarget; + } + + /** + * Returns the body of the last request. + * + * @return request body, or null if no request was made + */ + public String getLastBody() + { + return lastBody; + } + + @Override + public void close() + { + server.stop(0); + } + +} diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/sparql/UpdatesTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/sparql/UpdatesTest.java new file mode 100644 index 0000000000..f7608273a5 --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/sparql/UpdatesTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.sparql; + +import java.net.URI; +import org.apache.jena.query.Syntax; +import org.apache.jena.update.UpdateFactory; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for {@link Updates}: every template must be valid standard SPARQL 1.1. + */ +public class UpdatesTest +{ + + private static final URI DOC = URI.create("https://localhost:4443/some/"); + private static final URI BASE = URI.create("https://localhost:4443/"); + private static final URI ADMIN_BASE = URI.create("https://admin.localhost:4443/"); + + @Test + public void insertOntologyImportIsValidSPARQL11() + { + String update = Updates.insertOntologyImport(DOC, URI.create("https://example.org/ontology#")); + + assertDoesNotThrow(() -> UpdateFactory.create(update, Syntax.syntaxSPARQL_11)); + assertTrue(update.contains("")); + } + + @Test + public void insertGroupMemberIsValidSPARQL11() + { + String update = Updates.insertGroupMember(DOC, URI.create("https://localhost:4443/agents/x/#this")); + + assertDoesNotThrow(() -> UpdateFactory.create(update, Syntax.syntaxSPARQL_11)); + assertTrue(update.contains("")); + } + + @Test + public void removeBlockWithoutBlockKeepsVariable() + { + String update = Updates.removeBlock(DOC, null); + + assertDoesNotThrow(() -> UpdateFactory.create(update, Syntax.syntaxSPARQL_11)); + assertTrue(update.contains("?block")); + } + + @Test + public void removeBlockWithBlockInjectsIRI() + { + String update = Updates.removeBlock(DOC, URI.create("https://localhost:4443/some/#block")); + + assertDoesNotThrow(() -> UpdateFactory.create(update, Syntax.syntaxSPARQL_11)); + assertTrue(update.contains("")); + assertFalse(update.contains("?block")); + } + + @Test + public void makePublicIsValidSPARQL11() + { + String update = Updates.makePublic(BASE, ADMIN_BASE); + + assertDoesNotThrow(() -> UpdateFactory.create(update, Syntax.syntaxSPARQL_11)); + assertTrue(update.contains("")); + assertTrue(update.contains("")); + assertTrue(update.contains("")); + } + +} diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbersTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbersTest.java new file mode 100644 index 0000000000..275030c711 --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/SequenceNumbersTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.util; + +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.vocabulary.RDF; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for {@link SequenceNumbers}. + */ +public class SequenceNumbersTest +{ + + private static final String DOC = "https://localhost:4443/some/"; + + @Test + public void returnsFirstMembershipPropertyOnEmptyModel() + { + Model model = ModelFactory.createDefaultModel(); + + assertEquals(RDF.li(1), SequenceNumbers.nextSequenceProperty(model, model.createResource(DOC))); + } + + @Test + public void returnsMaxPlusOneWithGaps() + { + Model model = ModelFactory.createDefaultModel(); + Resource doc = model.createResource(DOC); + doc.addProperty(RDF.li(1), model.createResource()); + doc.addProperty(RDF.li(2), model.createResource()); + doc.addProperty(RDF.li(7), model.createResource()); + + assertEquals(RDF.li(8), SequenceNumbers.nextSequenceProperty(model, doc)); + } + + @Test + public void ignoresOtherSubjects() + { + Model model = ModelFactory.createDefaultModel(); + model.createResource("https://localhost:4443/other/").addProperty(RDF.li(5), model.createResource()); + + assertEquals(RDF.li(1), SequenceNumbers.nextSequenceProperty(model, model.createResource(DOC))); + } + + @Test + public void ignoresNonNumericSuffixes() + { + Model model = ModelFactory.createDefaultModel(); + Resource doc = model.createResource(DOC); + doc.addProperty(model.createProperty(RDF.getURI() + "_x"), model.createResource()); + doc.addProperty(RDF.li(3), model.createResource()); + + assertEquals(RDF.li(4), SequenceNumbers.nextSequenceProperty(model, doc)); + } + +} diff --git a/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/URIRewriterTest.java b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/URIRewriterTest.java new file mode 100644 index 0000000000..222a90863d --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/util/URIRewriterTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.atomgraph.linkeddatahub.cli.util; + +import java.net.URI; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link URIRewriter}. + */ +public class URIRewriterTest +{ + + @Test + public void rewriteReplacesOriginKeepingPathQueryFragment() + { + assertEquals(URI.create("https://localhost:8443/a%20b/c/?d=e#f"), + URIRewriter.rewrite(URI.create("https://linkeddatahub.com/a%20b/c/?d=e#f"), URI.create("https://localhost:8443"))); + } + + @Test + public void rewriteIgnoresProxyPath() + { + assertEquals(URI.create("https://localhost:8443/some/"), + URIRewriter.rewrite(URI.create("https://linkeddatahub.com:4443/some/"), URI.create("https://localhost:8443/ignored/"))); + } + + @Test + public void rewriteRejectsRelativeURI() + { + assertThrows(IllegalArgumentException.class, + () -> URIRewriter.rewrite(URI.create("/relative/path"), URI.create("https://localhost:8443"))); + } + + @Test + public void adminBasePrefixesHostWithAdminSubdomain() + { + assertEquals(URI.create("https://admin.localhost:4443/"), URIRewriter.adminBase(URI.create("https://localhost:4443/"))); + } + + @Test + public void encodeSlugKeepsUnreservedCharacters() + { + assertEquals("abc-._~123", URIRewriter.encodeSlug("abc-._~123")); + } + + @Test + public void encodeSlugEncodesReservedAndNonASCII() + { + assertEquals("a%20b%2F%C4%87", URIRewriter.encodeSlug("a b/ć")); + } + + @Test + public void childURIAppendsEncodedSlugAndSlash() + { + assertEquals(URI.create("https://localhost:4443/some/my%20item/"), + URIRewriter.childURI(URI.create("https://localhost:4443/some/"), "my item")); + } + +} diff --git a/cli/src/test/resources/test-keystore.p12 b/cli/src/test/resources/test-keystore.p12 new file mode 100644 index 0000000000..f50715e253 Binary files /dev/null and b/cli/src/test/resources/test-keystore.p12 differ diff --git a/config/dataspaces.trig b/config/dataspaces.trig index c710fc65db..12c44d8a61 100644 --- a/config/dataspaces.trig +++ b/config/dataspaces.trig @@ -14,7 +14,7 @@ dct:title "LinkedDataHub admin" ; lapp:origin ; ldt:ontology ; - ac:stylesheet . + ac:stylesheet . } @@ -26,7 +26,7 @@ dct:title "LinkedDataHub" ; lapp:origin ; ldt:ontology ; - ac:stylesheet ; + ac:stylesheet ; lapp:public true . } @@ -39,7 +39,7 @@ dct:title "Northwind Traders admin" ; lapp:origin ; ldt:ontology ; - ac:stylesheet . + ac:stylesheet . } @@ -51,7 +51,7 @@ dct:title "Northwind Traders" ; lapp:origin ; ldt:ontology ; - ac:stylesheet ; + ac:stylesheet ; lapp:public true . } diff --git a/http-tests/access/group-authorization.sh b/http-tests/access/group-authorization.sh index 76950bfedf..92f4a8fc92 100755 --- a/http-tests/access/group-authorization.sh +++ b/http-tests/access/group-authorization.sh @@ -25,16 +25,16 @@ fi # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" # create container -create-container.sh \ - -f "$AGENT_CERT_FILE" \ +ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ diff --git a/http-tests/access/owner-authorization.sh b/http-tests/access/owner-authorization.sh index b62766606d..b59f30adaa 100755 --- a/http-tests/access/owner-authorization.sh +++ b/http-tests/access/owner-authorization.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug="test" -container=$(create-container.sh \ - -f "$AGENT_CERT_FILE" \ +container=$(ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ diff --git a/http-tests/add/GET-proxied-source-POST-append.sh b/http-tests/add/GET-proxied-source-POST-append.sh index 5db813ac2c..ebf7333fbb 100755 --- a/http-tests/add/GET-proxied-source-POST-append.sh +++ b/http-tests/add/GET-proxied-source-POST-append.sh @@ -13,22 +13,22 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group (to read through the proxy) and the writers group (to append) -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" # create the target container -container=$(create-container.sh \ - -f "$AGENT_CERT_FILE" \ +container=$(ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ diff --git a/http-tests/add/PUT-generate-container.sh b/http-tests/add/PUT-generate-container.sh index 9500e29a1d..52b661e887 100755 --- a/http-tests/add/PUT-generate-container.sh +++ b/http-tests/add/PUT-generate-container.sh @@ -16,8 +16,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/admin/acl/add-delete-authorization.sh b/http-tests/admin/acl/add-delete-authorization.sh index 0692735f75..b040aea527 100755 --- a/http-tests/admin/acl/add-delete-authorization.sh +++ b/http-tests/admin/acl/add-delete-authorization.sh @@ -20,8 +20,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ slug="test" -container=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -30,8 +30,8 @@ container=$(create-container.sh \ # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake DELETE authorization from test.localhost" \ @@ -50,8 +50,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "DELETE authorization" \ diff --git a/http-tests/admin/acl/add-delete-class-authorization.sh b/http-tests/admin/acl/add-delete-class-authorization.sh index b763c5c5bb..1a8cbdab89 100755 --- a/http-tests/admin/acl/add-delete-class-authorization.sh +++ b/http-tests/admin/acl/add-delete-class-authorization.sh @@ -20,8 +20,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ slug="test" -container=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -30,8 +30,8 @@ container=$(create-container.sh \ # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake DELETE class authorization from test.localhost" \ @@ -50,8 +50,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "DELETE authorization" \ diff --git a/http-tests/admin/acl/add-delete-group-authorization.sh b/http-tests/admin/acl/add-delete-group-authorization.sh index c6fe39bffd..781183ba25 100755 --- a/http-tests/admin/acl/add-delete-group-authorization.sh +++ b/http-tests/admin/acl/add-delete-group-authorization.sh @@ -18,8 +18,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ # create group -group_doc=$(create-group.sh \ - -f "$OWNER_CERT_FILE" \ +group_doc=$(ldh admin acl create-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --name "Test group" \ @@ -36,8 +36,8 @@ group=$(curl -s -k \ slug="test" -container=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -46,8 +46,8 @@ container=$(create-container.sh \ # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake DELETE group authorization from test.localhost" \ @@ -66,8 +66,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "DELETE authorization" \ diff --git a/http-tests/admin/acl/add-get-authorization.sh b/http-tests/admin/acl/add-get-authorization.sh index 5f9b0c701c..77ecf4ee11 100755 --- a/http-tests/admin/acl/add-get-authorization.sh +++ b/http-tests/admin/acl/add-get-authorization.sh @@ -17,8 +17,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake GET authorization from test.localhost" \ @@ -36,8 +36,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "GET authorization" \ diff --git a/http-tests/admin/acl/add-get-class-authorization.sh b/http-tests/admin/acl/add-get-class-authorization.sh index 2d975c7395..99ca051f98 100755 --- a/http-tests/admin/acl/add-get-class-authorization.sh +++ b/http-tests/admin/acl/add-get-class-authorization.sh @@ -17,8 +17,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake GET Container authorization from test.localhost" \ @@ -36,8 +36,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "GET Container authorization" \ diff --git a/http-tests/admin/acl/add-get-group-authorization.sh b/http-tests/admin/acl/add-get-group-authorization.sh index 6c890a6eae..357b2b20d3 100755 --- a/http-tests/admin/acl/add-get-group-authorization.sh +++ b/http-tests/admin/acl/add-get-group-authorization.sh @@ -17,8 +17,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ # create group -group_doc=$(create-group.sh \ - -f "$OWNER_CERT_FILE" \ +group_doc=$(ldh admin acl create-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --name "Test group" \ @@ -33,8 +33,8 @@ group=$(curl -s -k \ # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake GET group authorization from test.localhost" \ @@ -52,8 +52,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -s \ # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "GET authorization" \ diff --git a/http-tests/admin/acl/add-post-authorization.sh b/http-tests/admin/acl/add-post-authorization.sh index c07bcf8642..d443706cd9 100755 --- a/http-tests/admin/acl/add-post-authorization.sh +++ b/http-tests/admin/acl/add-post-authorization.sh @@ -24,8 +24,8 @@ EOF # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake POST authorization from test.localhost" \ @@ -50,8 +50,8 @@ EOF # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "POST authorization" \ diff --git a/http-tests/admin/acl/add-post-class-authorization.sh b/http-tests/admin/acl/add-post-class-authorization.sh index f09d3102c4..fc0627e4e3 100755 --- a/http-tests/admin/acl/add-post-class-authorization.sh +++ b/http-tests/admin/acl/add-post-class-authorization.sh @@ -24,8 +24,8 @@ EOF # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake POST class authorization from test.localhost" \ @@ -50,8 +50,8 @@ EOF # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "POST authorization" \ diff --git a/http-tests/admin/acl/add-post-group-authorization.sh b/http-tests/admin/acl/add-post-group-authorization.sh index a6d048f7ce..cb532f634e 100755 --- a/http-tests/admin/acl/add-post-group-authorization.sh +++ b/http-tests/admin/acl/add-post-group-authorization.sh @@ -24,8 +24,8 @@ EOF # create group -group_doc=$(create-group.sh \ - -f "$OWNER_CERT_FILE" \ +group_doc=$(ldh admin acl create-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --name "Test group" \ @@ -40,8 +40,8 @@ group=$(curl -s -k \ # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake POST group authorization from test.localhost" \ @@ -66,8 +66,8 @@ EOF # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "POST authorization" \ diff --git a/http-tests/admin/acl/add-put-authorization.sh b/http-tests/admin/acl/add-put-authorization.sh index f35bbc4b4c..896c626b78 100755 --- a/http-tests/admin/acl/add-put-authorization.sh +++ b/http-tests/admin/acl/add-put-authorization.sh @@ -24,8 +24,8 @@ EOF # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake PUT authorization from test.localhost" \ @@ -50,8 +50,8 @@ EOF # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "PUT authorization" \ @@ -61,8 +61,8 @@ create-authorization.sh \ # get the graph content -root_ntriples=$(get.sh \ - -f "$OWNER_CERT_FILE" \ +root_ntriples=$(ldh get \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --accept 'application/n-triples' \ "$END_USER_BASE_URL") diff --git a/http-tests/admin/acl/add-put-class-authorization.sh b/http-tests/admin/acl/add-put-class-authorization.sh index a23c4cb75b..3938f74135 100755 --- a/http-tests/admin/acl/add-put-class-authorization.sh +++ b/http-tests/admin/acl/add-put-class-authorization.sh @@ -24,8 +24,8 @@ EOF # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake PUT class authorization from test.localhost" \ @@ -50,8 +50,8 @@ EOF # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "PUT authorization" \ @@ -61,8 +61,8 @@ create-authorization.sh \ # get the graph content -root_ntriples=$(get.sh \ - -f "$OWNER_CERT_FILE" \ +root_ntriples=$(ldh get \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --accept 'application/n-triples' \ "$END_USER_BASE_URL") diff --git a/http-tests/admin/acl/add-put-group-authorization.sh b/http-tests/admin/acl/add-put-group-authorization.sh index 1d5ccf9d39..e594d247c6 100755 --- a/http-tests/admin/acl/add-put-group-authorization.sh +++ b/http-tests/admin/acl/add-put-group-authorization.sh @@ -24,8 +24,8 @@ EOF # create group -group_doc=$(create-group.sh \ - -f "$OWNER_CERT_FILE" \ +group_doc=$(ldh admin acl create-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --name "Test group" \ @@ -40,8 +40,8 @@ group=$(curl -s -k \ # create fake test.localhost authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake PUT group authorization from test.localhost" \ @@ -66,8 +66,8 @@ EOF # create real localhost authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "DELETE authorization" \ @@ -77,8 +77,8 @@ create-authorization.sh \ # get the graph content -root_ntriples=$(get.sh \ - -f "$OWNER_CERT_FILE" \ +root_ntriples=$(ldh get \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --accept 'application/n-triples' \ "$END_USER_BASE_URL") diff --git a/http-tests/admin/acl/agent-acl-modes.sh b/http-tests/admin/acl/agent-acl-modes.sh index a0117fc302..dfccf4f9ae 100644 --- a/http-tests/admin/acl/agent-acl-modes.sh +++ b/http-tests/admin/acl/agent-acl-modes.sh @@ -9,17 +9,17 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" # create a new document to test ACL modes against -doc_url=$(create-item.sh \ +doc_url=$(ldh create-item \ -b "$END_USER_BASE_URL" \ - -f "$AGENT_CERT_FILE" \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --container "$END_USER_BASE_URL" \ --title "ACL Test Document Agent" \ diff --git a/http-tests/admin/acl/make-public.sh b/http-tests/admin/acl/make-public.sh index a3900b1079..1a148817c8 100755 --- a/http-tests/admin/acl/make-public.sh +++ b/http-tests/admin/acl/make-public.sh @@ -16,8 +16,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -v \ # create fake test.localhost public authorization (should be filtered out) -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "https://admin.test.localhost:4443/" \ --label "Fake public access from test.localhost" \ @@ -34,8 +34,8 @@ curl -k -w "%{http_code}\n" -o /dev/null -v \ # create real localhost public authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "Public access authorization" \ diff --git a/http-tests/admin/acl/owner-acl-modes.sh b/http-tests/admin/acl/owner-acl-modes.sh index 9f52a23389..fb0b938d0d 100644 --- a/http-tests/admin/acl/owner-acl-modes.sh +++ b/http-tests/admin/acl/owner-acl-modes.sh @@ -9,9 +9,9 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # create a new document to test ACL modes against -doc_url=$(create-item.sh \ +doc_url=$(ldh create-item \ -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --container "$END_USER_BASE_URL" \ --title "ACL Test Document" \ diff --git a/http-tests/admin/model/add-class.sh b/http-tests/admin/model/add-class.sh index b39e722261..ab860c4104 100755 --- a/http-tests/admin/model/add-class.sh +++ b/http-tests/admin/model/add-class.sh @@ -14,8 +14,8 @@ namespace="${namespace_doc}#" ontology_doc="${ADMIN_BASE_URL}ontologies/namespace/" class="${namespace_doc}#NewClass" -add-class.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin ontologies add-class \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --uri "$class" \ @@ -25,8 +25,8 @@ add-class.sh \ # clear ontology from memory -clear-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin clear-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" diff --git a/http-tests/admin/model/add-ontology-import.sh b/http-tests/admin/model/add-ontology-import.sh index 097b53b8ca..43c954cd13 100755 --- a/http-tests/admin/model/add-ontology-import.sh +++ b/http-tests/admin/model/add-ontology-import.sh @@ -14,16 +14,16 @@ import_uri="http://www.w3.org/ns/auth/acl" # add ontology import -add-ontology-import.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin add-ontology-import \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --import "$import_uri" \ "$ontology_doc" # clear the namespace ontology from memory -clear-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin clear-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" diff --git a/http-tests/admin/model/add-property-constraint.sh b/http-tests/admin/model/add-property-constraint.sh index c5e179841f..d91e6cd8ae 100755 --- a/http-tests/admin/model/add-property-constraint.sh +++ b/http-tests/admin/model/add-property-constraint.sh @@ -14,8 +14,8 @@ namespace="${namespace_doc}#" ontology_doc="${ADMIN_BASE_URL}ontologies/namespace/" constraint="${namespace_doc}#NewConstraint" -add-property-constraint.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin ontologies add-property-constraint \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --uri "$constraint" \ @@ -25,8 +25,8 @@ add-property-constraint.sh \ # create a class with the constraint -add-class.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin ontologies add-class \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --uri "${namespace_doc}#ConstrainedClass" \ @@ -37,8 +37,8 @@ add-class.sh \ # clear ontology from memory -clear-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin clear-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" diff --git a/http-tests/admin/model/add-restriction.sh b/http-tests/admin/model/add-restriction.sh index ccf39f91a4..8f1b7ab012 100755 --- a/http-tests/admin/model/add-restriction.sh +++ b/http-tests/admin/model/add-restriction.sh @@ -14,8 +14,8 @@ namespace="${namespace_doc}#" ontology_doc="${ADMIN_BASE_URL}ontologies/namespace/" restriction="${namespace_doc}#Restriction" -add-restriction.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin ontologies add-restriction \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --uri "$restriction" \ @@ -26,8 +26,8 @@ add-restriction.sh \ # clear ontology from memory -clear-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin clear-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" diff --git a/http-tests/admin/model/import-ontology.sh b/http-tests/admin/model/import-ontology.sh index de71779111..6b39b0173f 100755 --- a/http-tests/admin/model/import-ontology.sh +++ b/http-tests/admin/model/import-ontology.sh @@ -15,33 +15,39 @@ import_uri="http://www.w3.org/2004/02/skos/core" slug="test" -item=$(create-item.sh \ - -f "$OWNER_CERT_FILE" \ +item=$(ldh create-item \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --title "Test" \ --slug "$slug" \ --container "${ADMIN_BASE_URL}ontologies/") -# import the ontology into the item document and derive class constructors from it +# import the ontology: derive class constructors into the item document; the vocabulary itself only +# passes through a scratch document and is not persisted -import-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin ontologies import-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --source "$import_uri" \ --graph "$item" -# check that the item graph holds the raw ontology, using a query scoped to it via the SPARQL Protocol dataset specification +# check that the item graph does NOT hold the raw vocabulary, using a query scoped to it via the +# SPARQL Protocol dataset specification -curl -k -f -s \ +result=$(curl -k -f -s \ -G \ -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ -H 'Accept: application/sparql-results+xml' \ --data-urlencode "query=SELECT * { <${import_uri}> ?p ?o }" \ --data-urlencode "default-graph-uri=${item}" \ - "${ADMIN_BASE_URL}sparql" \ -| grep 'SKOS Vocabulary' > /dev/null + "${ADMIN_BASE_URL}sparql") +count=$(echo "$result" | xmllint --xpath "count(//*[local-name() = 'result'])" -) +if [ "$count" != "0" ]; then + echo "DEBUG: Expected 0 raw vocabulary triples in the item graph, got: $count" + exit 1 +fi # check that constructors were derived into the item graph @@ -54,26 +60,36 @@ curl -k -f -s \ "${ADMIN_BASE_URL}sparql" \ | grep '' > /dev/null -# add ontology import +# check that the item carries the annotation-ontology header importing the source vocabulary + +curl -k -f -s \ + -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H 'Accept: application/sparql-results+xml' \ + --data-urlencode "query=SELECT * { <${item}> a ; <${import_uri}> }" \ + --data-urlencode "default-graph-uri=${item}" \ + "${ADMIN_BASE_URL}sparql" \ +| grep '' > /dev/null + +# make the annotation document part of the application ontology (the vocabulary rides in via the +# document's own owl:imports) -add-ontology-import.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin add-ontology-import \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ - --import "$import_uri" \ + --import "$item" \ "$ontology_doc" # clear the namespace ontology from memory -clear-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin clear-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" -# check that the imported ontology is present in the ontology model TO-DO: replace with an ASK query when #118 is fixed -# (SKOS is a bundled vocabulary: OntologyRepository serves the shipped file authoritatively, so the closure carries -# its terms but not the constructors derived into the local document — those reach the closure only for -# ontologies that are not bundled. The constructor derivation itself is asserted on the document graph above.) +# check that the vocabulary is present in the ontology closure (resolved through the graph +# repository - SKOS is a bundled vocabulary - via the annotation document's owl:imports) curl -k -f -s \ -G \ @@ -82,3 +98,14 @@ curl -k -f -s \ --data-urlencode "query=SELECT * { <${import_uri}> ?p ?o }" \ "$namespace_doc" \ | grep 'SKOS Vocabulary' > /dev/null + +# check that the derived constructors reached the closure too - impossible under the old model for +# bundled vocabularies, where the shipped file shadowed the local copy that held the constructors + +curl -k -f -s \ + -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H 'Accept: application/sparql-results+xml' \ + --data-urlencode "query=SELECT * { ?constructor }" \ + "$namespace_doc" \ +| grep '' > /dev/null diff --git a/http-tests/admin/model/ontology-import-upload-no-deadlock.sh b/http-tests/admin/model/ontology-import-upload-no-deadlock.sh index 935facd7ec..fba3d6207e 100755 --- a/http-tests/admin/model/ontology-import-upload-no-deadlock.sh +++ b/http-tests/admin/model/ontology-import-upload-no-deadlock.sh @@ -19,8 +19,8 @@ pwd=$(realpath "$PWD") # add agent to the writers group so they can upload files -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -31,8 +31,8 @@ file_content_type="text/turtle" slug=$(uuidgen | tr '[:upper:]' '[:lower:]') # Create an item document to hold the file -file_doc=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +file_doc=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test ontology for upload import" \ @@ -40,8 +40,8 @@ file_doc=$(create-item.sh \ --slug "$slug") # Add the file to the document -add-file.sh \ - -f "$AGENT_CERT_FILE" \ +ldh add-file \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test ontology for upload import" \ @@ -67,16 +67,16 @@ namespace_doc="${END_USER_BASE_URL}ns" namespace="${namespace_doc}#" ontology_doc="${ADMIN_BASE_URL}ontologies/namespace/" -add-ontology-import.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin add-ontology-import \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --import "$upload_uri" \ "$ontology_doc" # Step 4: Clear the namespace ontology from memory to force reload on next request -clear-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin clear-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" diff --git a/http-tests/admin/packages/install-package-400.sh b/http-tests/admin/packages/install-package-400.sh deleted file mode 100755 index d77736d15f..0000000000 --- a/http-tests/admin/packages/install-package-400.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# Missing package-uri parameter should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_BAD_REQUEST" diff --git a/http-tests/admin/packages/install-package-403.sh b/http-tests/admin/packages/install-package-403.sh deleted file mode 100755 index 6cba485720..0000000000 --- a/http-tests/admin/packages/install-package-403.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# Unauthorized access (without certificate) should return 403 Forbidden -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/admin/packages/install-package-422.sh b/http-tests/admin/packages/install-package-422.sh deleted file mode 100755 index b5891998e7..0000000000 --- a/http-tests/admin/packages/install-package-422.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# Invalid/non-existent package URI should return 422 Unprocessable Entity -# (package loading failed) -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=${END_USER_BASE_URL}static/nonexistent/#package" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_UNPROCESSABLE_ENTITY" diff --git a/http-tests/admin/packages/install-package-document.sh b/http-tests/admin/packages/install-package-document.sh deleted file mode 100755 index f12073a66e..0000000000 --- a/http-tests/admin/packages/install-package-document.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# test package URI (SKOS package) -package_uri="https://packages.linkeddatahub.com/skos/#this" - -# install package -install-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" \ -| grep -q "$STATUS_SEE_OTHER" - -# verify package document was created (hash of package URI) -package_hash=$(echo -n "$package_uri" | shasum -a 1 | cut -d' ' -f1) -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - "${ADMIN_BASE_URL}packages/${package_hash}/" \ -| grep -qE "^($STATUS_OK|$STATUS_NOT_MODIFIED)$" - -# uninstall package -uninstall-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" \ -| grep -q "$STATUS_SEE_OTHER" - -# verify package document was deleted -#curl -k -w "%{http_code}\n" -o /dev/null -s \ -# -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ -# "${ADMIN_BASE_URL}packages/${package_hash}/" \ -#| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/admin/packages/install-package-internal-url-400.sh b/http-tests/admin/packages/install-package-internal-url-400.sh deleted file mode 100755 index a5ba23d36f..0000000000 --- a/http-tests/admin/packages/install-package-internal-url-400.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# Test SSRF protection: package-uri with link-local address (169.254.0.0/16) should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=http://169.254.1.1/package#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_BAD_REQUEST" - -# Test SSRF protection: package-uri with private class A address (10.0.0.0/8) should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=http://10.0.0.1/package#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_BAD_REQUEST" - -# Test SSRF protection: package-uri with private class B address (172.16.0.0/12) should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=http://172.16.0.0/package#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_BAD_REQUEST" - -# Test SSRF protection: package-uri with private class C address (192.168.0.0/16) should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=http://192.168.1.1/package#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_BAD_REQUEST" diff --git a/http-tests/admin/packages/install-package-stylesheet-no-duplicate.sh b/http-tests/admin/packages/install-package-stylesheet-no-duplicate.sh deleted file mode 100755 index 6eca6f823f..0000000000 --- a/http-tests/admin/packages/install-package-stylesheet-no-duplicate.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# Clean up any leftover package stylesheet files from previous test runs -docker compose exec -T linkeddatahub rm -rf /usr/local/tomcat/webapps/ROOT/static/com/linkeddatahub/packages/skos 2>/dev/null || true -docker compose exec -T linkeddatahub sed -i '/linkeddatahub\/packages\/skos\/layout.xsl/d' /usr/local/tomcat/webapps/ROOT/static/xsl/layout.xsl 2>/dev/null || true - -# Tomcat caches static files with default cacheTtl=5000ms (5 seconds) -# See: https://tomcat.apache.org/tomcat-10.1-doc/config/resources.html#Attributes -default_ttl=5 - -# test package URI (SKOS package) -package_uri="https://packages.linkeddatahub.com/skos/#this" - -# first install -install-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" - -# Wait for Tomcat's static resource cache to expire -sleep $default_ttl - -# verify exactly one import after first install -import_count=$(curl -k -s "${END_USER_BASE_URL}static/xsl/layout.xsl" \ - | grep -c "com/linkeddatahub/packages/skos/layout.xsl" || true) -if [ "$import_count" -ne 1 ]; then - exit 1 -fi - -# second install (same package) -install-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" - -# Wait for Tomcat's static resource cache to expire -sleep $default_ttl - -# verify still exactly one import after second install (deduplication guard) -import_count=$(curl -k -s "${END_USER_BASE_URL}static/xsl/layout.xsl" \ - | grep -c "com/linkeddatahub/packages/skos/layout.xsl" || true) -if [ "$import_count" -ne 1 ]; then - exit 1 -fi - -# cleanup -uninstall-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" diff --git a/http-tests/admin/packages/install-uninstall-package-ontology.sh b/http-tests/admin/packages/install-uninstall-package-ontology.sh deleted file mode 100755 index 7623b577c3..0000000000 --- a/http-tests/admin/packages/install-uninstall-package-ontology.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# test package URI (SKOS package) -package_uri="https://packages.linkeddatahub.com/skos/#this" -package_ontology_uri="https://raw.githubusercontent.com/AtomGraph/LinkedDataHub-Apps/refs/heads/master/packages/skos/ns.ttl#" -namespace_ontology_uri="${END_USER_BASE_URL}ns#" - -# verify owl:imports triple does NOT exist before install -if curl -k -s -H "Accept: application/n-triples" -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" "${ADMIN_BASE_URL}ontologies/namespace/" \ -| grep -q "<${namespace_ontology_uri}> <${package_ontology_uri}>"; then - exit 1 -fi - -# install package -install-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" \ -| grep -q "$STATUS_SEE_OTHER" - -# verify owl:imports triple was added (check graph store directly, not cached endpoint) -curl -k -s \ - -H "Accept: application/n-triples" \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - "${ADMIN_BASE_URL}ontologies/namespace/" \ -| grep -q "<${namespace_ontology_uri}> <${package_ontology_uri}>" - -# verify package ontology document exists -package_ontology_hash=$(echo -n "$package_ontology_uri" | shasum -a 1 | cut -d' ' -f1) -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - "${ADMIN_BASE_URL}ontologies/${package_ontology_hash}/" \ -| grep -qE "^($STATUS_OK|$STATUS_NOT_MODIFIED)$" - -# uninstall package -uninstall-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" \ -| grep -q "$STATUS_SEE_OTHER" - -# verify owl:imports triple was removed (check graph store directly, not cached endpoint) -ns_after=$(curl -k -s -H "Accept: application/n-triples" -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" "${ADMIN_BASE_URL}ontologies/namespace/") -if echo "$ns_after" | grep -q "<${namespace_ontology_uri}> <${package_ontology_uri}>"; then - exit 1 -fi - -# verify package ontology document was deleted -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - "${ADMIN_BASE_URL}ontologies/${package_ontology_hash}/" \ -| grep -q "$STATUS_NOT_FOUND" diff --git a/http-tests/admin/packages/install-uninstall-package-stylesheet.sh b/http-tests/admin/packages/install-uninstall-package-stylesheet.sh deleted file mode 100755 index 02452d7119..0000000000 --- a/http-tests/admin/packages/install-uninstall-package-stylesheet.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# Clean up any leftover package stylesheet files from previous test runs -docker compose exec -T linkeddatahub rm -rf /usr/local/tomcat/webapps/ROOT/static/com/linkeddatahub/packages/skos 2>/dev/null || true -docker compose exec -T linkeddatahub sed -i '/linkeddatahub\/packages\/skos\/layout.xsl/d' /usr/local/tomcat/webapps/ROOT/static/xsl/layout.xsl 2>/dev/null || true - -# Tomcat caches static files with default cacheTtl=5000ms (5 seconds) -# See: https://tomcat.apache.org/tomcat-10.1-doc/config/resources.html#Attributes -default_ttl=5 - -# test package URI (SKOS package) -package_uri="https://packages.linkeddatahub.com/skos/#this" - -# verify package stylesheet does NOT exist initially (should return 404) -curl -k -w "%{http_code}\n" -o /dev/null -s \ - "${END_USER_BASE_URL}static/com/linkeddatahub/packages/skos/layout.xsl" \ -| grep -q "$STATUS_NOT_FOUND" - -# verify master stylesheet does NOT include package initially -if curl -k -s "${END_USER_BASE_URL}static/xsl/layout.xsl" | grep -q "com/linkeddatahub/packages/skos/layout.xsl"; then - exit 1 -fi - -# install package -install-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" - -# Wait for Tomcat's static resource cache to expire -sleep $default_ttl - -# verify package stylesheet was installed (should return 200) -install_status=$(curl -k -w "%{http_code}\n" -o /dev/null -s \ - "${END_USER_BASE_URL}static/com/linkeddatahub/packages/skos/layout.xsl") -if [ "$install_status" != "200" ]; then - exit 1 -fi - -# verify master stylesheet includes package -if ! curl -k -s "${END_USER_BASE_URL}static/xsl/layout.xsl" | grep -q "com/linkeddatahub/packages/skos/layout.xsl"; then - exit 1 -fi - -# uninstall package -uninstall-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" - -# Wait for Tomcat's static resource cache to expire -sleep $default_ttl - -# verify package stylesheet was deleted (should return 404) -curl -k -w "%{http_code}\n" -o /dev/null -s \ - "${END_USER_BASE_URL}static/com/linkeddatahub/packages/skos/layout.xsl" \ -| grep -q "$STATUS_NOT_FOUND" - -# verify master stylesheet no longer includes package -if curl -k -s "${END_USER_BASE_URL}static/xsl/layout.xsl" | grep -q "com/linkeddatahub/packages/skos/layout.xsl"; then - exit 1 -fi diff --git a/http-tests/admin/packages/uninstall-package-400.sh b/http-tests/admin/packages/uninstall-package-400.sh deleted file mode 100755 index 50129dd8d6..0000000000 --- a/http-tests/admin/packages/uninstall-package-400.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# Missing package-uri parameter should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - "${ADMIN_BASE_URL}packages/uninstall" \ -| grep -q "$STATUS_BAD_REQUEST" diff --git a/http-tests/config/dataspaces.trig b/http-tests/config/dataspaces.trig index a1f212417e..bede0dd54d 100644 --- a/http-tests/config/dataspaces.trig +++ b/http-tests/config/dataspaces.trig @@ -19,7 +19,7 @@ dct:title "LinkedDataHub admin" ; lapp:origin ; ldt:ontology ; - ac:stylesheet . + ac:stylesheet . } # root end-user @@ -30,7 +30,7 @@ dct:title "LinkedDataHub" ; lapp:origin ; ldt:ontology ; - ac:stylesheet ; + ac:stylesheet ; lapp:public true . } @@ -42,7 +42,7 @@ dct:title "Test admin" ; lapp:origin ; ldt:ontology ; - ac:stylesheet . + ac:stylesheet . } # test end-user @@ -53,6 +53,6 @@ dct:title "Test" ; lapp:origin ; ldt:ontology ; - ac:stylesheet ; + ac:stylesheet ; lapp:public true . } diff --git a/http-tests/document-hierarchy/DELETE-404.sh b/http-tests/document-hierarchy/DELETE-404.sh index e5b99b11a1..2b0516003d 100755 --- a/http-tests/document-hierarchy/DELETE-404.sh +++ b/http-tests/document-hierarchy/DELETE-404.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/DELETE-conditional-412.sh b/http-tests/document-hierarchy/DELETE-conditional-412.sh index cad6b1ffc2..bda0c28751 100755 --- a/http-tests/document-hierarchy/DELETE-conditional-412.sh +++ b/http-tests/document-hierarchy/DELETE-conditional-412.sh @@ -11,8 +11,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" slug="test" -container=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -21,9 +21,9 @@ container=$(create-container.sh \ # add an explicit read/write authorization for the owner because add-agent-to-group.sh won't work non-existing URI -create-authorization.sh \ +ldh admin acl create-authorization \ -b "$ADMIN_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --label "Write base" \ --agent "$AGENT_URI" \ diff --git a/http-tests/document-hierarchy/DELETE-conditional.sh b/http-tests/document-hierarchy/DELETE-conditional.sh index 61f3a54c02..40726579f0 100755 --- a/http-tests/document-hierarchy/DELETE-conditional.sh +++ b/http-tests/document-hierarchy/DELETE-conditional.sh @@ -11,8 +11,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" slug="test" -container=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -21,9 +21,9 @@ container=$(create-container.sh \ # add an explicit read/write authorization for the owner because add-agent-to-group.sh won't work non-existing URI -create-authorization.sh \ +ldh admin acl create-authorization \ -b "$ADMIN_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --label "Write base" \ --agent "$AGENT_URI" \ diff --git a/http-tests/document-hierarchy/DELETE-owner-405.sh b/http-tests/document-hierarchy/DELETE-owner-405.sh index a2c7d3742e..5e7d1260fc 100755 --- a/http-tests/document-hierarchy/DELETE-owner-405.sh +++ b/http-tests/document-hierarchy/DELETE-owner-405.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/owners/" diff --git a/http-tests/document-hierarchy/DELETE-root-405.sh b/http-tests/document-hierarchy/DELETE-root-405.sh index a27d4a8427..fc421d88fd 100755 --- a/http-tests/document-hierarchy/DELETE-root-405.sh +++ b/http-tests/document-hierarchy/DELETE-root-405.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/owners/" diff --git a/http-tests/document-hierarchy/DELETE-secretary-405.sh b/http-tests/document-hierarchy/DELETE-secretary-405.sh index 26a2190b6e..c542de8f5f 100755 --- a/http-tests/document-hierarchy/DELETE-secretary-405.sh +++ b/http-tests/document-hierarchy/DELETE-secretary-405.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/owners/" diff --git a/http-tests/document-hierarchy/DELETE.sh b/http-tests/document-hierarchy/DELETE.sh index 8ffd924f41..b7724dec73 100755 --- a/http-tests/document-hierarchy/DELETE.sh +++ b/http-tests/document-hierarchy/DELETE.sh @@ -11,8 +11,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" slug="test" -container=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -21,9 +21,9 @@ container=$(create-container.sh \ # add an explicit read/write authorization for the owner because add-agent-to-group.sh won't work non-existing URI -create-authorization.sh \ +ldh admin acl create-authorization \ -b "$ADMIN_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --label "Write base" \ --agent "$AGENT_URI" \ diff --git a/http-tests/document-hierarchy/GET-404.sh b/http-tests/document-hierarchy/GET-404.sh index de39593def..a5f2a86514 100755 --- a/http-tests/document-hierarchy/GET-404.sh +++ b/http-tests/document-hierarchy/GET-404.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/GET-admin-html.sh b/http-tests/document-hierarchy/GET-admin-html.sh index 3cd4af9961..1bbe9b09c6 100755 --- a/http-tests/document-hierarchy/GET-admin-html.sh +++ b/http-tests/document-hierarchy/GET-admin-html.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the owners group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/owners/" diff --git a/http-tests/document-hierarchy/GET-admin.sh b/http-tests/document-hierarchy/GET-admin.sh index 16bb82587b..49d1c2f54b 100755 --- a/http-tests/document-hierarchy/GET-admin.sh +++ b/http-tests/document-hierarchy/GET-admin.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the owners group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/owners/" diff --git a/http-tests/document-hierarchy/GET-children.sh b/http-tests/document-hierarchy/GET-children.sh index 97cdd69ea9..d6c06865d4 100755 --- a/http-tests/document-hierarchy/GET-children.sh +++ b/http-tests/document-hierarchy/GET-children.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -34,8 +34,8 @@ curl -k -f -s \ slug="test-children-query" -container=$(create-container.sh \ - -f "$AGENT_CERT_FILE" \ +container=$(ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test Children Query" \ diff --git a/http-tests/document-hierarchy/GET-conditional-412.sh b/http-tests/document-hierarchy/GET-conditional-412.sh index f50926ca58..fe019e810e 100755 --- a/http-tests/document-hierarchy/GET-conditional-412.sh +++ b/http-tests/document-hierarchy/GET-conditional-412.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/document-hierarchy/GET-conditional.sh b/http-tests/document-hierarchy/GET-conditional.sh index 8cde864704..6d9b0fa7f6 100755 --- a/http-tests/document-hierarchy/GET-conditional.sh +++ b/http-tests/document-hierarchy/GET-conditional.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/document-hierarchy/GET-html.sh b/http-tests/document-hierarchy/GET-html.sh index a53cfffefd..92627cc47b 100755 --- a/http-tests/document-hierarchy/GET-html.sh +++ b/http-tests/document-hierarchy/GET-html.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/document-hierarchy/GET-namespace-forClass-rdfs.sh b/http-tests/document-hierarchy/GET-namespace-forClass-rdfs.sh deleted file mode 100644 index e363dfbcf4..0000000000 --- a/http-tests/document-hierarchy/GET-namespace-forClass-rdfs.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# sp:Describe is declared only as rdfs:Class (not owl:Class) in sp.ttl. -# OntologyFilter must promote rdfs:Class to owl:Class during materialization so -# that OWL2 profiles recognise third-party vocab terms and return their SPIN constructors. - -response=$(curl -k -f -s \ - -G \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -H "Accept: application/rdf+xml" \ - --data-urlencode "forClass=http://spinrdf.org/sp#Describe" \ - "${END_USER_BASE_URL}ns") - -# response must be non-empty: sp:Describe must be recognised as an OntClass -echo "$response" | grep -q "http://spinrdf.org/sp#Describe" diff --git a/http-tests/document-hierarchy/GET-ntriples.sh b/http-tests/document-hierarchy/GET-ntriples.sh index 4223b78174..10e5c0d146 100755 --- a/http-tests/document-hierarchy/GET-ntriples.sh +++ b/http-tests/document-hierarchy/GET-ntriples.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/GET-sparql.sh b/http-tests/document-hierarchy/GET-sparql.sh index a819738a3a..811f95df86 100755 --- a/http-tests/document-hierarchy/GET-sparql.sh +++ b/http-tests/document-hierarchy/GET-sparql.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/document-hierarchy/GET.sh b/http-tests/document-hierarchy/GET.sh index 9e61a3be81..95e9b2fa48 100755 --- a/http-tests/document-hierarchy/GET.sh +++ b/http-tests/document-hierarchy/GET.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/document-hierarchy/HEAD-accept-lang.sh b/http-tests/document-hierarchy/HEAD-accept-lang.sh index a33c5a27d6..edc4d5c51a 100755 --- a/http-tests/document-hierarchy/HEAD-accept-lang.sh +++ b/http-tests/document-hierarchy/HEAD-accept-lang.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/document-hierarchy/HEAD-accept.sh b/http-tests/document-hierarchy/HEAD-accept.sh index 8a86fe45bb..d7f6888f4d 100755 --- a/http-tests/document-hierarchy/HEAD-accept.sh +++ b/http-tests/document-hierarchy/HEAD-accept.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/document-hierarchy/PATCH-404.sh b/http-tests/document-hierarchy/PATCH-404.sh index c8055110b0..0de72b24ab 100755 --- a/http-tests/document-hierarchy/PATCH-404.sh +++ b/http-tests/document-hierarchy/PATCH-404.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PATCH-blank-node-skolemized.sh b/http-tests/document-hierarchy/PATCH-blank-node-skolemized.sh index fec9f83436..4fc76956cf 100755 --- a/http-tests/document-hierarchy/PATCH-blank-node-skolemized.sh +++ b/http-tests/document-hierarchy/PATCH-blank-node-skolemized.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PATCH-constraint-422.sh b/http-tests/document-hierarchy/PATCH-constraint-422.sh index f3e37c2f31..8061c117c8 100755 --- a/http-tests/document-hierarchy/PATCH-constraint-422.sh +++ b/http-tests/document-hierarchy/PATCH-constraint-422.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PATCH-delete-where-no-match.sh b/http-tests/document-hierarchy/PATCH-delete-where-no-match.sh index f6eda91f16..b905ccf6b4 100755 --- a/http-tests/document-hierarchy/PATCH-delete-where-no-match.sh +++ b/http-tests/document-hierarchy/PATCH-delete-where-no-match.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -27,8 +27,8 @@ echo " \"value2\" . . \"value3\" ." | \ - put.sh \ - -f "$OWNER_CERT_FILE" \ + ldh put \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -t "application/n-triples" \ "$test_graph_uri" @@ -38,14 +38,14 @@ echo "PREFIX ex: PREFIX owl: DELETE WHERE { ex:nonExistentResource owl:imports ex:nonExistentOntology }" | \ - patch.sh \ - -f "$OWNER_CERT_FILE" \ + ldh patch \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ "$test_graph_uri" # Verify graph still exists and contains original triples -graph_content=$(get.sh \ - -f "$OWNER_CERT_FILE" \ +graph_content=$(ldh get \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --accept "application/n-triples" \ "$test_graph_uri") diff --git a/http-tests/document-hierarchy/PATCH-empty-container.sh b/http-tests/document-hierarchy/PATCH-empty-container.sh index f61f40dad5..0dabcc6859 100755 --- a/http-tests/document-hierarchy/PATCH-empty-container.sh +++ b/http-tests/document-hierarchy/PATCH-empty-container.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug=$(uuidgen | tr '[:upper:]' '[:lower:]') -container=$(create-container.sh \ - -f "$AGENT_CERT_FILE" \ +container=$(ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test container" \ diff --git a/http-tests/document-hierarchy/PATCH-empty-item.sh b/http-tests/document-hierarchy/PATCH-empty-item.sh index 6f4d02978c..9a0654f7da 100755 --- a/http-tests/document-hierarchy/PATCH-empty-item.sh +++ b/http-tests/document-hierarchy/PATCH-empty-item.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug=$(uuidgen | tr '[:upper:]' '[:lower:]') -item=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +item=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test item" \ diff --git a/http-tests/document-hierarchy/PATCH-empty-root-405.sh b/http-tests/document-hierarchy/PATCH-empty-root-405.sh index eda7a677f0..1797220764 100755 --- a/http-tests/document-hierarchy/PATCH-empty-root-405.sh +++ b/http-tests/document-hierarchy/PATCH-empty-root-405.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the owners group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/owners/" diff --git a/http-tests/document-hierarchy/PATCH-graph-422.sh b/http-tests/document-hierarchy/PATCH-graph-422.sh index 3dc0d9c2ce..a6532fcb26 100755 --- a/http-tests/document-hierarchy/PATCH-graph-422.sh +++ b/http-tests/document-hierarchy/PATCH-graph-422.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PATCH-invalid-content-block-422.sh b/http-tests/document-hierarchy/PATCH-invalid-content-block-422.sh index 8f97618d00..e7a01120b0 100755 --- a/http-tests/document-hierarchy/PATCH-invalid-content-block-422.sh +++ b/http-tests/document-hierarchy/PATCH-invalid-content-block-422.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PATCH-remove-subresource.sh b/http-tests/document-hierarchy/PATCH-remove-subresource.sh index fae27a1418..fb872f11e0 100755 --- a/http-tests/document-hierarchy/PATCH-remove-subresource.sh +++ b/http-tests/document-hierarchy/PATCH-remove-subresource.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PATCH-strips-rdf-type-422.sh b/http-tests/document-hierarchy/PATCH-strips-rdf-type-422.sh index b52caa2569..b5c145cc7e 100755 --- a/http-tests/document-hierarchy/PATCH-strips-rdf-type-422.sh +++ b/http-tests/document-hierarchy/PATCH-strips-rdf-type-422.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PATCH-swap-type.sh b/http-tests/document-hierarchy/PATCH-swap-type.sh index 9375418924..320a8f34b9 100755 --- a/http-tests/document-hierarchy/PATCH-swap-type.sh +++ b/http-tests/document-hierarchy/PATCH-swap-type.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PATCH.sh b/http-tests/document-hierarchy/PATCH.sh index fc82d852e9..15fe0edc6e 100755 --- a/http-tests/document-hierarchy/PATCH.sh +++ b/http-tests/document-hierarchy/PATCH.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/POST-404.sh b/http-tests/document-hierarchy/POST-404.sh index 2cf0fccf9f..b328a599fb 100755 --- a/http-tests/document-hierarchy/POST-404.sh +++ b/http-tests/document-hierarchy/POST-404.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/POST-conditional-412.sh b/http-tests/document-hierarchy/POST-conditional-412.sh index 7a8b2138e1..e6d7d7a278 100755 --- a/http-tests/document-hierarchy/POST-conditional-412.sh +++ b/http-tests/document-hierarchy/POST-conditional-412.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/POST-conditional.sh b/http-tests/document-hierarchy/POST-conditional.sh index 67362d0d9c..1fa89bdee8 100755 --- a/http-tests/document-hierarchy/POST-conditional.sh +++ b/http-tests/document-hierarchy/POST-conditional.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/POST-html-jsonld.sh b/http-tests/document-hierarchy/POST-html-jsonld.sh index ab16fe546f..6f9d480cad 100755 --- a/http-tests/document-hierarchy/POST-html-jsonld.sh +++ b/http-tests/document-hierarchy/POST-html-jsonld.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/POST-item-metadata.sh b/http-tests/document-hierarchy/POST-item-metadata.sh index 901543b069..6bcc27f411 100755 --- a/http-tests/document-hierarchy/POST-item-metadata.sh +++ b/http-tests/document-hierarchy/POST-item-metadata.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -36,8 +36,8 @@ EOF # get initial state and verify cardinalities after PUT -item_ntriples=$(get.sh \ - -f "$AGENT_CERT_FILE" \ +item_ntriples=$(ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$item" @@ -72,8 +72,8 @@ EOF # get state after first POST and verify cardinalities -item_ntriples=$(get.sh \ - -f "$AGENT_CERT_FILE" \ +item_ntriples=$(ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$item" @@ -108,8 +108,8 @@ EOF # get final state and verify cardinalities (key test for the fix) -item_ntriples=$(get.sh \ - -f "$AGENT_CERT_FILE" \ +item_ntriples=$(ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$item" diff --git a/http-tests/document-hierarchy/POST-ntriples-etag.sh b/http-tests/document-hierarchy/POST-ntriples-etag.sh index d226c595d6..163654d128 100755 --- a/http-tests/document-hierarchy/POST-ntriples-etag.sh +++ b/http-tests/document-hierarchy/POST-ntriples-etag.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/POST-ntriples-public-etag.sh b/http-tests/document-hierarchy/POST-ntriples-public-etag.sh index fa9c0dcabf..2e8ea9768a 100755 --- a/http-tests/document-hierarchy/POST-ntriples-public-etag.sh +++ b/http-tests/document-hierarchy/POST-ntriples-public-etag.sh @@ -9,16 +9,16 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" # create public authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "Public access authorization" \ diff --git a/http-tests/document-hierarchy/POST-ntriples.sh b/http-tests/document-hierarchy/POST-ntriples.sh index 7f89a9e747..3a91306b53 100755 --- a/http-tests/document-hierarchy/POST-ntriples.sh +++ b/http-tests/document-hierarchy/POST-ntriples.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PUT-conditional-412.sh b/http-tests/document-hierarchy/PUT-conditional-412.sh index 696818bdc8..ca81070b3c 100755 --- a/http-tests/document-hierarchy/PUT-conditional-412.sh +++ b/http-tests/document-hierarchy/PUT-conditional-412.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PUT-conditional.sh b/http-tests/document-hierarchy/PUT-conditional.sh index 5c448962e0..36f4931387 100755 --- a/http-tests/document-hierarchy/PUT-conditional.sh +++ b/http-tests/document-hierarchy/PUT-conditional.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PUT-content-blocks.sh b/http-tests/document-hierarchy/PUT-content-blocks.sh index ab39ce63a3..e15909ede7 100755 --- a/http-tests/document-hierarchy/PUT-content-blocks.sh +++ b/http-tests/document-hierarchy/PUT-content-blocks.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug=$(uuidgen | tr '[:upper:]' '[:lower:]') -item=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +item=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test item" \ diff --git a/http-tests/document-hierarchy/PUT-double-slash-uri-400.sh b/http-tests/document-hierarchy/PUT-double-slash-uri-400.sh index 23ffd6883d..cb461b6dd8 100755 --- a/http-tests/document-hierarchy/PUT-double-slash-uri-400.sh +++ b/http-tests/document-hierarchy/PUT-double-slash-uri-400.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -18,8 +18,8 @@ add-agent-to-group.sh \ # create a container - IRIx resolves ".." on "new-item//" to "new-item/" (one segment per slash), # so the parent container must exist for authorization to pass and reach the // validation in put() -container=$(create-container.sh \ - -f "$AGENT_CERT_FILE" \ +container=$(ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test Container" \ diff --git a/http-tests/document-hierarchy/PUT-empty.sh b/http-tests/document-hierarchy/PUT-empty.sh index 1eef674532..5651a9c74b 100755 --- a/http-tests/document-hierarchy/PUT-empty.sh +++ b/http-tests/document-hierarchy/PUT-empty.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PUT-invalid-content-block-422.sh b/http-tests/document-hierarchy/PUT-invalid-content-block-422.sh index f5e9ee48da..b0cf90848b 100755 --- a/http-tests/document-hierarchy/PUT-invalid-content-block-422.sh +++ b/http-tests/document-hierarchy/PUT-invalid-content-block-422.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug=$(uuidgen | tr '[:upper:]' '[:lower:]') -item=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +item=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test item" \ diff --git a/http-tests/document-hierarchy/PUT-item-metadata.sh b/http-tests/document-hierarchy/PUT-item-metadata.sh index 942c8cd919..e012b47397 100755 --- a/http-tests/document-hierarchy/PUT-item-metadata.sh +++ b/http-tests/document-hierarchy/PUT-item-metadata.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -33,8 +33,8 @@ EOF ) \ | grep -q "$STATUS_CREATED" -item_ntriples=$(get.sh \ - -f "$AGENT_CERT_FILE" \ +item_ntriples=$(ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$item" @@ -73,8 +73,8 @@ EOF ) \ | grep -q "$STATUS_OK" -item_ntriples=$(get.sh \ - -f "$AGENT_CERT_FILE" \ +item_ntriples=$(ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$item" diff --git a/http-tests/document-hierarchy/PUT-item-parent-403.sh b/http-tests/document-hierarchy/PUT-item-parent-403.sh index 748f222fc8..d9ce58f83d 100755 --- a/http-tests/document-hierarchy/PUT-item-parent-403.sh +++ b/http-tests/document-hierarchy/PUT-item-parent-403.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug="test-item" -item=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +item=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ diff --git a/http-tests/document-hierarchy/PUT-location.sh b/http-tests/document-hierarchy/PUT-location.sh index 0da9472804..dadb8f8f95 100755 --- a/http-tests/document-hierarchy/PUT-location.sh +++ b/http-tests/document-hierarchy/PUT-location.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PUT-no-parent-403.sh b/http-tests/document-hierarchy/PUT-no-parent-403.sh index ae7c98dec9..b6494eb06d 100755 --- a/http-tests/document-hierarchy/PUT-no-parent-403.sh +++ b/http-tests/document-hierarchy/PUT-no-parent-403.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PUT-no-slash-308.sh b/http-tests/document-hierarchy/PUT-no-slash-308.sh index e0b6ae1ce1..af0326de48 100755 --- a/http-tests/document-hierarchy/PUT-no-slash-308.sh +++ b/http-tests/document-hierarchy/PUT-no-slash-308.sh @@ -9,17 +9,17 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" # add an explicit read/write authorization for the parent since the child document will inherit it -create-authorization.sh \ +ldh admin acl create-authorization \ -b "$ADMIN_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --label "Write base" \ --agent "$AGENT_URI" \ diff --git a/http-tests/document-hierarchy/PUT-ntriples-etag.sh b/http-tests/document-hierarchy/PUT-ntriples-etag.sh index eafbd71090..4b7732dcf1 100755 --- a/http-tests/document-hierarchy/PUT-ntriples-etag.sh +++ b/http-tests/document-hierarchy/PUT-ntriples-etag.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PUT-ntriples-public-etag.sh b/http-tests/document-hierarchy/PUT-ntriples-public-etag.sh index 95c9f9c411..ff30219b84 100755 --- a/http-tests/document-hierarchy/PUT-ntriples-public-etag.sh +++ b/http-tests/document-hierarchy/PUT-ntriples-public-etag.sh @@ -9,16 +9,16 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" # create public authorization -create-authorization.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl create-authorization \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --label "Public access authorization" \ diff --git a/http-tests/document-hierarchy/PUT-ntriples.sh b/http-tests/document-hierarchy/PUT-ntriples.sh index 88053e31ba..c2e3384386 100755 --- a/http-tests/document-hierarchy/PUT-ntriples.sh +++ b/http-tests/document-hierarchy/PUT-ntriples.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PUT-orphan-bnode-object-skolemized.sh b/http-tests/document-hierarchy/PUT-orphan-bnode-object-skolemized.sh index a8c58ee43d..f4b7f48cff 100755 --- a/http-tests/document-hierarchy/PUT-orphan-bnode-object-skolemized.sh +++ b/http-tests/document-hierarchy/PUT-orphan-bnode-object-skolemized.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug=$(uuidgen | tr '[:upper:]' '[:lower:]') -container=$(create-container.sh \ - -f "$AGENT_CERT_FILE" \ +container=$(ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test container" \ diff --git a/http-tests/document-hierarchy/PUT-owner-405.sh b/http-tests/document-hierarchy/PUT-owner-405.sh index 43f4b4663a..39a25a0803 100755 --- a/http-tests/document-hierarchy/PUT-owner-405.sh +++ b/http-tests/document-hierarchy/PUT-owner-405.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/owners/" diff --git a/http-tests/document-hierarchy/PUT-relative-uri-ntriples-400.sh b/http-tests/document-hierarchy/PUT-relative-uri-ntriples-400.sh index 7cd64cacfd..3e8409d2b0 100755 --- a/http-tests/document-hierarchy/PUT-relative-uri-ntriples-400.sh +++ b/http-tests/document-hierarchy/PUT-relative-uri-ntriples-400.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PUT-relative-uri-turtle.sh b/http-tests/document-hierarchy/PUT-relative-uri-turtle.sh index 0f795ff79c..e52646dab8 100755 --- a/http-tests/document-hierarchy/PUT-relative-uri-turtle.sh +++ b/http-tests/document-hierarchy/PUT-relative-uri-turtle.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/PUT-secretary-405.sh b/http-tests/document-hierarchy/PUT-secretary-405.sh index b0eef91221..c015c64ea8 100755 --- a/http-tests/document-hierarchy/PUT-secretary-405.sh +++ b/http-tests/document-hierarchy/PUT-secretary-405.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/owners/" diff --git a/http-tests/document-hierarchy/PUT-twice.sh b/http-tests/document-hierarchy/PUT-twice.sh index 8076781047..c58e46e8d3 100755 --- a/http-tests/document-hierarchy/PUT-twice.sh +++ b/http-tests/document-hierarchy/PUT-twice.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/document-hierarchy/create-container.sh b/http-tests/document-hierarchy/create-container.sh index 47bc3be645..16af5be945 100755 --- a/http-tests/document-hierarchy/create-container.sh +++ b/http-tests/document-hierarchy/create-container.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug="test" -container=$(create-container.sh \ - -f "$AGENT_CERT_FILE" \ +container=$(ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -29,8 +29,8 @@ container=$(create-container.sh \ # check that the container was created at the expected URL and attached to the document hierarchy -get.sh \ - -f "$AGENT_CERT_FILE" \ +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$container" \ diff --git a/http-tests/document-hierarchy/create-item.sh b/http-tests/document-hierarchy/create-item.sh index 196b76955f..07c36767d4 100755 --- a/http-tests/document-hierarchy/create-item.sh +++ b/http-tests/document-hierarchy/create-item.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug="test-item" -item=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +item=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -29,8 +29,8 @@ item=$(create-item.sh \ # check that the item was created at the expected URL and attached to the document hierarchy -get.sh \ - -f "$AGENT_CERT_FILE" \ +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$item" \ diff --git a/http-tests/document-hierarchy/owner-container.sh b/http-tests/document-hierarchy/owner-container.sh index 6037a1060b..4e3df66f80 100755 --- a/http-tests/document-hierarchy/owner-container.sh +++ b/http-tests/document-hierarchy/owner-container.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug="test" -container=$(create-container.sh \ - -f "$AGENT_CERT_FILE" \ +container=$(ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -29,8 +29,8 @@ container=$(create-container.sh \ # check that the created container has the agent as owner -get.sh \ - -f "$AGENT_CERT_FILE" \ +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$container" \ diff --git a/http-tests/document-hierarchy/owner-item.sh b/http-tests/document-hierarchy/owner-item.sh index 9e7aea7dcc..bc718f18de 100755 --- a/http-tests/document-hierarchy/owner-item.sh +++ b/http-tests/document-hierarchy/owner-item.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -19,8 +19,8 @@ add-agent-to-group.sh \ slug="test-item" -item=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +item=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -29,8 +29,8 @@ item=$(create-item.sh \ # check that the created item has the agent as owner -get.sh \ - -f "$AGENT_CERT_FILE" \ +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$item" \ diff --git a/http-tests/federation/GET-remote-document-links.sh b/http-tests/federation/GET-remote-document-links.sh new file mode 100755 index 0000000000..0417656a84 --- /dev/null +++ b/http-tests/federation/GET-remote-document-links.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# Federation browse leg: instance A's client dereferences instance B's document through A's +# Linked Data proxy. The wire carries a conneg GET; B's hypermedia (Link headers) is forwarded +# so the client discovers B's SPARQL endpoint and application at runtime, and B's ETag is +# forwarded so preconditioned writes against B validate. The two dataspaces share a triplestore +# below the HTTP surface (test config), but meet only through the full HTTP stack here. + +remote_base="https://test.localhost:4443/" + +headers=$(mktemp) +trap 'rm -f "$headers"' EXIT + +# dereference B's root document through A's proxy + +curl -k -f -s -o /dev/null -D "$headers" \ + -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/rdf+xml" \ + --data-urlencode "uri=${remote_base}" \ + "$END_USER_BASE_URL" + +# B's SPARQL endpoint is discovered from the forwarded Link header, not configured + +grep -i '^link:' "$headers" | tr ',' '\n' | grep 'sparql-service-description#endpoint' | grep -q "${remote_base}sparql" + +# B's application URI is forwarded too (it marks the remote as a Linked Data application) + +grep -i '^link:' "$headers" | tr ',' '\n' | grep -q 'linkeddatahub/apps#application' + +# the proxied response carries B's own ETag (resource-state validator), enabling If-Match writes + +proxied_etag=$(grep -i '^etag:' "$headers" | tr -d '\r' | awk '{print $2}') +direct_etag=$(curl -k -f -s -I \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/rdf+xml" \ + "$remote_base" \ +| grep -i '^etag:' | tr -d '\r' | awk '{print $2}') + +echo "DEBUG: proxied ETag: $proxied_etag direct ETag: $direct_etag" +if [ -z "$proxied_etag" ] || [ "$proxied_etag" != "$direct_etag" ]; then + echo "DEBUG: proxied ETag does not match the origin's ETag" + exit 1 +fi diff --git a/http-tests/federation/PATCH-remote-document-unauthorized.sh b/http-tests/federation/PATCH-remote-document-unauthorized.sh new file mode 100755 index 0000000000..3516cca1dc --- /dev/null +++ b/http-tests/federation/PATCH-remote-document-unauthorized.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# Federation negative: B's access control arbitrates the meeting. The signed-up agent is a +# federation identity that is NOT granted write on B (no authorization is created for it on +# test.localhost). Its delegated cross-instance PATCH is refused - the proxy forwards the +# identity, but B's ACL, not the proxy, decides. A truly anonymous request cannot express this: +# a proxied request with no user certificate rides the server's own credential to the origin. + +remote_base="https://test.localhost:4443/" + +# create the target on B as the owner (authorized), so only the *writer* differs from the +# positive test + +item=$(ldh create-item \ + -f "$OWNER_CERT_KEYSTORE" \ + -p "$OWNER_CERT_PWD" \ + -b "$remote_base" \ + --title "Federation unauthorized target" \ + --slug "federation-unauthorized-$(date +%s)" \ + --container "$remote_base") + +update=$(cat < + +INSERT +{ + <${item}> dct:description "Should not land" . +} +WHERE {} +EOF +) + +# the agent's delegated write is refused by B (401 if B declines the identity, 403 if it is +# recognised but unauthorized - either way not a success) + +code=$(curl -k -w "%{http_code}" -o /dev/null -s \ + -X PATCH \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + -H 'Content-Type: application/sparql-update' \ + --url-query "uri=${item}" \ + --data-binary "$update" \ + "$END_USER_BASE_URL") + +echo "DEBUG: unauthorized cross-instance PATCH returned: $code" +if ! echo "$code" | grep -qE "^($STATUS_UNAUTHORIZED|$STATUS_FORBIDDEN)$"; then + echo "DEBUG: expected 401 or 403 for the unauthorized delegated write, got: $code" + exit 1 +fi + +# the delta did not land + +if curl -k -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "$item" \ +| grep -q "Should not land"; then + echo "DEBUG: unauthorized delta landed on the remote document" + exit 1 +fi diff --git a/http-tests/federation/PATCH-remote-document.sh b/http-tests/federation/PATCH-remote-document.sh new file mode 100755 index 0000000000..c3d2d89338 --- /dev/null +++ b/http-tests/federation/PATCH-remote-document.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# Federation write leg: A's client submits a graph-scoped SPARQL Update delta as a PATCH against +# B's document, through A's proxy, under an If-Match precondition using B's own ETag (forwarded +# by the proxy on the read). The proxy forwards the method, body and the agent's identity; B's +# ACL arbitrates. This is the read-write half of the federation test: browse, query, and write +# crossing the wire on spec-terms only. + +remote_base="https://test.localhost:4443/" + +# create the document on B (the owner is authorized on both dataspaces in the test setup) + +item=$(ldh create-item \ + -f "$OWNER_CERT_KEYSTORE" \ + -p "$OWNER_CERT_PWD" \ + -b "$remote_base" \ + --title "Federation write target" \ + --slug "federation-patch-$(date +%s)" \ + --container "$remote_base") + +# read the document through A's proxy, capturing B's ETag for the precondition + +etag=$(curl -k -f -s -o /dev/null -D - \ + -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/rdf+xml" \ + --data-urlencode "uri=${item}" \ + "$END_USER_BASE_URL" \ +| grep -i '^etag:' | tr -d '\r' | awk '{print $2}') + +echo "DEBUG: ETag for If-Match: $etag" +if [ -z "$etag" ]; then + echo "DEBUG: no ETag on the proxied response" + exit 1 +fi + +update=$(cat < + +INSERT +{ + <${item}> dct:description "Updated across instances" . +} +WHERE {} +EOF +) + +# a stale precondition is rejected by B - proves the proxy forwards If-Match and B evaluates it. +# Accept must match the read: LDH ETags are variant-specific (the negotiated media type folds into +# the tag), so the conditional PATCH negotiates the same rdf+xml variant the ETag above was read for. + +stale_code=$(curl -k -w "%{http_code}" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H 'Content-Type: application/sparql-update' \ + -H 'Accept: application/rdf+xml' \ + -H 'If-Match: "stale"' \ + --url-query "uri=${item}" \ + --data-binary "$update" \ + "$END_USER_BASE_URL") + +echo "DEBUG: stale If-Match returned: $stale_code (expected $STATUS_PRECONDITION_FAILED)" +if [ "$stale_code" != "$STATUS_PRECONDITION_FAILED" ]; then + exit 1 +fi + +# the delta with B's current ETag succeeds + +valid_code=$(curl -k -w "%{http_code}" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H 'Content-Type: application/sparql-update' \ + -H 'Accept: application/rdf+xml' \ + -H "If-Match: $etag" \ + --url-query "uri=${item}" \ + --data-binary "$update" \ + "$END_USER_BASE_URL") + +echo "DEBUG: valid If-Match returned: $valid_code (expected $STATUS_NO_CONTENT)" +if [ "$valid_code" != "$STATUS_NO_CONTENT" ]; then + exit 1 +fi + +# the delta landed on B - confirmed on B directly, not through the proxy + +curl -k -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "$item" \ +| grep "Updated across instances" > /dev/null diff --git a/http-tests/federation/POST-remote-endpoint-query.sh b/http-tests/federation/POST-remote-endpoint-query.sh new file mode 100755 index 0000000000..c057a4b32c --- /dev/null +++ b/http-tests/federation/POST-remote-endpoint-query.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# Federation query leg: A's client poses a SPARQL Protocol query to B's endpoint, with the +# endpoint URL taken from B's forwarded Link header (runtime discovery, not configuration). +# The query request rides A's proxy, which forwards the method, body and media type. + +remote_base="https://test.localhost:4443/" + +headers=$(mktemp) +trap 'rm -f "$headers"' EXIT + +curl -k -f -s -o /dev/null -D "$headers" \ + -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/rdf+xml" \ + --data-urlencode "uri=${remote_base}" \ + "$END_USER_BASE_URL" + +endpoint=$(grep -i '^link:' "$headers" | tr ',' '\n' | grep 'sparql-service-description#endpoint' | sed 's/.*<\([^>]*\)>.*/\1/') + +echo "DEBUG: discovered endpoint: $endpoint" +if [ -z "$endpoint" ]; then + echo "DEBUG: no sd:endpoint Link header forwarded" + exit 1 +fi + +# query B's root document graph on the discovered endpoint, through A's proxy + +query="SELECT * WHERE { GRAPH <${remote_base}> { ?s ?p ?o } } LIMIT 1" + +count=$(curl -k -f -s \ + -X POST \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-query" \ + -H "Accept: application/sparql-results+xml" \ + --url-query "uri=${endpoint}" \ + --data-binary "$query" \ + "$END_USER_BASE_URL" \ +| xmllint --xpath "count(//*[local-name() = 'result'])" -) + +if [ "$count" != "1" ]; then + echo "DEBUG: Expected 1 result from the discovered remote endpoint, got: $count" + exit 1 +fi diff --git a/http-tests/federation/POST-remote-ns-constructors.sh b/http-tests/federation/POST-remote-ns-constructors.sh new file mode 100755 index 0000000000..c15ece2448 --- /dev/null +++ b/http-tests/federation/POST-remote-ns-constructors.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# Federation ontology leg: the constructor SELECT that drives A's client-side form derivation, +# posed against B's ns endpoint through A's proxy. On a remote pane the client resolves ns +# against the pane's data-base (B's base from the forwarded lapp:application Link), so forms +# for B's resources derive from B's ontology closure - this pins that contract on the wire. + +remote_base="https://test.localhost:4443/" +remote_ns="${remote_base}ns" + +query='SELECT DISTINCT ?constructor ?text WHERE { VALUES ?type { } ?type * ?class . ?class ?constructor . ?constructor ?text . }' + +results=$(curl -k -f -s \ + -X POST \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-query" \ + -H "Accept: application/sparql-results+xml" \ + --url-query "uri=${remote_ns}" \ + --data-binary "$query" \ + "$END_USER_BASE_URL") + +# the default LDH ontology is in every app's closure, so its constructors are returned by B + +echo "$results" | grep -q "https://w3id.org/atomgraph/linkeddatahub#TitleConstructor" + +count=$(echo "$results" | xmllint --xpath "count(//*[local-name() = 'binding'][@name = 'text']/*[local-name() = 'literal'][contains(., 'CONSTRUCT')])" -) +if [ "$count" -lt 1 ]; then + echo "DEBUG: Expected at least 1 constructor text from the remote ns, got: $count" + exit 1 +fi diff --git a/http-tests/imports/GET-file-404.sh b/http-tests/imports/GET-file-404.sh index 6d627d8fa4..88bea1336f 100755 --- a/http-tests/imports/GET-file-404.sh +++ b/http-tests/imports/GET-file-404.sh @@ -8,8 +8,8 @@ purge_cache "$ADMIN_VARNISH_SERVICE" purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/imports/GET-file-range.sh b/http-tests/imports/GET-file-range.sh index c9c416308f..954c6fb8ff 100755 --- a/http-tests/imports/GET-file-range.sh +++ b/http-tests/imports/GET-file-range.sh @@ -11,8 +11,8 @@ pwd=$(realpath "$PWD") # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -25,8 +25,8 @@ file_content_type="application/octet-stream" slug=$(uuidgen | tr '[:upper:]' '[:lower:]') # Create an item document to hold the file -file_doc=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +file_doc=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Random file" \ @@ -34,8 +34,8 @@ file_doc=$(create-item.sh \ --slug "$slug") # Add the file to the document -add-file.sh \ - -f "$AGENT_CERT_FILE" \ +ldh add-file \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Random file" \ diff --git a/http-tests/imports/GET-file-sha1sum.sh b/http-tests/imports/GET-file-sha1sum.sh index 5b62d6bbc6..5f52a76506 100755 --- a/http-tests/imports/GET-file-sha1sum.sh +++ b/http-tests/imports/GET-file-sha1sum.sh @@ -11,8 +11,8 @@ pwd=$(realpath "$PWD") # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -24,8 +24,8 @@ time dd if=/dev/urandom of="$filename" bs=1 count=1024 file_content_type="application/octet-stream" # Create a container for files first -create-container.sh \ - -f "$AGENT_CERT_FILE" \ +ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Files" \ @@ -33,16 +33,16 @@ create-container.sh \ --slug "files" # Create an item document to hold the file -file_doc=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +file_doc=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Random file" \ --container "${END_USER_BASE_URL}files/") # Add the file to the document -add-file.sh \ - -f "$AGENT_CERT_FILE" \ +ldh add-file \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Random file" \ diff --git a/http-tests/imports/GET-file-utf8-charset.sh b/http-tests/imports/GET-file-utf8-charset.sh index 25c0031d24..d7b40159d8 100755 --- a/http-tests/imports/GET-file-utf8-charset.sh +++ b/http-tests/imports/GET-file-utf8-charset.sh @@ -11,8 +11,8 @@ pwd=$(realpath "$PWD") # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -33,8 +33,8 @@ EOF file_content_type="text/markdown" # Create a container for files first -create-container.sh \ - -f "$AGENT_CERT_FILE" \ +ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Files" \ @@ -42,16 +42,16 @@ create-container.sh \ --slug "files" # Create an item document to hold the file -file_doc=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +file_doc=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "UTF-8 test file" \ --container "${END_USER_BASE_URL}files/") # Add the file to the document -add-file.sh \ - -f "$AGENT_CERT_FILE" \ +ldh add-file \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "UTF-8 test file" \ diff --git a/http-tests/imports/PUT-file-format-explicit.sh b/http-tests/imports/PUT-file-format-explicit.sh index d480fcb4a8..a90ab21895 100755 --- a/http-tests/imports/PUT-file-format-explicit.sh +++ b/http-tests/imports/PUT-file-format-explicit.sh @@ -11,8 +11,8 @@ pwd=$(realpath "$PWD") # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -27,8 +27,8 @@ echo "4,5,6" >> "$test_file" slug=$(uuidgen | tr '[:upper:]' '[:lower:]') # Create an item document to hold the file -file_doc=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +file_doc=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test File for Media Type Update" \ @@ -36,8 +36,8 @@ file_doc=$(create-item.sh \ --slug "$slug") # upload file with explicit media type: text/plain -add-file.sh \ - -f "$AGENT_CERT_FILE" \ +ldh add-file \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test File for Media Type Update" \ @@ -51,8 +51,8 @@ file_uri="${END_USER_BASE_URL}uploads/${sha1sum}" # get the file resource URI and initial dct:format -file_doc_ntriples=$(get.sh \ - -f "$AGENT_CERT_FILE" \ +file_doc_ntriples=$(ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$file_doc") @@ -72,8 +72,8 @@ fi # re-upload the same file but different explicit media type: text/csv # this simulates editing the file document through the UI and uploading a new file -add-file.sh \ - -f "$AGENT_CERT_FILE" \ +ldh add-file \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test File for Media Type Update" \ @@ -83,8 +83,8 @@ add-file.sh \ # get updated document -updated_ntriples=$(get.sh \ - -f "$AGENT_CERT_FILE" \ +updated_ntriples=$(ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$file_doc") diff --git a/http-tests/imports/PUT-file-format.sh b/http-tests/imports/PUT-file-format.sh index f066be3969..f07859a506 100755 --- a/http-tests/imports/PUT-file-format.sh +++ b/http-tests/imports/PUT-file-format.sh @@ -11,8 +11,8 @@ pwd=$(realpath "$PWD") # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -27,8 +27,8 @@ echo "4,5,6" >> "$test_file" slug=$(uuidgen | tr '[:upper:]' '[:lower:]') # Create an item document to hold the file -file_doc=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +file_doc=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test File for Browser Media Type" \ @@ -36,8 +36,8 @@ file_doc=$(create-item.sh \ --slug "$slug") # upload file WITHOUT explicit media type (rely on browser detection via `file -b --mime-type`) -add-file.sh \ - -f "$AGENT_CERT_FILE" \ +ldh add-file \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test File for Browser Media Type" \ @@ -50,8 +50,8 @@ file_uri="${END_USER_BASE_URL}uploads/${sha1sum}" # get the file resource URI and initial dct:format -file_doc_ntriples=$(get.sh \ - -f "$AGENT_CERT_FILE" \ +file_doc_ntriples=$(ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$file_doc") @@ -65,8 +65,8 @@ initial_format=$(echo "$file_doc_ntriples" | sed -rn "s/<${file_uri//\//\\/}> &2 +echo "DEBUG: Got: $file" >&2 +[ "$file" = "${END_USER_BASE_URL}uploads/${sha1sum}" ] echo "$file" # file URL used in other tests diff --git a/http-tests/imports/import-csv.sh b/http-tests/imports/import-csv.sh index 85835aaaa1..275e191cd2 100755 --- a/http-tests/imports/import-csv.sh +++ b/http-tests/imports/import-csv.sh @@ -11,16 +11,16 @@ pwd=$(realpath "$PWD") # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" # create import item -item=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +item=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "RDF import" \ @@ -28,8 +28,8 @@ item=$(create-item.sh \ # create target container -container=$(create-container.sh \ - -f "$AGENT_CERT_FILE" \ +container=$(ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ @@ -38,8 +38,8 @@ container=$(create-container.sh \ # import CSV -import-csv.sh \ - -f "$AGENT_CERT_FILE" \ +ldh imports import-csv \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ diff --git a/http-tests/imports/import-rdf-no-query.sh b/http-tests/imports/import-rdf-no-query.sh index 1b63a5bd11..32abd5e495 100755 --- a/http-tests/imports/import-rdf-no-query.sh +++ b/http-tests/imports/import-rdf-no-query.sh @@ -11,16 +11,16 @@ pwd=$(realpath "$PWD") # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" # create import item -item=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +item=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "RDF import" \ @@ -28,8 +28,8 @@ item=$(create-item.sh \ # create target item -graph=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +graph=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Concepts" \ @@ -38,8 +38,8 @@ graph=$(create-item.sh \ # import RDF -import-rdf.sh \ - -f "$AGENT_CERT_FILE" \ +ldh imports import-rdf \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ diff --git a/http-tests/imports/import-rdf.sh b/http-tests/imports/import-rdf.sh index 20ed503760..f66a3af38e 100755 --- a/http-tests/imports/import-rdf.sh +++ b/http-tests/imports/import-rdf.sh @@ -11,16 +11,16 @@ pwd=$(realpath "$PWD") # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" # create import item -item=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +item=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "RDF import" \ @@ -28,8 +28,8 @@ item=$(create-item.sh \ # create target container -container=$(create-container.sh \ - -f "$AGENT_CERT_FILE" \ +container=$(ldh create-container \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Concepts" \ @@ -38,8 +38,8 @@ container=$(create-container.sh \ # import RDF -import-rdf.sh \ - -f "$AGENT_CERT_FILE" \ +ldh imports import-rdf \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test" \ diff --git a/http-tests/misc/PATCH-settings-package-import.sh b/http-tests/misc/PATCH-settings-package-import.sh new file mode 100755 index 0000000000..1e5df3286e --- /dev/null +++ b/http-tests/misc/PATCH-settings-package-import.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# Test: declarative package install — a single ldh:import triple PATCHed into the dataspace +# settings composes the package stylesheet into the app stylesheet on the next request. +# The update strings mirror the ones generated by the settings modal's package Save button. + +app_uri="urn:linkeddatahub:apps/end-user" +package_uri="https://packages.linkeddatahub.com/skos/#this" + +# stylesheet marker injected into every page by the SKOS package layout.xsl +marker="com/linkeddatahub/demo/skos/css/bootstrap.css" + +# verify the homepage is not rendered with the package stylesheet initially +if curl -k -s -H "Accept: text/html" -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" "$END_USER_BASE_URL" \ + | grep -q "$marker"; then + echo "DEBUG: package stylesheet marker present before import" + exit 1 +fi + +# declare the package import +( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-update" \ + -d "INSERT { <${app_uri}> <${package_uri}> . } WHERE { }" \ + "${END_USER_BASE_URL}settings" +) \ +| grep -q "$STATUS_NO_CONTENT" + +# verify the homepage is rendered with the package stylesheet — no restart, no sleep +curl -k -s -H "Accept: text/html" -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" "$END_USER_BASE_URL" \ +| grep -q "$marker" + +# remove the package import +( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-update" \ + -d "DELETE { <${app_uri}> <${package_uri}> . } WHERE { }" \ + "${END_USER_BASE_URL}settings" +) \ +| grep -q "$STATUS_NO_CONTENT" + +# verify the homepage is no longer rendered with the package stylesheet +if curl -k -s -H "Accept: text/html" -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" "$END_USER_BASE_URL" \ + | grep -q "$marker"; then + echo "DEBUG: package stylesheet marker still present after removal" + exit 1 +fi diff --git a/http-tests/misc/PATCH-settings-package-ontology.sh b/http-tests/misc/PATCH-settings-package-ontology.sh new file mode 100755 index 0000000000..6d415ba911 --- /dev/null +++ b/http-tests/misc/PATCH-settings-package-ontology.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# Test: the ldh:import declaration alone puts the package ontology into the application's +# ontology imports closure - the SKOS package's spin:constructor for skos:Concept becomes +# visible on the /ns endpoint after the PATCH and disappears again after removal. + +app_uri="urn:linkeddatahub:apps/end-user" +package_uri="https://packages.linkeddatahub.com/skos/#this" + +query='SELECT ?text WHERE { ?constructor . ?constructor ?text . }' + +constructor_count() { + curl -k -f -s -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/sparql-results+xml" \ + "${END_USER_BASE_URL}ns" \ + --data-urlencode "query=${query}" \ + | xmllint --xpath "count(//*[local-name() = 'result'])" - +} + +# the skos:Concept constructor is not in the app ontology closure initially +count=$(constructor_count) +if [ "$count" != "0" ]; then + echo "DEBUG: Expected 0 skos:Concept constructors before import, got: $count" + exit 1 +fi + +# declare the package import +( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-update" \ + -d "INSERT { <${app_uri}> <${package_uri}> . } WHERE { }" \ + "${END_USER_BASE_URL}settings" +) \ +| grep -q "$STATUS_NO_CONTENT" + +# the /ns query URL is identical across the phases, so evict any cached response +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# the package ontology joined the closure - no restart, no sleep +count=$(constructor_count) +if [ "$count" != "1" ]; then + echo "DEBUG: Expected 1 skos:Concept constructor after import, got: $count" + exit 1 +fi + +# remove the package import +( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-update" \ + -d "DELETE { <${app_uri}> <${package_uri}> . } WHERE { }" \ + "${END_USER_BASE_URL}settings" +) \ +| grep -q "$STATUS_NO_CONTENT" + +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# the package ontology left the closure +count=$(constructor_count) +if [ "$count" != "0" ]; then + echo "DEBUG: Expected 0 skos:Concept constructors after removal, got: $count" + echo "DEBUG: does /settings still carry the ldh:import triple after the DELETE?" + curl -k -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "${END_USER_BASE_URL}settings" \ + | grep -c "linkeddatahub#import" | sed 's/^/DEBUG: ldh:import triple count in settings = /' + exit 1 +fi diff --git a/http-tests/misc/POST-content-length-413.sh b/http-tests/misc/POST-content-length-413.sh index 3dbbb121a6..171b2dbe55 100755 --- a/http-tests/misc/POST-content-length-413.sh +++ b/http-tests/misc/POST-content-length-413.sh @@ -11,8 +11,8 @@ pwd=$(realpath "$PWD") # add agent to the writers group to be able to read/write documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/misc/POST-transfer-chunked-413.sh b/http-tests/misc/POST-transfer-chunked-413.sh index 847e605fe4..3b5d8b2d07 100755 --- a/http-tests/misc/POST-transfer-chunked-413.sh +++ b/http-tests/misc/POST-transfer-chunked-413.sh @@ -11,8 +11,8 @@ pwd=$(realpath "$PWD") # add agent to the writers group to be able to read/write documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/misc/admin-webid-delegation.sh b/http-tests/misc/admin-webid-delegation.sh index b141a67603..58a08694ba 100755 --- a/http-tests/misc/admin-webid-delegation.sh +++ b/http-tests/misc/admin-webid-delegation.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # check that the acl:delegates triple exists in the agent's description -get.sh \ - -f "$AGENT_CERT_FILE" \ +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$AGENT_URI" \ @@ -27,8 +27,8 @@ curl --head -k -w "%{http_code}\n" -o /dev/null -s \ # add agent to the owners group to be able to control the admin app -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/owners/" diff --git a/http-tests/misc/webid-delegation.sh b/http-tests/misc/webid-delegation.sh index 34af28dab0..c331049bab 100755 --- a/http-tests/misc/webid-delegation.sh +++ b/http-tests/misc/webid-delegation.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # check that the acl:delegates triple exists in the agent's description -get.sh \ - -f "$AGENT_CERT_FILE" \ +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "$AGENT_URI" \ @@ -27,8 +27,8 @@ curl --head -k -w "%{http_code}\n" -o /dev/null -s \ # add agent to the writers group to be able to read/write documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/proxy/GET-proxied-404.sh b/http-tests/proxy/GET-proxied-404.sh index ef695402f2..fd573b562a 100755 --- a/http-tests/proxy/GET-proxied-404.sh +++ b/http-tests/proxy/GET-proxied-404.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/GET-proxied-accept-forwarded.sh b/http-tests/proxy/GET-proxied-accept-forwarded.sh index f33938a395..3709e398f2 100755 --- a/http-tests/proxy/GET-proxied-accept-forwarded.sh +++ b/http-tests/proxy/GET-proxied-accept-forwarded.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/GET-proxied-accept-html-not-preferred.sh b/http-tests/proxy/GET-proxied-accept-html-not-preferred.sh index 3e29461564..a907af72c4 100755 --- a/http-tests/proxy/GET-proxied-accept-html-not-preferred.sh +++ b/http-tests/proxy/GET-proxied-accept-html-not-preferred.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/GET-proxied-external-502.sh b/http-tests/proxy/GET-proxied-external-502.sh index c0c6bfc454..957a5c3ff5 100755 --- a/http-tests/proxy/GET-proxied-external-502.sh +++ b/http-tests/proxy/GET-proxied-external-502.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/GET-proxied-external.sh b/http-tests/proxy/GET-proxied-external.sh index 63d3443371..7382e708c4 100755 --- a/http-tests/proxy/GET-proxied-external.sh +++ b/http-tests/proxy/GET-proxied-external.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/GET-proxied-html-jsonld.sh b/http-tests/proxy/GET-proxied-html-jsonld.sh index 28e4fd9722..9a127b46fe 100755 --- a/http-tests/proxy/GET-proxied-html-jsonld.sh +++ b/http-tests/proxy/GET-proxied-html-jsonld.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/GET-proxied-internal-403.sh b/http-tests/proxy/GET-proxied-internal-403.sh index 865411f23d..752c3ef51f 100755 --- a/http-tests/proxy/GET-proxied-internal-403.sh +++ b/http-tests/proxy/GET-proxied-internal-403.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/GET-proxied-mapped-vocab.sh b/http-tests/proxy/GET-proxied-mapped-vocab.sh index f2250e07a0..29e025cb66 100755 --- a/http-tests/proxy/GET-proxied-mapped-vocab.sh +++ b/http-tests/proxy/GET-proxied-mapped-vocab.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/GET-proxied-rfc1918-403.sh b/http-tests/proxy/GET-proxied-rfc1918-403.sh index eb50c3e888..ddcded0060 100755 --- a/http-tests/proxy/GET-proxied-rfc1918-403.sh +++ b/http-tests/proxy/GET-proxied-rfc1918-403.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/GET-proxied.sh b/http-tests/proxy/GET-proxied.sh index 6e1a2b9982..b38546b89c 100755 --- a/http-tests/proxy/GET-proxied.sh +++ b/http-tests/proxy/GET-proxied.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/HEAD-proxied-accept.sh b/http-tests/proxy/HEAD-proxied-accept.sh index de2a1a5714..56fa126c95 100755 --- a/http-tests/proxy/HEAD-proxied-accept.sh +++ b/http-tests/proxy/HEAD-proxied-accept.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/HEAD-proxied-etag.sh b/http-tests/proxy/HEAD-proxied-etag.sh index 251a7be93e..0a1e067c2f 100755 --- a/http-tests/proxy/HEAD-proxied-etag.sh +++ b/http-tests/proxy/HEAD-proxied-etag.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the readers group to be able to read documents -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/proxy/PATCH-proxied-constructor-update.sh b/http-tests/proxy/PATCH-proxied-constructor-update.sh index 563dedc1ac..050dd27a26 100755 --- a/http-tests/proxy/PATCH-proxied-constructor-update.sh +++ b/http-tests/proxy/PATCH-proxied-constructor-update.sh @@ -41,8 +41,8 @@ curl -k -f -s -o /dev/null \ # Rebuild the in-memory ontology so the constructor hash URI enters the OntModel. # After this, the DESCRIBE check in ProxyRequestFilter will fire for the PATCH. -clear-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin clear-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" diff --git a/http-tests/proxy/PATCH-proxied-update.sh b/http-tests/proxy/PATCH-proxied-update.sh index e0d3894576..22a83b4626 100755 --- a/http-tests/proxy/PATCH-proxied-update.sh +++ b/http-tests/proxy/PATCH-proxied-update.sh @@ -9,16 +9,16 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" # create item document to PATCH -item=$(create-item.sh \ - -f "$AGENT_CERT_FILE" \ +item=$(ldh create-item \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Test Item" \ diff --git a/http-tests/proxy/POST-proxied-form.sh b/http-tests/proxy/POST-proxied-form.sh index 167bd1f72a..3a0f900dbb 100755 --- a/http-tests/proxy/POST-proxied-form.sh +++ b/http-tests/proxy/POST-proxied-form.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/proxy/POST-proxied-query.sh b/http-tests/proxy/POST-proxied-query.sh index da39e3b6bb..58b38b4083 100644 --- a/http-tests/proxy/POST-proxied-query.sh +++ b/http-tests/proxy/POST-proxied-query.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group - POST requests count as write operations -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/proxy/PUT-proxied-location.sh b/http-tests/proxy/PUT-proxied-location.sh index 6811d71834..f724d820f3 100755 --- a/http-tests/proxy/PUT-proxied-location.sh +++ b/http-tests/proxy/PUT-proxied-location.sh @@ -9,8 +9,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/root-owner.trig.template b/http-tests/root-owner.trig.template index 1b78aad03f..d16aef5ac6 100644 --- a/http-tests/root-owner.trig.template +++ b/http-tests/root-owner.trig.template @@ -55,15 +55,15 @@ foaf:member <${OWNER_URI}> . } - # TO-DO: use $OWNER_AUTH_UUID + { - a dh:Item ; - foaf:primaryTopic ; + a dh:Item ; + foaf:primaryTopic ; sioc:has_container ; dct:title "Public owner's WebID" . - a acl:Authorization ; + a acl:Authorization ; acl:accessTo <${OWNER_DOC_URI}>, ; acl:mode acl:Read ; acl:agentClass foaf:Agent, acl:AuthenticatedAgent . diff --git a/http-tests/run.sh b/http-tests/run.sh index bd69cd1805..54b294201a 100755 --- a/http-tests/run.sh +++ b/http-tests/run.sh @@ -7,11 +7,24 @@ if [ "$#" -ne 4 ]; then exit 1 fi +hash ldh 2>/dev/null || { echo >&2 "ldh not on \$PATH. Build it with 'mvn package' in cli/ and add cli/bin to \$PATH. Aborting."; exit 1; } + export OWNER_CERT_FILE="$(realpath "$1")" export OWNER_CERT_PWD="$2" export SECRETARY_CERT_FILE="$(realpath "$3")" export SECRETARY_CERT_PWD="$4" +# the platform generates a PKCS12 keystore and derives the PEM beside it: ldh reads the keystore, +# the curl assertions read the PEM + +export OWNER_CERT_KEYSTORE="$(dirname "$OWNER_CERT_FILE")/keystore.p12" +export SECRETARY_CERT_KEYSTORE="$(dirname "$SECRETARY_CERT_FILE")/keystore.p12" + +for keystore in "$OWNER_CERT_KEYSTORE" "$SECRETARY_CERT_KEYSTORE" +do + [ -f "$keystore" ] || { echo >&2 "PKCS12 keystore not found next to the certificate: $keystore. Aborting."; exit 1; } +done + export STATUS_OK=200 export STATUS_DELETE_SUCCESS='200|204' export STATUS_PATCH_SUCCESS='200|201|204' @@ -230,6 +243,7 @@ error_count=0 ### Signup test ### export AGENT_CERT_FILE=$(mktemp) +export AGENT_CERT_KEYSTORE=$(mktemp) export AGENT_CERT_PWD="changeit" start_time=$(date +%s) @@ -264,6 +278,8 @@ run_tests "misc" $(find ./misc/ -type f -name '*.sh') (( error_count += $? )) run_tests "proxy" $(find ./proxy/ -type f -name '*.sh') (( error_count += $? )) +run_tests "federation" $(find ./federation/ -type f -name '*.sh') +(( error_count += $? )) run_tests "sparql-protocol" $(find ./sparql-protocol/ -type f -name '*.sh') (( error_count += $? )) run_tests "versioning" $(find ./versioning/ -type f -name '*.sh') diff --git a/http-tests/signup.sh b/http-tests/signup.sh index cf29c805a5..d4ca5ff983 100755 --- a/http-tests/signup.sh +++ b/http-tests/signup.sh @@ -7,7 +7,6 @@ given_name="John" family_name="Doe" password="$AGENT_CERT_PWD" title="whatever" -agent_p12_cert=$(mktemp) curl -k -s -f \ -H "Content-Type: application/x-www-form-urlencoded" \ @@ -50,14 +49,13 @@ curl -k -s -f \ --data-urlencode "pu=http://xmlns.com/foaf/0.1/primaryTopic" \ --data-urlencode "ob=agent" \ "${ADMIN_BASE_URL}sign%20up?download=true" \ -> "$agent_p12_cert" +> "$AGENT_CERT_KEYSTORE" -# convert PKCS12 to PEM +# the signup download is already the PKCS12 keystore ldh reads; derive the PEM the curl +# assertions and webid-uri.sh need openssl pkcs12 \ - -in "$agent_p12_cert" \ + -in "$AGENT_CERT_KEYSTORE" \ -out "$AGENT_CERT_FILE" \ -passin pass:"$AGENT_CERT_PWD" \ - -passout pass:"$AGENT_CERT_PWD" - -rm "$agent_p12_cert" \ No newline at end of file + -passout pass:"$AGENT_CERT_PWD" \ No newline at end of file diff --git a/http-tests/sparql-protocol/query/GET-ns-constructors.sh b/http-tests/sparql-protocol/query/GET-ns-constructors.sh new file mode 100755 index 0000000000..349e1fe3a0 --- /dev/null +++ b/http-tests/sparql-protocol/query/GET-ns-constructors.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# the constructor SELECT the client-side instantiation relies on: for a type set it returns the +# spin:constructor texts of the classes and their superclasses, deduplicated + +query='SELECT DISTINCT ?constructor ?text WHERE { VALUES ?type { } ?type * ?class . ?class ?constructor . ?constructor ?text . }' + +results=$(curl -k -f -s -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/sparql-results+xml" \ + "${END_USER_BASE_URL}ns" \ + --data-urlencode "query=${query}") + +# the end-user app class's own constructor is returned +echo "$results" | grep -q "https://w3id.org/atomgraph/linkeddatahub/apps#EndUserApplicationConstructor" + +# the generic constructors attached to lapp:Application by the default ontology are returned +echo "$results" | grep -q "https://w3id.org/atomgraph/linkeddatahub#TitleConstructor" + +# the constructor texts are returned (CONSTRUCT templates the client instantiates) +count=$(echo "$results" | xmllint --xpath "count(//*[local-name() = 'binding'][@name = 'text']/*[local-name() = 'literal'][contains(., 'CONSTRUCT')])" -) +if [ "$count" -lt 3 ]; then + echo "DEBUG: Expected at least 3 constructor texts, got: $count" + exit 1 +fi diff --git a/http-tests/sparql-protocol/query/GET-ns-no-query.sh b/http-tests/sparql-protocol/query/GET-ns-no-query.sh index c2e99f7595..5cb09bd307 100755 --- a/http-tests/sparql-protocol/query/GET-ns-no-query.sh +++ b/http-tests/sparql-protocol/query/GET-ns-no-query.sh @@ -14,8 +14,8 @@ namespace="${namespace_doc}#" ontology_doc="${ADMIN_BASE_URL}ontologies/namespace/" class="${namespace}ClassThree" -add-class.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin ontologies add-class \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --uri "$class" \ @@ -24,8 +24,8 @@ add-class.sh \ # clear ontology from memory so the new class is loaded on next request -clear-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin clear-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" diff --git a/http-tests/sparql-protocol/query/GET-ns-relative-uri.sh b/http-tests/sparql-protocol/query/GET-ns-relative-uri.sh index 2ea264ba85..fa65fc40d9 100755 --- a/http-tests/sparql-protocol/query/GET-ns-relative-uri.sh +++ b/http-tests/sparql-protocol/query/GET-ns-relative-uri.sh @@ -14,8 +14,8 @@ namespace="${namespace_doc}#" ontology_doc="${ADMIN_BASE_URL}ontologies/namespace/" class="${namespace}NewClass" -add-class.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin ontologies add-class \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --uri "$class" \ @@ -24,8 +24,8 @@ add-class.sh \ # clear ontology from memory -clear-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin clear-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" diff --git a/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri-from-override.sh b/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri-from-override.sh index 1185cb7604..23eb625342 100755 --- a/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri-from-override.sh +++ b/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri-from-override.sh @@ -12,16 +12,16 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" slug_one=$(uuidgen | tr '[:upper:]' '[:lower:]') slug_two=$(uuidgen | tr '[:upper:]' '[:lower:]') -container_one=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container_one=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Graph scope one" \ --slug "$slug_one" \ --parent "$END_USER_BASE_URL") -container_two=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container_two=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Graph scope two" \ diff --git a/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri-graph-pattern.sh b/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri-graph-pattern.sh index 650fccbbe3..e831ad6b24 100755 --- a/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri-graph-pattern.sh +++ b/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri-graph-pattern.sh @@ -11,8 +11,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" slug=$(uuidgen | tr '[:upper:]' '[:lower:]') -container=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Graph scope one" \ diff --git a/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri.sh b/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri.sh index e57135812b..187d1587b6 100755 --- a/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri.sh +++ b/http-tests/sparql-protocol/query/GET-sparql-default-graph-uri.sh @@ -12,16 +12,16 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" slug_one=$(uuidgen | tr '[:upper:]' '[:lower:]') slug_two=$(uuidgen | tr '[:upper:]' '[:lower:]') -container_one=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container_one=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Graph scope one" \ --slug "$slug_one" \ --parent "$END_USER_BASE_URL") -container_two=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container_two=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Graph scope two" \ diff --git a/http-tests/sparql-protocol/query/POST-ns-relative-uri.sh b/http-tests/sparql-protocol/query/POST-ns-relative-uri.sh index a0654c8872..fc08375556 100644 --- a/http-tests/sparql-protocol/query/POST-ns-relative-uri.sh +++ b/http-tests/sparql-protocol/query/POST-ns-relative-uri.sh @@ -14,8 +14,8 @@ namespace="${namespace_doc}#" ontology_doc="${ADMIN_BASE_URL}ontologies/namespace/" class="${namespace}NewClass" -add-class.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin ontologies add-class \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --uri "$class" \ @@ -24,8 +24,8 @@ add-class.sh \ # clear ontology from memory -clear-ontology.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin clear-ontology \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" diff --git a/http-tests/sparql-protocol/query/POST-sparql-default-graph-uri.sh b/http-tests/sparql-protocol/query/POST-sparql-default-graph-uri.sh index 21f7a40bc6..7d3cecfb40 100755 --- a/http-tests/sparql-protocol/query/POST-sparql-default-graph-uri.sh +++ b/http-tests/sparql-protocol/query/POST-sparql-default-graph-uri.sh @@ -12,16 +12,16 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" slug_one=$(uuidgen | tr '[:upper:]' '[:lower:]') slug_two=$(uuidgen | tr '[:upper:]' '[:lower:]') -container_one=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container_one=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Graph scope one" \ --slug "$slug_one" \ --parent "$END_USER_BASE_URL") -container_two=$(create-container.sh \ - -f "$OWNER_CERT_FILE" \ +container_two=$(ldh create-container \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ -b "$END_USER_BASE_URL" \ --title "Graph scope two" \ diff --git a/http-tests/system/admin/POST-clear-403.sh b/http-tests/system/admin/POST-clear-403.sh index 62bcd2dc79..5e9f822c57 100755 --- a/http-tests/system/admin/POST-clear-403.sh +++ b/http-tests/system/admin/POST-clear-403.sh @@ -10,8 +10,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # POST /clear with a writer (not owner) should return 403 # /clear is only in the full-control authorization which is restricted to owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/system/admin/POST-clear-readers-403.sh b/http-tests/system/admin/POST-clear-readers-403.sh index ea10da0a2e..56a2f8ad98 100755 --- a/http-tests/system/admin/POST-clear-readers-403.sh +++ b/http-tests/system/admin/POST-clear-readers-403.sh @@ -10,8 +10,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # POST /clear with a reader should return 403 # /clear is only in the full-control authorization which is restricted to owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/system/admin/POST-packages-install-401.sh b/http-tests/system/admin/POST-packages-install-401.sh deleted file mode 100755 index 8720c139fe..0000000000 --- a/http-tests/system/admin/POST-packages-install-401.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# POST /packages/install without a certificate should return 401 -# Only owners have access to /packages/install via full-control authorization in admin.trig - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_UNAUTHORIZED" diff --git a/http-tests/system/admin/POST-packages-install-403.sh b/http-tests/system/admin/POST-packages-install-403.sh deleted file mode 100755 index f1a3eeee2c..0000000000 --- a/http-tests/system/admin/POST-packages-install-403.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# POST /packages/install with a writer (not owner) should return 403 -# /packages/install is only in the full-control authorization which is restricted to owners - -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --agent "$AGENT_URI" \ - "${ADMIN_BASE_URL}acl/groups/writers/" - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/system/admin/POST-packages-install-readers-403.sh b/http-tests/system/admin/POST-packages-install-readers-403.sh deleted file mode 100755 index 5d19c435ca..0000000000 --- a/http-tests/system/admin/POST-packages-install-readers-403.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# POST /packages/install with a reader should return 403 -# /packages/install is only in the full-control authorization which is restricted to owners - -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --agent "$AGENT_URI" \ - "${ADMIN_BASE_URL}acl/groups/readers/" - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/system/admin/POST-packages-uninstall-401.sh b/http-tests/system/admin/POST-packages-uninstall-401.sh deleted file mode 100755 index 5d12c86b0a..0000000000 --- a/http-tests/system/admin/POST-packages-uninstall-401.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# POST /packages/uninstall without a certificate should return 401 -# Only owners have access to /packages/uninstall via full-control authorization in admin.trig - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/uninstall" \ -| grep -q "$STATUS_UNAUTHORIZED" diff --git a/http-tests/system/admin/POST-packages-uninstall-403.sh b/http-tests/system/admin/POST-packages-uninstall-403.sh deleted file mode 100755 index bb49118770..0000000000 --- a/http-tests/system/admin/POST-packages-uninstall-403.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# POST /packages/uninstall with a writer (not owner) should return 403 -# /packages/uninstall is only in the full-control authorization which is restricted to owners - -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --agent "$AGENT_URI" \ - "${ADMIN_BASE_URL}acl/groups/writers/" - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/uninstall" \ -| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/system/admin/POST-packages-uninstall-readers-403.sh b/http-tests/system/admin/POST-packages-uninstall-readers-403.sh deleted file mode 100755 index a8a3933eec..0000000000 --- a/http-tests/system/admin/POST-packages-uninstall-readers-403.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" -initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" -purge_cache "$END_USER_VARNISH_SERVICE" -purge_cache "$ADMIN_VARNISH_SERVICE" -purge_cache "$FRONTEND_VARNISH_SERVICE" - -# POST /packages/uninstall with a reader should return 403 -# /packages/uninstall is only in the full-control authorization which is restricted to owners - -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --agent "$AGENT_URI" \ - "${ADMIN_BASE_URL}acl/groups/readers/" - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/uninstall" \ -| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/system/end-user/GET-settings-403.sh b/http-tests/system/end-user/GET-settings-403.sh index 90ca5ce5cc..a05ff98159 100755 --- a/http-tests/system/end-user/GET-settings-403.sh +++ b/http-tests/system/end-user/GET-settings-403.sh @@ -10,8 +10,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # GET /settings with a writer (not owner) should return 403 # /settings is only in the full-control authorization which is restricted to owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/system/end-user/GET-settings-readers-403.sh b/http-tests/system/end-user/GET-settings-readers-403.sh index 0e59b70851..a7361daad0 100755 --- a/http-tests/system/end-user/GET-settings-readers-403.sh +++ b/http-tests/system/end-user/GET-settings-readers-403.sh @@ -10,8 +10,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # GET /settings with a reader should return 403 # /settings is only in the full-control authorization which is restricted to owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/system/end-user/PATCH-settings-403.sh b/http-tests/system/end-user/PATCH-settings-403.sh index 4288b87818..199d83e789 100755 --- a/http-tests/system/end-user/PATCH-settings-403.sh +++ b/http-tests/system/end-user/PATCH-settings-403.sh @@ -10,8 +10,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # PATCH /settings with a writer (not owner) should return 403 # /settings is only in the full-control authorization which is restricted to owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" diff --git a/http-tests/system/end-user/PATCH-settings-readers-403.sh b/http-tests/system/end-user/PATCH-settings-readers-403.sh index cf49063d91..13fda096be 100755 --- a/http-tests/system/end-user/PATCH-settings-readers-403.sh +++ b/http-tests/system/end-user/PATCH-settings-readers-403.sh @@ -10,8 +10,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # PATCH /settings with a reader should return 403 # /settings is only in the full-control authorization which is restricted to owners -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/readers/" diff --git a/http-tests/versioning/DELETE-removes-file.sh b/http-tests/versioning/DELETE-removes-file.sh index 345ec5df8a..847e1254e8 100755 --- a/http-tests/versioning/DELETE-removes-file.sh +++ b/http-tests/versioning/DELETE-removes-file.sh @@ -17,8 +17,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -31,29 +31,29 @@ path="${VERSIONING_PATH_PREFIX:-graphs}/${slug}.nt" echo "<${doc_url}> . <${doc_url}> \"To be deleted\" ." | \ - put.sh \ - -f "$AGENT_CERT_FILE" \ + ldh put \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -t "application/n-triples" \ "$doc_url" for i in $(seq 1 30); do - if gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=main" > /dev/null 2>&1; then + if gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=${VERSIONING_TEST_BRANCH:-main}" > /dev/null 2>&1; then break fi sleep 1 done -gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=main" > /dev/null +gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=${VERSIONING_TEST_BRANCH:-main}" > /dev/null # delete the document and check that the file disappears -delete.sh \ - -f "$AGENT_CERT_FILE" \ +ldh delete \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ "$doc_url" for i in $(seq 1 30); do - if ! gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=main" > /dev/null 2>&1; then + if ! gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=${VERSIONING_TEST_BRANCH:-main}" > /dev/null 2>&1; then exit 0 fi sleep 1 diff --git a/http-tests/versioning/GET-timegate.sh b/http-tests/versioning/GET-timegate.sh new file mode 100755 index 0000000000..cba201d672 --- /dev/null +++ b/http-tests/versioning/GET-timegate.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +# requires a dataspace configured with lapp:versioningRepository (branch "main", path prefix "graphs") +# pointing at $VERSIONING_TEST_REPO ("owner/repo"), with the token in secrets/credentials.trig + +if [ -z "${VERSIONING_TEST_REPO:-}" ] || [ -z "${GITHUB_TOKEN:-}" ] || ! command -v gh > /dev/null; then + echo "SKIPPED: VERSIONING_TEST_REPO/GITHUB_TOKEN not set or gh CLI not available" + exit 0 +fi + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# add agent to the writers group + +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ + -p "$OWNER_CERT_PWD" \ + --agent "$AGENT_URI" \ + "${ADMIN_BASE_URL}acl/groups/writers/" + +slug=$(uuidgen | tr '[:upper:]' '[:lower:]') +doc_url="${END_USER_BASE_URL}${slug}/" +path="${VERSIONING_PATH_PREFIX:-graphs}/${slug}.nt" + +put_document() +{ + echo "<${doc_url}> . +<${doc_url}> \"${1}\" ." | \ + ldh put \ + -f "$AGENT_CERT_KEYSTORE" \ + -p "$AGENT_CERT_PWD" \ + -t "application/n-triples" \ + "$doc_url" +} + +head_sha() +{ + gh api "repos/${VERSIONING_TEST_REPO}/commits?path=${path}&sha=${VERSIONING_TEST_BRANCH:-main}&per_page=1" --jq '.[0].sha' 2> /dev/null || true +} + +# create two versions + +put_document "First version" + +for i in $(seq 1 30); do + sha1=$(head_sha) + if [ -n "$sha1" ]; then break; fi + sleep 1 +done +[ -n "$sha1" ] + +first_datetime=$(gh api "repos/${VERSIONING_TEST_REPO}/commits/${sha1}" --jq '.commit.author.date') + +put_document "Second version" + +for i in $(seq 1 30); do + sha2=$(head_sha) + if [ -n "$sha2" ] && [ "$sha2" != "$sha1" ]; then break; fi + sleep 1 +done +[ "$sha2" != "$sha1" ] + +# the document advertises its TimeGate (RFC 7089 4.1.1) + +response_headers=$( +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ + -p "$AGENT_CERT_PWD" \ + --accept 'application/n-triples' \ + --head \ + "$doc_url" \ +| tr -d '\r') + +echo "DEBUG: Original Resource headers:" +echo "$response_headers" + +echo "$response_headers" | grep -q "<${doc_url}?timegate>; rel=timegate" + +# without Accept-Datetime the TimeGate selects the most recent Memento + +timegate_headers=$( +curl -k -s -D - -o /dev/null \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + "${doc_url}?timegate" \ +| tr -d '\r') + +echo "DEBUG: TimeGate headers (no Accept-Datetime):" +echo "$timegate_headers" + +echo "$timegate_headers" | grep -q '^HTTP/.* 302' +echo "$timegate_headers" | grep -qi "^Location: ${doc_url}?version=${sha2}" +echo "$timegate_headers" | grep -qi '^Vary:.*accept-datetime' +echo "$timegate_headers" | grep -q "<${doc_url}>; rel=original" +# the redirect must not be cached: it would outlive the commit that made it the most recent +echo "$timegate_headers" | grep -qi '^Cache-Control:.*no-store' + +# a 302 TimeGate response must not carry Memento-Datetime + +if echo "$timegate_headers" | grep -qi '^Memento-Datetime:'; then + echo "DEBUG: TimeGate 302 response must not carry Memento-Datetime" + exit 1 +fi + +# with Accept-Datetime at the first commit's time, the TimeGate selects the first Memento + +accept_datetime=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$first_datetime" "+%a, %d %b %Y %H:%M:%S GMT" 2> /dev/null \ + || date -u -d "$first_datetime" "+%a, %d %b %Y %H:%M:%S GMT") + +echo "DEBUG: Accept-Datetime: $accept_datetime (commit $sha1 at $first_datetime)" + +dated_headers=$( +curl -k -s -D - -o /dev/null \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + -H "Accept-Datetime: ${accept_datetime}" \ + "${doc_url}?timegate" \ +| tr -d '\r') + +echo "DEBUG: TimeGate headers (Accept-Datetime at first commit):" +echo "$dated_headers" + +echo "$dated_headers" | grep -qi "^Location: ${doc_url}?version=${sha1}" + +# a malformed Accept-Datetime is rejected + +status=$( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + -H "Accept-Datetime: yesterday afternoon" \ + "${doc_url}?timegate") + +echo "DEBUG: malformed Accept-Datetime status: $status (expected 400)" +[ "$status" = "400" ] + +# the TimeGate is read-only + +status=$( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + -X PUT \ + -H "Content-Type: application/n-triples" \ + --data-binary "<${doc_url}> \"Overwrite attempt\" ." \ + "${doc_url}?timegate") + +echo "DEBUG: PUT to TimeGate status: $status (expected 405)" +[ "$status" = "405" ] diff --git a/http-tests/versioning/GET-timemap.sh b/http-tests/versioning/GET-timemap.sh index 60501d03c6..543f2cc85d 100755 --- a/http-tests/versioning/GET-timemap.sh +++ b/http-tests/versioning/GET-timemap.sh @@ -17,8 +17,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -31,25 +31,25 @@ path="${VERSIONING_PATH_PREFIX:-graphs}/${slug}.nt" echo "<${doc_url}> . <${doc_url}> \"TimeMap test\" ." | \ - put.sh \ - -f "$AGENT_CERT_FILE" \ + ldh put \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -t "application/n-triples" \ "$doc_url" for i in $(seq 1 30); do - if gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=main" > /dev/null 2>&1; then + if gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=${VERSIONING_TEST_BRANCH:-main}" > /dev/null 2>&1; then break fi sleep 1 done -gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=main" > /dev/null +gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=${VERSIONING_TEST_BRANCH:-main}" > /dev/null # check that the document advertises its TimeMap via the Link header response_headers=$( -get.sh \ - -f "$AGENT_CERT_FILE" \ +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ --head \ @@ -59,13 +59,21 @@ get.sh \ echo "DEBUG: Response headers:" echo "$response_headers" -echo "$response_headers" | grep -q "mementoweb.org/ns#timemap" +# RFC 7089: the Original Resource advertises rel=timemap with the link-format media type, +# and MUST NOT carry rel=original +echo "$response_headers" | grep -q 'rel=timemap' +echo "$response_headers" | grep -q 'type="application/link-format"' -# retrieve the TimeMap and check the memento entries +if echo "$response_headers" | grep -q 'rel=original'; then + echo "DEBUG: Original Resource must not carry rel=original" + exit 1 +fi + +# retrieve the TimeMap as RDF and check the PROV description timemap=$( -get.sh \ - -f "$AGENT_CERT_FILE" \ +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "${doc_url}?timemap") @@ -73,8 +81,38 @@ get.sh \ echo "DEBUG: TimeMap:" echo "$timemap" -echo "$timemap" | grep -q "mementoweb.org/ns#TimeMap" -echo "$timemap" | grep -q "mementoweb.org/ns#Memento" +echo "$timemap" | grep -q "ns/prov#Collection" +echo "$timemap" | grep -q "ns/prov#hadMember" +echo "$timemap" | grep -q "ns/prov#specializationOf" +echo "$timemap" | grep -q "ns/prov#generatedAtTime" echo "$timemap" | grep -q "${doc_url}?version=" -echo "$timemap" | grep -q "mementoweb.org/ns#mementoDatetime" echo "$timemap" | grep -q "$AGENT_URI" + +# the same TimeMap in the serialization RFC 7089 requires + +link_format=$( +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ + -p "$AGENT_CERT_PWD" \ + --accept 'application/link-format' \ + "${doc_url}?timemap") + +echo "DEBUG: link-format TimeMap:" +echo "$link_format" + +echo "$link_format" | grep -q "<${doc_url}>;rel=\"original\"" +echo "$link_format" | grep -q "<${doc_url}?timemap>;rel=\"self\";type=\"application/link-format\"" +echo "$link_format" | grep -q "<${doc_url}?timegate>;rel=\"timegate\"" +echo "$link_format" | grep -q 'rel="first' # first memento, or "first last memento" on a single-version document +echo "$link_format" | grep -q 'datetime="' + +# link-format is scoped to the TimeMap: an ordinary document does not offer it + +status=$( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + -H "Accept: application/link-format" \ + "$doc_url") + +echo "DEBUG: link-format status on the document itself: $status (expected 406)" +[ "$status" = "406" ] diff --git a/http-tests/versioning/GET-version.sh b/http-tests/versioning/GET-version.sh index a497afbc1e..c4ad815997 100755 --- a/http-tests/versioning/GET-version.sh +++ b/http-tests/versioning/GET-version.sh @@ -17,8 +17,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -31,8 +31,8 @@ put_document() { echo "<${doc_url}> . <${doc_url}> \"${1}\" ." | \ - put.sh \ - -f "$AGENT_CERT_FILE" \ + ldh put \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -t "application/n-triples" \ "$doc_url" @@ -40,7 +40,7 @@ put_document() head_sha() { - gh api "repos/${VERSIONING_TEST_REPO}/commits?path=${path}&per_page=1" --jq '.[0].sha' 2> /dev/null || true + gh api "repos/${VERSIONING_TEST_REPO}/commits?path=${path}&sha=${VERSIONING_TEST_BRANCH:-main}&per_page=1" --jq '.[0].sha' 2> /dev/null || true } # create the first version and wait for its commit @@ -68,8 +68,8 @@ done # retrieve the first version and check its content response_body=$( -get.sh \ - -f "$AGENT_CERT_FILE" \ +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ "${doc_url}?version=${sha1}") @@ -87,8 +87,8 @@ fi # check the Memento-Datetime and immutable caching headers response_headers=$( -get.sh \ - -f "$AGENT_CERT_FILE" \ +ldh get \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ --accept 'application/n-triples' \ --head \ @@ -101,6 +101,13 @@ echo "$response_headers" echo "$response_headers" | grep -qi '^Memento-Datetime:' echo "$response_headers" | grep -qi '^Cache-Control:.*immutable' +# RFC 7089: the Memento-Datetime value is RFC 1123 with a zero-padded day of month, +# and a Memento MUST link to its Original Resource + +echo "$response_headers" | grep -qiE '^Memento-Datetime: [A-Z][a-z]{2}, [0-9]{2} [A-Z][a-z]{2} [0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2} GMT' +echo "$response_headers" | grep -q "<${doc_url}>; rel=original" +echo "$response_headers" | grep -q 'rel=timemap' + # a historical version is read-only: acl:Read advertised but no write modes, writes rejected with 405 echo "$response_headers" | grep -q 'acl#Read' diff --git a/http-tests/versioning/PUT-creates-commit.sh b/http-tests/versioning/PUT-creates-commit.sh index 40f5f51b40..ce3693f20c 100755 --- a/http-tests/versioning/PUT-creates-commit.sh +++ b/http-tests/versioning/PUT-creates-commit.sh @@ -17,8 +17,8 @@ purge_cache "$FRONTEND_VARNISH_SERVICE" # add agent to the writers group -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ +ldh admin acl add-agent-to-group \ + -f "$OWNER_CERT_KEYSTORE" \ -p "$OWNER_CERT_PWD" \ --agent "$AGENT_URI" \ "${ADMIN_BASE_URL}acl/groups/writers/" @@ -30,8 +30,8 @@ doc_url="${END_USER_BASE_URL}${slug}/" echo "<${doc_url}> . <${doc_url}> \"Versioned document\" ." | \ - put.sh \ - -f "$AGENT_CERT_FILE" \ + ldh put \ + -f "$AGENT_CERT_KEYSTORE" \ -p "$AGENT_CERT_PWD" \ -t "application/n-triples" \ "$doc_url" @@ -41,16 +41,16 @@ echo "<${doc_url}> /dev/null 2>&1; then + if gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=${VERSIONING_TEST_BRANCH:-main}" > /dev/null 2>&1; then break fi sleep 1 done -gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=main" > /dev/null +gh api "repos/${VERSIONING_TEST_REPO}/contents/${path}?ref=${VERSIONING_TEST_BRANCH:-main}" > /dev/null # check that the commit author is the agent's WebID -author=$(gh api "repos/${VERSIONING_TEST_REPO}/commits?path=${path}&per_page=1" --jq '.[0].commit.author.name') +author=$(gh api "repos/${VERSIONING_TEST_REPO}/commits?path=${path}&sha=${VERSIONING_TEST_BRANCH:-main}&per_page=1" --jq '.[0].commit.author.name') echo "DEBUG: Expected author: $AGENT_URI" echo "DEBUG: Got author: $author" [ "$author" = "$AGENT_URI" ] diff --git a/platform/datasets/admin.trig b/platform/datasets/admin.trig index 4c0fbf4b89..45511f7b7d 100644 --- a/platform/datasets/admin.trig +++ b/platform/datasets/admin.trig @@ -332,7 +332,7 @@ WHERE rdfs:label "Full control" ; rdfs:comment "Allows full read/write access to all application resources" ; acl:accessToClass dh:Item, dh:Container, def:Root ; - acl:accessTo , , ; + acl:accessTo ; acl:mode acl:Read, acl:Append, acl:Write, acl:Control ; acl:agentGroup . @@ -388,42 +388,6 @@ WHERE } -### PACKAGES ### - -# ENDPOINTS - - -{ - - a foaf:Document ; - dct:title "Install package endpoint" . - -} - - -{ - - a foaf:Document ; - dct:title "Uninstall package endpoint" . - -} - -# CONTAINERS - - -{ - - a dh:Container ; - sioc:has_parent <> ; - dct:title "Packages" ; - dct:description "Manage installed packages" ; - rdf:_1 . - - a ldh:Object ; - rdf:value ldh:ChildrenView . - -} - ### ONTOLOGIES ### # CONTAINERS diff --git a/platform/entrypoint.sh b/platform/entrypoint.sh index 73fb2e9f9a..a120882145 100755 --- a/platform/entrypoint.sh +++ b/platform/entrypoint.sh @@ -339,6 +339,55 @@ wait_for_url() fi } +# Adds install metadata to every document that does not already carry it, so fixtures look like documents +# created through put(). One request rather than a query per document: the check lives in the WHERE clause. +# The dataset load appends rather than replaces, so without the check a document would collect one +# dct:created per container recreate. Creator and owner are guarded by the same condition rather than +# inserted unconditionally: $OWNER_URI is whoever the install owner is now, and re-asserting it would +# both accumulate a value each time that WebID changes (regenerated certs) and override ownership that +# has legitimately moved to another agent. Documents that have dct:created always have the other two. +insert_document_metadata() +{ + local endpoint_url="$1" + local auth_user="$2" + local auth_pwd="$3" + local auth_token="$4" + local owner_uri="$5" + + local now + now=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") + + # in the LDH data model every named graph IS a document, so every graph is a candidate + local update="PREFIX dct: +PREFIX acl: +PREFIX xsd: + +INSERT +{ + GRAPH ?g + { + ?g dct:created \"${now}\"^^xsd:dateTime ; + dct:creator <${owner_uri}> ; + acl:owner <${owner_uri}> . + } +} +WHERE +{ + { SELECT DISTINCT ?g WHERE { GRAPH ?g { ?s ?p ?o } } } + FILTER NOT EXISTS { GRAPH ?g { ?g dct:created ?created } } +}" + + printf "\n### Adding dct:created=%s, dct:creator=acl:owner=<%s> to documents that lack it\n" "$now" "$owner_uri" + + if [ -n "$auth_token" ]; then + curl -k -s -f -X POST "$endpoint_url" -H "Authorization: Bearer $auth_token" -H "Content-Type: application/sparql-update" --data-binary "$update" > /dev/null + elif [ -n "$auth_user" ] && [ -n "$auth_pwd" ]; then + curl -k -s -f -X POST "$endpoint_url" --user "$auth_user":"$auth_pwd" -H "Content-Type: application/sparql-update" --data-binary "$update" > /dev/null + else + curl -k -s -f -X POST "$endpoint_url" -H "Content-Type: application/sparql-update" --data-binary "$update" > /dev/null + fi +} + # function to append quad data to an RDF graph store append_quads() @@ -449,36 +498,6 @@ EOF done <<< "$graph_uris" } -# Mirrors what DocumentHierarchyGraphStoreImpl.put() sets on newly-created documents, so install fixtures -# carry the same dct:created/dct:creator/acl:owner metadata that user-created docs get from put(). -# In the LDH data model every named graph IS a document, so we enrich each distinct graph URI. -enrich_document_metadata() -{ - local nq_file="$1" - local owner_uri="$2" - - [ -f "$nq_file" ] || return 0 - - local now - now=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") - - local graphs - graphs=$(awk '{print $(NF-1)}' "$nq_file" | sort -u) - local graph_count - graph_count=$(printf '%s\n' "$graphs" | grep -c '^.') - - printf "\n### Enriching %s document(s) in %s with dct:created=%s, dct:creator=acl:owner=<%s>\n" "$graph_count" "$nq_file" "$now" "$owner_uri" - - printf '%s\n' "$graphs" | while IFS= read -r g; do - [ -z "$g" ] && continue - cat >> "$nq_file" < "${now}"^^ $g . -$g <${owner_uri}> $g . -$g <${owner_uri}> $g . -EOQ - done -} - generate_cert() { local alias="$1" @@ -653,10 +672,6 @@ SECRETARY_KEY_URI="${SECRETARY_KEY_DOC_URI}#this" printf "\n### Owner's WebID URI: %s\n" "$OWNER_URI" printf "\n### Secretary's WebID URI: %s\n" "$SECRETARY_URI" -# Enrich the owner and secretary nq datasets in-place with dct:created/creator + acl:owner -enrich_document_metadata /var/linkeddatahub/based-datasets/root-owner.nq "$OWNER_URI" -enrich_document_metadata /var/linkeddatahub/based-datasets/root-secretary.nq "$OWNER_URI" - # Note: LOAD_DATASETS check is now done per-app inside the loop # base the $CONTEXT_DATASET @@ -836,7 +851,6 @@ for app in "${apps[@]}"; do esac trig --base="${app_origin}/" "$END_USER_DATASET" > "/var/linkeddatahub/based-datasets/${app_folder}/end-user.nq" - enrich_document_metadata "/var/linkeddatahub/based-datasets/${app_folder}/end-user.nq" "$OWNER_URI" printf "\n### Waiting for %s...\n" "$app_store_url" wait_for_url "$app_store_url" "$app_service_auth_user" "$app_service_auth_pwd" "$TIMEOUT" "$app_store_content_type" "$app_service_auth_token" @@ -860,7 +874,6 @@ for app in "${apps[@]}"; do esac trig --base="${app_origin}/" "$ADMIN_DATASET" > "/var/linkeddatahub/based-datasets/${app_folder}/admin.nq" - enrich_document_metadata "/var/linkeddatahub/based-datasets/${app_folder}/admin.nq" "$OWNER_URI" printf "\n### Waiting for %s...\n" "$app_store_url" wait_for_url "$app_store_url" "$app_service_auth_user" "$app_service_auth_pwd" "$TIMEOUT" "$app_store_content_type" "$app_service_auth_token" @@ -877,7 +890,6 @@ for app in "${apps[@]}"; do envsubst < namespace-ontology.trig.template > "$namespace_ontology_dataset_path" trig --base="${app_origin}/" --output=nq "$namespace_ontology_dataset_path" > "/var/linkeddatahub/based-datasets/${app_folder}/namespace-ontology.nq" - enrich_document_metadata "/var/linkeddatahub/based-datasets/${app_folder}/namespace-ontology.nq" "$OWNER_URI" printf "\n### Loading namespace ontology into the admin triplestore...\n" "$app_store_fn" "$app_store_url" "$app_service_auth_user" "$app_service_auth_pwd" "/var/linkeddatahub/based-datasets/${app_folder}/namespace-ontology.nq" "$app_store_content_type" "$app_service_auth_token" @@ -896,15 +908,15 @@ for app in "${apps[@]}"; do owner_auth_dataset_path="/var/linkeddatahub/datasets/${app_folder}/owner-authorization.trig" mkdir -p "$(dirname "$owner_auth_dataset_path")" - OWNER_AUTH_UUID=$(uuidgen | tr '[:upper:]' '[:lower:]') - OWNER_AUTH_DOC_URI="${app_origin}/acl/authorizations/${OWNER_AUTH_UUID}/" + # a stable slug, like every authorization bundled in admin.trig: a fresh UUID per run made this + # document new every time, so each container recreate left another copy of the same grant behind + OWNER_AUTH_DOC_URI="${app_origin}/acl/authorizations/owner-webid/" OWNER_AUTH_URI="${OWNER_AUTH_DOC_URI}#auth" export OWNER_URI OWNER_DOC_URI OWNER_KEY_DOC_URI OWNER_AUTH_DOC_URI OWNER_AUTH_URI envsubst < root-owner-authorization.trig.template > "$owner_auth_dataset_path" trig --base="${app_origin}/" --output=nq "$owner_auth_dataset_path" > "/var/linkeddatahub/based-datasets/${app_folder}/owner-authorization.nq" - enrich_document_metadata "/var/linkeddatahub/based-datasets/${app_folder}/owner-authorization.nq" "$OWNER_URI" printf "\n### Uploading owner authorizations for this app...\n\n" "$app_store_fn" "$app_store_url" "$app_service_auth_user" "$app_service_auth_pwd" "/var/linkeddatahub/based-datasets/${app_folder}/owner-authorization.nq" "$app_store_content_type" "$app_service_auth_token" @@ -912,20 +924,21 @@ for app in "${apps[@]}"; do secretary_auth_dataset_path="/var/linkeddatahub/datasets/${app_folder}/secretary-authorization.trig" mkdir -p "$(dirname "$secretary_auth_dataset_path")" - SECRETARY_AUTH_UUID=$(uuidgen | tr '[:upper:]' '[:lower:]') - SECRETARY_AUTH_DOC_URI="${app_origin}/acl/authorizations/${SECRETARY_AUTH_UUID}/" + SECRETARY_AUTH_DOC_URI="${app_origin}/acl/authorizations/secretary-webid/" SECRETARY_AUTH_URI="${SECRETARY_AUTH_DOC_URI}#auth" export SECRETARY_URI SECRETARY_DOC_URI SECRETARY_KEY_DOC_URI SECRETARY_AUTH_DOC_URI SECRETARY_AUTH_URI envsubst < root-secretary-authorization.trig.template > "$secretary_auth_dataset_path" trig --base="${app_origin}/" --output=nq "$secretary_auth_dataset_path" > "/var/linkeddatahub/based-datasets/${app_folder}/secretary-authorization.nq" - enrich_document_metadata "/var/linkeddatahub/based-datasets/${app_folder}/secretary-authorization.nq" "$OWNER_URI" printf "\n### Uploading secretary authorizations for this app...\n\n" "$app_store_fn" "$app_store_url" "$app_service_auth_user" "$app_service_auth_pwd" "/var/linkeddatahub/based-datasets/${app_folder}/secretary-authorization.nq" "$app_store_content_type" "$app_service_auth_token" fi + + # after the uploads, so it covers every document this app just loaded, whichever branch loaded it + insert_document_metadata "$app_endpoint_url" "$app_service_auth_user" "$app_service_auth_pwd" "$app_service_auth_token" "$OWNER_URI" fi done diff --git a/pom.xml b/pom.xml index d98a1c83e9..372c0ac13a 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ com.atomgraph linkeddatahub - 5.9.1 + 5.9.2-SNAPSHOT ${packaging.type} AtomGraph LinkedDataHub @@ -46,7 +46,7 @@ https://github.com/AtomGraph/LinkedDataHub scm:git:git://github.com/AtomGraph/LinkedDataHub.git scm:git:git@github.com:AtomGraph/LinkedDataHub.git - linkeddatahub-5.9.1 + linkeddatahub-5.5.4 diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java index 1dcfe40950..03a1f401f5 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java @@ -17,7 +17,7 @@ package com.atomgraph.linkeddatahub; import com.atomgraph.client.util.jena.PrefixGraphRepository; -import com.atomgraph.client.util.StylesheetResolver; +import com.atomgraph.linkeddatahub.server.util.LocalStylesheetResolver; import com.atomgraph.linkeddatahub.writer.impl.SameSiteSourceResolver; import com.atomgraph.linkeddatahub.server.util.OntologyRepository; import org.apache.jena.riot.RDFParser; @@ -75,6 +75,7 @@ import com.atomgraph.linkeddatahub.io.SchemaOrgDocumentLoader; import com.atomgraph.linkeddatahub.listener.EMailListener; import com.atomgraph.linkeddatahub.writer.ModelXSLTWriter; +import com.atomgraph.linkeddatahub.writer.TimeMapWriter; import com.atomgraph.linkeddatahub.model.Import; import com.atomgraph.linkeddatahub.model.RDFImport; import com.atomgraph.linkeddatahub.model.UserAccount; @@ -134,6 +135,7 @@ import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; import org.apache.jena.enhanced.BuiltinPersonalities; +import org.apache.jena.ontology.ConversionException; import org.apache.jena.riot.RDFParserRegistry; import org.slf4j.Logger; import java.net.URI; @@ -182,6 +184,7 @@ import java.util.Arrays; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.Optional; import java.util.TreeMap; import java.util.concurrent.ExecutorService; @@ -257,12 +260,6 @@ public class Application extends ResourceConfig private static final Logger log = LoggerFactory.getLogger(Application.class); - /** - * Path to the master XSLT stylesheet for server-side transformations. - * Package stylesheets are imported into this master stylesheet. - */ - public static final String MASTER_STYLESHEET_PATH = "/static/xsl/layout.xsl"; - private final ExecutorService importThreadPool; private final ServletConfig servletConfig; private final EventBus eventBus = new EventBus(); @@ -860,7 +857,7 @@ protected PasswordAuthentication getPasswordAuthentication() xsltComp = xsltProc.newXsltCompiler(); xsltComp.setParameter(new QName("ldh", LDH.base.getNameSpace(), LDH.base.getLocalName()), new XdmAtomicValue(baseURI)); - xsltComp.setURIResolver(new StylesheetResolver(client)); // resolves xsl:import to raw stylesheet sources + xsltComp.setURIResolver(new LocalStylesheetResolver(this, servletConfig.getServletContext(), client)); // resolves xsl:import to raw stylesheet sources, app-origin /static/ URLs locally xsltExec = xsltComp.compile(stylesheet); } catch (FileNotFoundException ex) @@ -929,6 +926,7 @@ public void init() register(new UpdateRequestProvider()); register(new ModelXSLTWriter(getXsltExecutable(), getResolver(), getMessageDigest())); // writes (X)HTML responses register(new ResultSetXSLTWriter(getXsltExecutable(), getResolver(), getMessageDigest())); // writes (X)HTML responses + register(new TimeMapWriter()); // writes link-format TimeMap responses final com.atomgraph.linkeddatahub.Application system = this; register(new AbstractBinder() @@ -1107,7 +1105,6 @@ protected void registerContainerResponseFilters() register(new XsltExecutableFilter()); if (isInvalidateCache()) register(new CacheInvalidationFilter()); register(new VersioningFilter()); -// register(new ProvenanceFilter()); } /** @@ -1930,6 +1927,69 @@ public Map getOntologyGraphs() return ontologyGraphs; } + /** + * Loads the package description from its URI. + * Mapped locations (e.g. bundled package descriptions) and cached graphs are read from the graph + * repository; other URIs are dereferenced over HTTP. + * + * @param packageURI package URI + * @return package resource, or null if the description could not be resolved + */ + public com.atomgraph.linkeddatahub.apps.model.Package getPackage(String packageURI) + { + final Model model; + + if (getRepository().isCached(packageURI) || getRepository().isMapped(packageURI)) + model = ModelFactory.createModelForGraph(getRepository().get(packageURI)); + else + { + try + { + // validate package URI to prevent SSRF attacks + getURLValidator().validate(URI.create(packageURI)); + + model = GraphStoreClient.create(getClient(), getMediaTypes()).getModel(packageURI); + } + catch (RuntimeException ex) // invalid URI, 404 from the package server, connection refused, timeout... + { + if (log.isErrorEnabled()) log.error("Loading package description failed: {}", packageURI, ex); + return null; + } + } + + try + { + return model.getResource(packageURI).as(com.atomgraph.linkeddatahub.apps.model.Package.class); + } + catch (ConversionException ex) + { + if (log.isErrorEnabled()) log.error("Resource <{}> cannot be converted to a Package", packageURI, ex); + return null; + } + } + + /** + * Resolves the descriptions of the packages imported by the application and returns their + * ontology URIs, ordered by package URI. Packages whose description cannot be resolved, or + * without an ontology (stylesheet-only), are skipped. + * + * @param app application resource + * @return list of package ontology URIs + */ + public List getPackageOntologies(com.atomgraph.linkeddatahub.apps.model.Application app) + { + return app.getImportedPackages().stream(). + filter(Resource::isURIResource). + map(Resource::getURI). + sorted(). + map(this::getPackage). + filter(Objects::nonNull). + map(com.atomgraph.linkeddatahub.apps.model.Package::getOntology). + filter(Objects::nonNull). + map(ontology -> URI.create(ontology.getURI())). + collect(Collectors.toList()); + } + /** * Returns a registry of readable and writeable media types. * diff --git a/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java b/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java index 1d2d02f9b8..4ad8b71e50 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java +++ b/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java @@ -51,14 +51,4 @@ public interface Package extends Resource */ java.util.Set getImportedPackages(); - /** - * Returns the filesystem resource path for this package. - * Converts the package URI to a path by reversing hostname components. - * Example: https://packages.linkeddatahub.com/skos/#this -> com/linkeddatahub/packages/skos - * - * @return filesystem path relative to static directory - * @throws IllegalArgumentException if package URI is invalid - */ - String getStylesheetPath(); - } diff --git a/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImpl.java b/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImpl.java index 311a98a78c..a431ee2628 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImpl.java +++ b/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImpl.java @@ -27,7 +27,6 @@ import org.apache.jena.rdf.model.StmtIterator; import org.apache.jena.rdf.model.impl.ResourceImpl; -import java.net.URI; import java.util.HashSet; import java.util.Set; @@ -86,44 +85,4 @@ public Set getImportedPackages() return packages; } - @Override - public String getStylesheetPath() - { - String uri = getURI(); - if (uri == null) - throw new IllegalArgumentException("Package URI cannot be null"); - - try - { - URI uriObj = URI.create(uri); - String host = uriObj.getHost(); - String path = uriObj.getPath(); - - if (host == null) - throw new IllegalArgumentException("Package URI must have a host: " + uri); - - // Reverse hostname components: packages.linkeddatahub.com -> com/linkeddatahub/packages - String[] hostParts = host.split("\\."); - StringBuilder reversedHost = new StringBuilder(); - for (int i = hostParts.length - 1; i >= 0; i--) - { - reversedHost.append(hostParts[i]); - if (i > 0) reversedHost.append("/"); - } - - // Append path without leading/trailing slashes and fragment - if (path != null && !path.isEmpty() && !path.equals("/")) - { - String cleanPath = path.replaceAll("^/+|/+$", ""); // Remove leading/trailing slashes - return reversedHost + "/" + cleanPath; - } - - return reversedHost.toString(); - } - catch (IllegalArgumentException e) - { - throw new IllegalArgumentException("Invalid package URI: " + uri, e); - } - } - } diff --git a/src/main/java/com/atomgraph/linkeddatahub/client/GitHubClient.java b/src/main/java/com/atomgraph/linkeddatahub/client/GitHubClient.java index a6f85126de..0883195cd5 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/client/GitHubClient.java +++ b/src/main/java/com/atomgraph/linkeddatahub/client/GitHubClient.java @@ -61,6 +61,10 @@ public class GitHubClient public static final String AUTHOR_EMAIL = "noreply@linkeddatahub.invalid"; /** Maximum retries on rate-limited requests */ public static final int MAX_RETRIES = 2; + /** Commits requested per page when walking a file's history */ + public static final int COMMITS_PER_PAGE = 100; + /** Maximum history pages retrieved, bounding the API calls a single TimeMap request can make */ + public static final int MAX_COMMIT_PAGES = 10; private final WebTarget endpoint; private final String authorization; @@ -184,32 +188,48 @@ public record CommitInfo(String sha, Instant datetime, String authorName) { } /** * Lists commits that touched a file on the branch, most recent first. + * Pages through the history so the result is the file's complete history, up to + * {@link #MAX_COMMIT_PAGES} pages. * * @param path file path within the repository - * @return commit list (up to 100), empty if the file has no history + * @return commit list, empty if the file has no history */ public List listCommits(String path) { - try (Response response = invoke(() -> endpoint.path("repos/{owner}/{repo}/commits"). - queryParam("path", path). - queryParam("sha", branch). - queryParam("per_page", 100). - resolveTemplate("owner", owner).resolveTemplate("repo", repo). - request(GITHUB_JSON). - header(HttpHeaders.AUTHORIZATION, authorization). - buildGet())) - { - if (response.getStatus() != Response.Status.OK.getStatusCode()) return List.of(); + List commits = new ArrayList<>(); - List commits = new ArrayList<>(); - for (JsonValue value : response.readEntity(JsonArray.class)) + for (int page = 1; page <= MAX_COMMIT_PAGES; page++) + { + final int currentPage = page; + try (Response response = invoke(() -> endpoint.path("repos/{owner}/{repo}/commits"). + queryParam("path", path). + queryParam("sha", branch). + queryParam("per_page", COMMITS_PER_PAGE). + queryParam("page", currentPage). + resolveTemplate("owner", owner).resolveTemplate("repo", repo). + request(GITHUB_JSON). + header(HttpHeaders.AUTHORIZATION, authorization). + buildGet())) { - JsonObject commit = value.asJsonObject(); - JsonObject author = commit.getJsonObject("commit").getJsonObject("author"); - commits.add(new CommitInfo(commit.getString("sha"), Instant.parse(author.getString("date")), author.getString("name"))); + if (response.getStatus() != Response.Status.OK.getStatusCode()) return commits; + + JsonArray array = response.readEntity(JsonArray.class); + for (JsonValue value : array) + { + JsonObject commit = value.asJsonObject(); + JsonObject author = commit.getJsonObject("commit").getJsonObject("author"); + commits.add(new CommitInfo(commit.getString("sha"), Instant.parse(author.getString("date")), author.getString("name"))); + } + + if (array.size() < COMMITS_PER_PAGE) return commits; // last page } - return commits; } + + // the history is longer than we retrieve, so the oldest commit returned is not the file's first + if (log.isWarnEnabled()) log.warn("History of '{}' in {}/{} exceeds {} commits, TimeMap is truncated to the most recent ones", + path, owner, repo, MAX_COMMIT_PAGES * COMMITS_PER_PAGE); + + return commits; } /** diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java b/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java index 50cd157ca9..ac3278e2cf 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java @@ -16,8 +16,6 @@ */ package com.atomgraph.linkeddatahub.resource; -import com.atomgraph.client.util.Constructor; -import com.atomgraph.client.vocabulary.AC; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Request; import jakarta.ws.rs.core.Response; @@ -46,13 +44,10 @@ import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.SecurityContext; import jakarta.ws.rs.core.UriInfo; -import org.apache.jena.irix.IRIx; import org.apache.jena.ontapi.model.OntModel; import org.apache.jena.query.DatasetFactory; import org.apache.jena.query.Query; import org.apache.jena.query.QueryFactory; -import org.apache.jena.rdf.model.Model; -import org.apache.jena.rdf.model.ModelFactory; import org.apache.jena.update.UpdateRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -99,18 +94,17 @@ public Namespace(@Context Request request, @Context UriInfo uriInfo, /** * If SPARQL query is provided, returns its result over the in-memory namespace ontology graph. - * If query is not provided - *
    - *
  • returns constructed instance if forClass URL param value (ontology class URI) is provided
  • - *
  • otherwise, returns the namespace ontology graph (which is standalone, i.e. not the full ontology imports closure)
  • - *
- * + * If query is not provided, returns the namespace ontology graph (which is standalone, i.e. + * not the full ontology imports closure). + * Constructor instances are instantiated client-side from the ontology's spin:constructor + * queries (fetched with a SPARQL query on this endpoint). + * * @param query SPARQL query string (optional) * @param defaultGraphUris default graph URI (ignored) * @param namedGraphUris named graph URIs (ignored) - * + * * {@link com.atomgraph.linkeddatahub.server.model.impl.Dispatcher#getNamespace()} - * + * * @return response */ @Override @@ -121,20 +115,6 @@ public Response get(@QueryParam(QUERY) Query query, // if query param is not provided and the app is end-user, return the namespace ontology associated with this document if (query == null) { - // construct instances for a list of ontology classes whose URIs are provided as ?forClass - if (getUriInfo().getQueryParameters().containsKey(AC.forClass.getLocalName())) - { - List forClasses = getUriInfo().getQueryParameters().get(AC.forClass.getLocalName()); - Model instances = ModelFactory.createDefaultModel(); - - forClasses.stream(). - map(forClass -> Optional.ofNullable(getOntology().getOntClass(checkURI(forClass).toString()))). - flatMap(Optional::stream). - forEach(forClass -> new Constructor().construct(forClass, instances, getApplication().getBase().getURI())); - - return getResponseBuilder(instances).build(); - } - if (getApplication().canAs(EndUserApplication.class)) { // the application ontology MUST use a URI! This is the URI this ontology endpoint is deployed on by the Dispatcher class @@ -175,20 +155,6 @@ public Response post(UpdateRequest update, @QueryParam(USING_GRAPH_URI) List endpoint", Status.METHOD_NOT_ALLOWED); } - /** - * Checks URI syntax. Throws exception if invalid. - * - * @param classIRIStr URI string - * @return IRI - */ - public static IRIx checkURI(String classIRIStr) - { - if (classIRIStr == null) throw new IllegalArgumentException("URI String cannot be null"); - - // IRIx.create() validates and throws IRIException on bad URIs - return IRIx.create(classIRIStr); - } - /** * Returns URI of this resource. * diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java b/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java index ea051eec85..c398022346 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java @@ -18,8 +18,10 @@ import com.atomgraph.core.util.ModelUtils; import com.atomgraph.linkeddatahub.apps.model.Application; +import com.atomgraph.linkeddatahub.resource.admin.ClearOntology; import com.atomgraph.linkeddatahub.server.io.ValidatingModelProvider; import com.atomgraph.linkeddatahub.vocabulary.LAPP; +import jakarta.ws.rs.container.ResourceContext; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.GET; @@ -61,6 +63,7 @@ public class Settings private final com.atomgraph.linkeddatahub.Application system; private final Providers providers; private final Request request; + private final ResourceContext resourceContext; /** * Constructs the Settings endpoint. @@ -69,14 +72,16 @@ public class Settings * @param system the system application * @param providers JAX-RS provider registry * @param request JAX-RS request context + * @param resourceContext JAX-RS resource context (for delegating to sub-resources) */ @Inject - public Settings(Application application, com.atomgraph.linkeddatahub.Application system, @Context Providers providers, @Context Request request) + public Settings(Application application, com.atomgraph.linkeddatahub.Application system, @Context Providers providers, @Context Request request, @Context ResourceContext resourceContext) { this.application = application; this.system = system; this.providers = providers; this.request = request; + this.resourceContext = resourceContext; } /** @@ -147,6 +152,13 @@ public Response patch(UpdateRequest updateRequest) throws IOException // Write the updated model back to the context dataset file getSystem().updateApp(getApplication(), mutableModel); + // clear and reload the ontology so the next request re-derives with the updated ldh:import set. + // Delegate to ClearOntology (context-agnostic) for the full eviction - repository graph + closure + // union + proxy cache purges - rather than duplicating it: clearing only the in-memory caches + // would leave stale /ns SPARQL responses in varnish after a package add/remove + if (getApplication().getOntology() != null) + getResourceContext().getResource(ClearOntology.class).post(getApplication().getOntology().getURI(), null); + if (log.isInfoEnabled()) log.info("Updated settings for dataspace <{}> via PATCH", getApplication().getURI()); return Response.noContent().build(); @@ -182,6 +194,16 @@ public Providers getProviders() return providers; } + /** + * Returns the JAX-RS resource context, used to obtain fully-injected sub-resource instances. + * + * @return the resource context + */ + public ResourceContext getResourceContext() + { + return resourceContext; + } + /** * Validates model against SPIN and SHACL constraints. * diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java index 190fbd3467..c69db8d3b5 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java @@ -76,7 +76,20 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer { if (ontologyURI == null) throw new BadRequestException("Ontology URI not specified"); - EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); // we're assuming the current app is admin + // resolve both apps regardless of which one the request matched: /clear is admin, but Settings + // delegates here on the end-user app (its PATCH origin), and both backends need purging either way + final EndUserApplication endUserApp; + final AdminApplication adminApp; + if (getApplication().canAs(AdminApplication.class)) + { + adminApp = getApplication().as(AdminApplication.class); + endUserApp = adminApp.getEndUserApplication(); + } + else + { + endUserApp = getApplication().as(EndUserApplication.class); + adminApp = endUserApp.getAdminApplication(); + } OntologyRepository repository = getSystem().getRepository(endUserApp); if (repository.isCached(ontologyURI) || getSystem().getOntologyGraphs().containsKey(ontologyURI)) { @@ -94,7 +107,7 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer if (log.isDebugEnabled()) log.debug("Purge ontology document with URI '{}' from frontend proxy cache", ontologyDocURI); ban(frontendProxy, ontologyDocURI.toString(), false); } - URI adminBackendProxy = getSystem().getServiceContext(getApplication().getService()).getBackendProxy(); + URI adminBackendProxy = getSystem().getServiceContext(adminApp.getService()).getBackendProxy(); if (adminBackendProxy != null) { // URL-pattern BAN of the ontology URI is a no-op on the SPARQL proxy (its req.url namespace is /ds/?query=..., @@ -112,7 +125,7 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer } // !!! we need to reload the ontology model before returning a response, to make sure the next request already gets the new version !!! - getSystem().getOntologyGraphs().put(ontologyURI, OntologyFilter.loadOntology(repository, ontologyURI)); + getSystem().getOntologyGraphs().put(ontologyURI, OntologyFilter.loadOntology(repository, ontologyURI, getSystem().getPackageOntologies(endUserApp))); } if (referer != null) return Response.seeOther(referer).build(); diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/SignUp.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/SignUp.java index 1fd7057e7f..06f4115095 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/SignUp.java +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/SignUp.java @@ -140,15 +140,17 @@ public class SignUp extends DocumentHierarchyGraphStoreImpl * @param providers registry of JAX-RS providers * @param system system application * @param servletConfig servlet config + * @param httpHeaders request headers */ // TO-DO: move to AuthenticationExceptionMapper and handle as state instead of URI resource? @Inject public SignUp(@Context Request request, @Context UriInfo uriInfo, MediaTypes mediaTypes, com.atomgraph.linkeddatahub.apps.model.Application application, Optional ontology, Optional service, @Context SecurityContext securityContext, Optional agentContext, - @Context Providers providers, com.atomgraph.linkeddatahub.Application system, @Context ServletConfig servletConfig) + @Context Providers providers, com.atomgraph.linkeddatahub.Application system, @Context ServletConfig servletConfig, + @Context HttpHeaders httpHeaders) { - super(request, uriInfo, mediaTypes, application, ontology, service, securityContext, agentContext, providers, system); + super(request, uriInfo, mediaTypes, application, ontology, service, securityContext, agentContext, providers, system, httpHeaders); if (log.isDebugEnabled()) log.debug("Constructing {}", getClass()); if (!application.canAs(AdminApplication.class)) // we are supposed to be in the admin app diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java deleted file mode 100644 index fdebf6bb8e..0000000000 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java +++ /dev/null @@ -1,540 +0,0 @@ -/** - * Copyright 2025 Martynas Jusevičius - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.atomgraph.linkeddatahub.resource.admin.pkg; - -import static com.atomgraph.client.MediaType.TEXT_XSL; -import com.atomgraph.linkeddatahub.apps.model.AdminApplication; -import com.atomgraph.linkeddatahub.apps.model.EndUserApplication; -import com.atomgraph.linkeddatahub.client.GraphStoreClient; -import com.atomgraph.linkeddatahub.resource.admin.ClearOntology; -import com.atomgraph.linkeddatahub.server.security.AgentContext; -import com.atomgraph.linkeddatahub.server.util.XSLTMasterUpdater; -import static com.atomgraph.server.status.UnprocessableEntityStatus.UNPROCESSABLE_ENTITY; -import jakarta.inject.Inject; -import jakarta.servlet.ServletContext; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.FormParam; -import jakarta.ws.rs.HeaderParam; -import jakarta.ws.rs.NotFoundException; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.WebApplicationException; -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.WebTarget; -import jakarta.ws.rs.container.ResourceContext; -import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriBuilder; -import org.apache.commons.codec.binary.Hex; -import org.apache.jena.rdf.model.Model; -import org.apache.jena.rdf.model.Resource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Optional; -import jakarta.ws.rs.NotFoundException; -import jakarta.ws.rs.ProcessingException; -import org.apache.jena.ontology.ConversionException; -import org.apache.jena.update.UpdateFactory; -import org.apache.jena.update.UpdateRequest; -import com.atomgraph.linkeddatahub.vocabulary.DH; -import com.atomgraph.linkeddatahub.vocabulary.FOAF; -import com.atomgraph.linkeddatahub.vocabulary.SIOC; -import com.atomgraph.linkeddatahub.server.util.Skolemizer; -import org.apache.jena.rdf.model.ModelFactory; -import org.apache.jena.vocabulary.DCTerms; -import org.apache.jena.vocabulary.RDF; - -/** - * JAX-RS resource that installs a LinkedDataHub package. - * Package installation involves: - * 1. Fetching package metadata - * 2. Downloading and validating package resources (ontology and stylesheet) - * 3. Creating item document under packages/ container with package metadata - * 4. PUTting package ontology as new document under ontologies/{hash}/ - * 5. Adding owl:imports of package ontology to namespace ontology - * 6. Saving package stylesheet (layout.xsl) to /static/{package-path}/ - * 7. Regenerating application master stylesheet - * - * @author Martynas Jusevičius {@literal } - */ -public class InstallPackage -{ - private static final Logger log = LoggerFactory.getLogger(InstallPackage.class); - - private final com.atomgraph.linkeddatahub.apps.model.Application application; - private final com.atomgraph.linkeddatahub.Application system; - private final Optional agentContext; - - @Context ServletContext servletContext; - @Context ResourceContext resourceContext; - - /** - * Constructs endpoint. - * - * @param application matched application (admin app) - * @param system system application - * @param agentContext authenticated agent context - */ - @Inject - public InstallPackage(com.atomgraph.linkeddatahub.apps.model.Application application, - com.atomgraph.linkeddatahub.Application system, - Optional agentContext) - { - this.application = application; - this.system = system; - this.agentContext = agentContext; - } - - /** - * Installs a package into the current dataspace. - * - * @param packageURI the package URI (e.g., https://packages.linkeddatahub.com/skos/#this) - * @param referer the referring URL - * @return JAX-RS response - */ - @POST - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response post(@FormParam("package-uri") String packageURI, @HeaderParam("Referer") URI referer) - { - if (packageURI == null) - { - if (log.isErrorEnabled()) log.error("Package URI not specified"); - throw new BadRequestException("Package URI not specified"); - } - - // Validate package URI to prevent SSRF attacks - getSystem().getURLValidator().validate(URI.create(packageURI)); - - if (log.isInfoEnabled()) log.info("Installing package: {}", packageURI); - com.atomgraph.linkeddatahub.apps.model.Package pkg = getPackage(packageURI); - if (pkg == null) - { - if (log.isErrorEnabled()) log.error("Loading package failed: {}", packageURI); - throw new WebApplicationException("Loading package failed", UNPROCESSABLE_ENTITY.getStatusCode()); // 422 Unprocessable Entity - } - - Resource ontology = pkg.getOntology(); - Resource stylesheet = pkg.getStylesheet(); - - if (ontology == null && stylesheet == null) - { - if (log.isErrorEnabled()) log.error("Package ontology and stylesheet are both unspecified for package: {}", packageURI); - throw new WebApplicationException("Package ontology and stylesheet are both unspecified", UNPROCESSABLE_ENTITY.getStatusCode()); // 422 Unprocessable Entity - } - - try - { - EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); - AdminApplication adminApp = endUserApp.getAdminApplication(); - - if (ontology != null) - { - // Validate ontology URI to prevent SSRF attacks - getSystem().getURLValidator().validate(URI.create(ontology.getURI())); - - if (log.isDebugEnabled()) log.debug("Downloading package ontology from: {}", ontology.getURI()); - Model ontologyModel = downloadOntology(ontology.getURI()); - - installOntology(endUserApp, ontologyModel, ontology.getURI()); - } - - if (stylesheet != null) - { - URI stylesheetURI = URI.create(stylesheet.getURI()); - String packagePath = pkg.getStylesheetPath(); - - // Validate stylesheet URI to prevent SSRF attacks - getSystem().getURLValidator().validate(stylesheetURI); - - if (log.isDebugEnabled()) log.debug("Downloading package stylesheet from: {}", stylesheetURI); - String stylesheetContent = downloadStylesheet(stylesheetURI); - - installStylesheet(Paths.get(getServletContext().getRealPath("/static")).resolve(packagePath).resolve("layout.xsl"), stylesheetContent); - - // Purge package stylesheet from frontend proxy cache - String stylesheetURL = "/static/" + packagePath + "/layout.xsl"; - if (getSystem().getFrontendProxy() != null) - { - if (log.isDebugEnabled()) log.debug("Purging package stylesheet from frontend proxy cache: {}", stylesheetURL); - getSystem().ban(getSystem().getFrontendProxy(), stylesheetURL, false); - } - - regenerateMasterStylesheet(endUserApp, pkg); - - // Purge master stylesheet from frontend proxy cache - if (getSystem().getFrontendProxy() != null) - { - if (log.isDebugEnabled()) log.debug("Purging master stylesheet from frontend proxy cache: {}", com.atomgraph.linkeddatahub.Application.MASTER_STYLESHEET_PATH); - getSystem().ban(getSystem().getFrontendProxy(), com.atomgraph.linkeddatahub.Application.MASTER_STYLESHEET_PATH, false); - } - } - - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - if (getAgentContext().isPresent()) gsc = gsc.delegation(adminApp.getBaseURI(), getAgentContext().get()); - - String slug = hashURI(packageURI); - URI packageDocumentURI = adminApp.getUriBuilder(). - path("packages/"). - path("{slug}/"). - build(slug); - Model packageDocModel = ModelFactory.createDefaultModel(); - packageDocModel.add(pkg.getModel()); - Resource container = packageDocModel.createResource(adminApp.getBaseURI().resolve("packages/").toString()); - createPackageDocument(packageDocModel, packageDocumentURI, container, pkg, slug); - new Skolemizer(packageDocumentURI.toString()).apply(packageDocModel); - putPackageDocument(gsc, packageDocumentURI, packageDocModel); - - if (log.isInfoEnabled()) log.info("Successfully installed package: {}", packageURI); - - // Redirect back to referer or application base - URI redirectURI = (referer != null) ? referer : endUserApp.getBaseURI(); - return Response.seeOther(redirectURI).build(); - } - catch (IOException e) - { - log.error("Failed to install package: {}", packageURI, e); - throw new WebApplicationException("Package installation failed", e); - } - } - - /** - * Loads package metadata from its URI using GraphStoreClient. - * Package metadata is expected to be available as Linked Data. - * - * @param gsc the graph store client - * @param packageURI the package URI (e.g., https://packages.linkeddatahub.com/skos/#this) - * @return Package instance - * @throws NotFoundException if package cannot be found (404) - */ - private com.atomgraph.linkeddatahub.apps.model.Package getPackage(String packageURI) - { - if (log.isDebugEnabled()) log.debug("Loading package from: {}", packageURI); - - final Model model; - - // check if we have the model in the cache first and if yes, return it from there instead making an HTTP request - if (getSystem().getRepository().isCached(packageURI) || - (getSystem().getRepository().isMapped(packageURI))) // read mapped URIs (such as system ontologies) from a file - { - if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", packageURI, getSystem().getRepository().isCached(packageURI)); - if (log.isDebugEnabled()) log.debug("isMapped({}): {}", packageURI, getSystem().getRepository().isMapped(packageURI)); - model = ModelFactory.createModelForGraph(getSystem().getRepository().get(packageURI)); - } - else - { - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - try - { - model = gsc.getModel(packageURI); - } - catch (NotFoundException ex) // 404 from the package server - { - return null; - } - catch (ProcessingException ex) // connection refused, timeout, etc. - { - return null; - } - } - - try - { - return model.getResource(packageURI).as(com.atomgraph.linkeddatahub.apps.model.Package.class); - } - catch (ConversionException ex) - { - return null; - } - } - - /** - * Downloads RDF from a URI using GraphStoreClient. - */ - private Model downloadOntology(String uri) - { - if (log.isDebugEnabled()) log.debug("Downloading ontology from: {}", uri); - - // check if we have the model in the cache first and if yes, return it from there instead making an HTTP request - if (getSystem().getRepository().isCached(uri) || - (getSystem().getRepository().isMapped(uri))) // read mapped URIs (such as system ontologies) from a file - { - if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", uri, getSystem().getRepository().isCached(uri)); - if (log.isDebugEnabled()) log.debug("isMapped({}): {}", uri, getSystem().getRepository().isMapped(uri)); - return ModelFactory.createModelForGraph(getSystem().getRepository().get(uri)); - } - else - { - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - return gsc.getModel(uri); - } - } - - /** - * Downloads XSLT stylesheet content from a URI using Jersey Client. - * Prioritizes text/xsl, falls back to text/*. - */ - private String downloadStylesheet(URI uri) throws IOException - { - if (log.isDebugEnabled()) log.debug("Downloading XSLT stylesheet from: {}", uri); - - WebTarget target = getClient().target(uri); - // Prioritize text/xsl (q=1.0), then any text/* (q=0.8) - try (Response response = target.request(TEXT_XSL, "text/*;q=0.8").get()) - { - if (!response.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to download XSLT from {}: {}", uri, response.getStatus()); - throw new IOException("Failed to download XSLT from " + uri + ": " + response.getStatus()); - } - - return response.readEntity(String.class); - } - } - - /** - * Hashes a URI using SHA-1 to create a unique document slug. - * - * @param uri the URI to hash - * @return the SHA-1 hash as a hexadecimal string - * @throws IOException if hashing fails - */ - private String hashURI(String uri) throws IOException - { - try - { - MessageDigest md = MessageDigest.getInstance("SHA-1"); - md.update(uri.getBytes(StandardCharsets.UTF_8)); - String hash = Hex.encodeHexString(md.digest()); - if (log.isDebugEnabled()) log.debug("URI '{}' hashed to '{}'", uri, hash); - return hash; - } - catch (NoSuchAlgorithmException e) - { - if (log.isErrorEnabled()) log.error("Failed to hash URI: {}", uri, e); - throw new IOException("Failed to hash URI", e); - } - } - - /** - * Installs ontology by PUTting as a new document and adding owl:imports to namespace ontology. - * - * @param app the end-user application - * @param ontologyModel the package ontology model - * @param packageOntologyURI the package ontology URI - * @throws IOException if installation fails - */ - private void installOntology(EndUserApplication app, Model ontologyModel, String packageOntologyURI) throws IOException - { - AdminApplication adminApp = app.getAdminApplication(); - - // 1. Create hash of package ontology URI to use as document slug - String hash = hashURI(packageOntologyURI); - - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - - // Delegate agent credentials if authenticated - if (getAgentContext().isPresent()) - { - if (log.isDebugEnabled()) log.debug("Delegating agent credentials for PUT request"); - gsc = gsc.delegation(adminApp.getBaseURI(), getAgentContext().get()); - } - - // 2. PUT package ontology as a document under model/ontologies/{hash}/ (overwrites if exists) - URI ontologyDocumentURI = UriBuilder.fromUri(adminApp.getBaseURI()).path("ontologies/{hash}/").build(hash); - if (log.isDebugEnabled()) log.debug("PUTting package ontology to document: {}", ontologyDocumentURI); - - try (Response putResponse = gsc.put(ontologyDocumentURI, ontologyModel)) - { - if (!putResponse.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to PUT package ontology to {}: {}", ontologyDocumentURI, putResponse.getStatus()); - throw new IOException("Failed to PUT package ontology to " + ontologyDocumentURI + ": " + putResponse.getStatus()); - } - if (log.isDebugEnabled()) log.debug("Package ontology PUT response status: {}", putResponse.getStatus()); - } - - // 3. Add owl:imports triple to namespace ontology in namespace graph - String namespaceOntologyURI = app.getOntology().getURI(); - URI namespaceGraphURI = UriBuilder.fromUri(adminApp.getBaseURI()).path("ontologies/namespace/").build(); - - if (log.isDebugEnabled()) log.debug("Adding owl:imports from namespace ontology '{}' to package ontology '{}'", namespaceOntologyURI, packageOntologyURI); - - String updateString = String.format( - "PREFIX owl: " + - "INSERT { <%s> owl:imports <%s> } WHERE { }", - namespaceOntologyURI, packageOntologyURI - ); - UpdateRequest updateRequest = UpdateFactory.create(updateString); - - try (Response patchResponse = gsc.patch(namespaceGraphURI, updateRequest)) - { - if (!patchResponse.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to PATCH namespace graph {}: {}", namespaceGraphURI, patchResponse.getStatus()); - throw new IOException("Failed to PATCH namespace graph " + namespaceGraphURI + ": " + patchResponse.getStatus()); - } - if (log.isDebugEnabled()) log.debug("Namespace graph PATCH response status: {}", patchResponse.getStatus()); - } - - if (log.isDebugEnabled()) log.debug("Clearing and reloading namespace ontology '{}'", namespaceOntologyURI); - getResourceContext().getResource(ClearOntology.class).post(namespaceOntologyURI, null); - } - - /** - * Installs stylesheet to /static//layout.xsl - */ - private void installStylesheet(Path stylesheetFile, String stylesheetContent) throws IOException - { - Files.createDirectories(stylesheetFile.getParent()); - Files.writeString(stylesheetFile, stylesheetContent); - - if (log.isDebugEnabled()) log.debug("Installed package stylesheet at: {}", stylesheetFile); - } - - /** - * Regenerates master stylesheet for the application. - * - * @param app the application - * @param newPackage the package being installed - * @throws IOException if regeneration fails - */ - private void regenerateMasterStylesheet(EndUserApplication app, com.atomgraph.linkeddatahub.apps.model.Package newPackage) throws IOException - { - XSLTMasterUpdater updater = new XSLTMasterUpdater(getServletContext()); - updater.addPackageImport(newPackage.getStylesheetPath()); - } - - /** - * Creates a package document item. - * - * @param model the model to populate - * @param packageDocumentURI the document URI - * @param container the container resource - * @param pkg the package resource - * @param slug the document slug - * @return the document item resource - */ - private Resource createPackageDocument(Model model, - URI packageDocumentURI, - Resource container, - com.atomgraph.linkeddatahub.apps.model.Package pkg, - String slug) - { - return model.createResource(packageDocumentURI.toString()). - addProperty(RDF.type, DH.Item). - addProperty(SIOC.HAS_CONTAINER, container). - addLiteral(DH.slug, slug). - addLiteral(DCTerms.title, pkg.getProperty(DCTerms.title).getString()). - addProperty(FOAF.primaryTopic, pkg); - } - - /** - * PUTs a package document to the specified URI. - * - * @param gsc the graph store client - * @param packageDocumentURI the document URI - * @param packageDocModel the package document model - * @throws IOException if PUT fails - */ - private void putPackageDocument(GraphStoreClient gsc, URI packageDocumentURI, Model packageDocModel) throws IOException - { - if (log.isDebugEnabled()) log.debug("PUTting package document to: {}", packageDocumentURI); - - try (Response putResponse = gsc.put(packageDocumentURI, packageDocModel)) - { - if (!putResponse.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to PUT package document to {}: {}", packageDocumentURI, putResponse.getStatus()); - throw new IOException("Failed to PUT package document to " + packageDocumentURI + ": " + putResponse.getStatus()); - } - if (log.isDebugEnabled()) log.debug("Package document PUT response status: {}", putResponse.getStatus()); - } - - if (log.isInfoEnabled()) log.info("Successfully created package document at: {}", packageDocumentURI); - } - - /** - * Returns the current application. - * - * @return application resource - */ - public com.atomgraph.linkeddatahub.apps.model.Application getApplication() - { - return application; - } - - /** - * Returns the system application. - * - * @return system application - */ - public com.atomgraph.linkeddatahub.Application getSystem() - { - return system; - } - - /** - * Returns Jersey HTTP client. - * - * @return HTTP client - */ - public Client getClient() - { - return getSystem().getClient(); - } - - /** - * Returns servlet context. - * - * @return servlet context - */ - public ServletContext getServletContext() - { - return servletContext; - } - - - /** - * Returns JAX-RS resource context. - * - * @return resource context - */ - public ResourceContext getResourceContext() - { - return resourceContext; - } - - /** - * Returns the authenticated agent context. - * - * @return agent context - */ - public Optional getAgentContext() - { - return agentContext; - } - -} diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java deleted file mode 100644 index a00539a11a..0000000000 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java +++ /dev/null @@ -1,369 +0,0 @@ -/** - * Copyright 2025 Martynas Jusevičius - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.atomgraph.linkeddatahub.resource.admin.pkg; - -import com.atomgraph.linkeddatahub.apps.model.AdminApplication; -import com.atomgraph.linkeddatahub.apps.model.EndUserApplication; -import com.atomgraph.linkeddatahub.client.GraphStoreClient; -import com.atomgraph.linkeddatahub.resource.admin.ClearOntology; -import com.atomgraph.linkeddatahub.server.security.AgentContext; -import com.atomgraph.linkeddatahub.server.util.XSLTMasterUpdater; -import static com.atomgraph.server.status.UnprocessableEntityStatus.UNPROCESSABLE_ENTITY; -import jakarta.inject.Inject; -import jakarta.servlet.ServletContext; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.FormParam; -import jakarta.ws.rs.HeaderParam; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.WebApplicationException; -import jakarta.ws.rs.container.ResourceContext; -import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriBuilder; -import org.apache.commons.codec.binary.Hex; -import org.apache.jena.rdf.model.Model; -import org.apache.jena.rdf.model.ModelFactory; -import org.apache.jena.rdf.model.Resource; -import org.apache.jena.update.UpdateFactory; -import org.apache.jena.update.UpdateRequest; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Optional; -import org.apache.jena.ontology.ConversionException; - -/** - * JAX-RS resource that uninstalls a LinkedDataHub package. - * Package uninstallation involves: - * 1. DELETEing package ontology document from ontologies/{hash}/ - * 2. Removing owl:imports triple from namespace graph - * 3. Clearing and reloading namespace ontology from cache - * 4. Deleting package stylesheet from /static/{package-path}/ - * 5. Regenerating application master stylesheet - * - * @author Martynas Jusevičius {@literal } - */ -public class UninstallPackage -{ - private static final Logger log = LoggerFactory.getLogger(UninstallPackage.class); - - private final com.atomgraph.linkeddatahub.apps.model.Application application; - private final com.atomgraph.linkeddatahub.Application system; - private final Optional agentContext; - - @Context ServletContext servletContext; - @Context ResourceContext resourceContext; - - /** - * Constructs endpoint. - * - * @param application matched application (admin app) - * @param system system application - * @param agentContext authenticated agent context - */ - @Inject - public UninstallPackage(com.atomgraph.linkeddatahub.apps.model.Application application, - com.atomgraph.linkeddatahub.Application system, - Optional agentContext) - { - this.application = application; - this.system = system; - this.agentContext = agentContext; - } - - /** - * Uninstalls a package from the current dataspace. - * - * @param packageURI the package URI (e.g., https://packages.linkeddatahub.com/skos/#this) - * @param referer the referring URL - * @return JAX-RS response - */ - @POST - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response post(@FormParam("package-uri") String packageURI, @HeaderParam("Referer") URI referer) - { - if (packageURI == null) - { - if (log.isErrorEnabled()) log.error("Package URI not specified"); - throw new BadRequestException("Package URI not specified"); - } - - try - { - EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); - - if (log.isInfoEnabled()) log.info("Uninstalling package: {}", packageURI); - - com.atomgraph.linkeddatahub.apps.model.Package pkg = getPackage(packageURI); - if (pkg == null) - { - if (log.isErrorEnabled()) log.error("Loading package failed: {}", packageURI); - throw new WebApplicationException("Loading package failed", UNPROCESSABLE_ENTITY.getStatusCode()); // 422 Unprocessable Entity - } - - Resource ontology = pkg.getOntology(); - Resource stylesheet = pkg.getStylesheet(); - - // either ontology or stylesheet need to be specified, or both - if (ontology == null && stylesheet == null) - { - if (log.isErrorEnabled()) log.error("Package ontology and stylesheet are both unspecified for package: {}", packageURI); - throw new WebApplicationException("Package ontology and stylesheet are both unspecified", UNPROCESSABLE_ENTITY.getStatusCode()); // 422 Unprocessable Entity - } - - if (ontology != null) uninstallOntology(endUserApp, ontology.getURI()); - - if (stylesheet != null) - { - String packagePath = pkg.getStylesheetPath(); - Path packageDir = Paths.get(getServletContext().getRealPath("/static")).resolve(packagePath); - Path stylesheetFile = packageDir.resolve("layout.xsl"); - - uninstallStylesheet(stylesheetFile, packagePath, endUserApp); - regenerateMasterStylesheet(endUserApp, pkg); - } - - if (log.isInfoEnabled()) log.info("Successfully uninstalled package: {}", packageURI); - - URI redirectURI = (referer != null) ? referer : endUserApp.getBaseURI(); - return Response.seeOther(redirectURI).build(); - } - catch (IOException e) - { - if (log.isErrorEnabled()) log.error("Failed to uninstall package: {}", packageURI, e); - throw new WebApplicationException("Package uninstallation failed", e); - } - } - - /** - * Uninstalls ontology by deleting the package ontology document. - * - * @param app the end-user application - * @param packageOntologyURI the package ONTOLOGY URI - * @throws IOException if uninstallation fails - */ - private void uninstallOntology(EndUserApplication app, String packageOntologyURI) throws IOException - { - AdminApplication adminApp = app.getAdminApplication(); - - String hash; - try - { - MessageDigest md = MessageDigest.getInstance("SHA-1"); - md.update(packageOntologyURI.getBytes(StandardCharsets.UTF_8)); - hash = Hex.encodeHexString(md.digest()); - if (log.isDebugEnabled()) log.debug("Package ontology URI '{}' hashed to '{}'", packageOntologyURI, hash); - } - catch (NoSuchAlgorithmException e) - { - if (log.isErrorEnabled()) log.error("Failed to hash package ontology URI: {}", packageOntologyURI, e); - throw new IOException("Failed to hash package ontology URI", e); - } - - // 3. DELETE package ontology document at ontologies/{hash}/ - URI ontologyDocumentURI = UriBuilder.fromUri(adminApp.getBaseURI()).path("ontologies/{hash}/").build(hash); - if (log.isDebugEnabled()) log.debug("DELETEing package ontology document: {}", ontologyDocumentURI); - - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - - // Delegate agent credentials if authenticated - if (getAgentContext().isPresent()) - { - if (log.isDebugEnabled()) log.debug("Delegating agent credentials for DELETE request"); - gsc = gsc.delegation(adminApp.getBaseURI(), getAgentContext().get()); - } - - try (Response deleteResponse = gsc.delete(ontologyDocumentURI)) - { - if (!deleteResponse.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to DELETE package ontology document {}: {}", ontologyDocumentURI, deleteResponse.getStatus()); - throw new IOException("Failed to DELETE package ontology document " + ontologyDocumentURI + ": " + deleteResponse.getStatus()); - } - if (log.isDebugEnabled()) log.debug("Package ontology DELETE response status: {}", deleteResponse.getStatus()); - } - - // 4. Remove owl:imports triple from namespace ontology in namespace graph - String namespaceOntologyURI = app.getOntology().getURI(); - URI namespaceGraphURI = UriBuilder.fromUri(adminApp.getBaseURI()).path("ontologies/namespace/").build(); - - if (log.isDebugEnabled()) log.debug("Removing owl:imports from namespace ontology '{}' to package ontology '{}'", namespaceOntologyURI, packageOntologyURI); - - String updateString = String.format( - "PREFIX owl: " + - "DELETE WHERE { <%s> owl:imports <%s> }", - namespaceOntologyURI, packageOntologyURI - ); - UpdateRequest updateRequest = UpdateFactory.create(updateString); - - try (Response patchResponse = gsc.patch(namespaceGraphURI, updateRequest)) - { - if (!patchResponse.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to PATCH namespace graph {}: {}", namespaceGraphURI, patchResponse.getStatus()); - throw new IOException("Failed to PATCH namespace graph " + namespaceGraphURI + ": " + patchResponse.getStatus()); - } - if (log.isDebugEnabled()) log.debug("Namespace graph PATCH response status: {}", patchResponse.getStatus()); - } - - // 5. Clear and reload namespace ontology from cache - if (log.isDebugEnabled()) log.debug("Clearing and reloading namespace ontology '{}'", namespaceOntologyURI); - getResourceContext().getResource(ClearOntology.class).post(namespaceOntologyURI, null); - } - - /** - * Deletes stylesheet from /static// - */ - private void uninstallStylesheet(Path stylesheetFile, String packagePath, EndUserApplication endUserApp) throws IOException - { - Files.delete(stylesheetFile); - if (log.isDebugEnabled()) log.debug("Deleted package stylesheet: {}", stylesheetFile); - - // Purge stylesheet from frontend proxy cache - String stylesheetURL = "/static/" + packagePath + "/layout.xsl"; - if (getSystem().getFrontendProxy() != null) - { - if (log.isDebugEnabled()) log.debug("Purging stylesheet from frontend proxy cache: {}", stylesheetURL); - getSystem().ban(getSystem().getFrontendProxy(), stylesheetURL, false); - } - - // Delete directory if empty - if (Files.list(stylesheetFile.getParent()).count() == 0) - { - Files.delete(stylesheetFile.getParent()); - if (log.isDebugEnabled()) log.debug("Deleted package directory: {}", stylesheetFile.getParent()); - } - } - - /** - * Regenerates master stylesheet for the application without the uninstalled package. - * - * @param app the application - * @param removedPackage the package being uninstalled - * @throws IOException if regeneration fails - */ - private void regenerateMasterStylesheet(EndUserApplication app, com.atomgraph.linkeddatahub.apps.model.Package removedPackage) throws IOException - { - XSLTMasterUpdater updater = new XSLTMasterUpdater(getServletContext()); - updater.removePackageImport(removedPackage.getStylesheetPath()); - - // Purge master stylesheet from cache - if (getSystem().getFrontendProxy() != null) - { - if (log.isDebugEnabled()) log.debug("Purging master stylesheet from frontend proxy cache: {}", com.atomgraph.linkeddatahub.Application.MASTER_STYLESHEET_PATH); - getSystem().ban(getSystem().getFrontendProxy(), com.atomgraph.linkeddatahub.Application.MASTER_STYLESHEET_PATH, false); - } - } - - /** - * Returns the current application. - * - * @return application resource - */ - public com.atomgraph.linkeddatahub.apps.model.Application getApplication() - { - return application; - } - - /** - * Returns servlet context. - * - * @return servlet context - */ - public ServletContext getServletContext() - { - return servletContext; - } - - /** - * Loads package metadata from its URI using GraphStoreClient. - * Package metadata is expected to be available as Linked Data. - * - * @param packageURI the package URI (e.g., https://packages.linkeddatahub.com/skos/#this) - * @return Package instance, or null if package cannot be loaded - */ - private com.atomgraph.linkeddatahub.apps.model.Package getPackage(String packageURI) - { - if (log.isDebugEnabled()) log.debug("Loading package from: {}", packageURI); - - final Model model; - - // check if we have the model in the cache first and if yes, return it from there instead making an HTTP request - if (getSystem().getRepository().isCached(packageURI) || - (getSystem().getRepository().isMapped(packageURI))) // read mapped URIs (such as system ontologies) from a file - { - if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", packageURI, getSystem().getRepository().isCached(packageURI)); - if (log.isDebugEnabled()) log.debug("isMapped({}): {}", packageURI, getSystem().getRepository().isMapped(packageURI)); - model = ModelFactory.createModelForGraph(getSystem().getRepository().get(packageURI)); - } - else - { - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - model = gsc.getModel(packageURI); - } - - try - { - return model.getResource(packageURI).as(com.atomgraph.linkeddatahub.apps.model.Package.class); - } - catch (ConversionException ex) - { - return null; - } - } - - /** - * Returns the system application. - * - * @return system application - */ - public com.atomgraph.linkeddatahub.Application getSystem() - { - return system; - } - - - /** - * Returns JAX-RS resource context. - * - * @return resource context - */ - public ResourceContext getResourceContext() - { - return resourceContext; - } - - /** - * Returns the authenticated agent context. - * - * @return agent context - */ - public Optional getAgentContext() - { - return agentContext; - } - -} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java index 319cf68e3e..40cf8efcda 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; +import java.util.List; import java.util.Optional; import jakarta.annotation.Priority; import jakarta.inject.Inject; @@ -158,7 +159,7 @@ public OntModel getOntology(Application app, String uri) union = getSystem().getOntologyGraphs().get(uri); if (union == null) { - union = loadOntology(repository, uri); + union = loadOntology(repository, uri, getSystem().getPackageOntologies(app)); getSystem().getOntologyGraphs().put(uri, union); } } @@ -178,6 +179,38 @@ public OntModel getOntology(Application app, String uri) * @param uri ontology URI * @return closure union graph */ + /** + * Assembles the ontology's owl:imports closure composed with the ontologies of the imported + * packages. Each package ontology is assembled as its own closure union (so its owl:imports + * resolve too) and added as a member of the application ontology's union — derived in memory, + * mirroring the stylesheet composition in {@code XsltExecutableFilter}; no owl:imports triple + * is materialized anywhere. A package ontology that fails to load is skipped so a broken + * package cannot take the application ontology down. + * + * @param repository graph repository + * @param uri ontology URI + * @param packageOntologies package ontology URIs + * @return closure union graph + */ + public static UnionGraph loadOntology(PrefixGraphRepository repository, String uri, List packageOntologies) + { + UnionGraph union = loadOntology(repository, uri); + + for (URI packageOntology : packageOntologies) + { + try + { + union.addSubGraph(loadOntology(repository, packageOntology.toString())); + } + catch (RuntimeException ex) + { + if (log.isErrorEnabled()) log.error("Could not load package ontology '{}', skipping it", packageOntology, ex); + } + } + + return union; + } + public static UnionGraph loadOntology(PrefixGraphRepository repository, String uri) { if (log.isDebugEnabled()) log.debug("Started loading ontology with URI '{}'", uri); diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java index da1050384e..e299dcb7f3 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java @@ -128,6 +128,19 @@ public class ProxyRequestFilter implements ContainerRequestFilter HttpHeaders.LOCATION, HttpHeaders.RETRY_AFTER, "Age"); + /** + * Conditional request headers forwarded verbatim to the upstream so preconditions are evaluated + * at the origin: {@code If-Match}/{@code If-Unmodified-Since} carry optimistic-concurrency + * validators on writes, {@code If-None-Match}/{@code If-Modified-Since} carry cache validation on + * reads. Excludes {@code Authorization}/{@code Cookie} (agent identity is delegated explicitly via + * {@link WebIDDelegationFilter}/{@link IDTokenDelegationFilter}) and {@code Range}, whose byte + * offsets do not survive the Model re-serialization the proxy performs. + */ + private static final Set FORWARDED_REQUEST_HEADERS = Set.of( + HttpHeaders.IF_MATCH, + HttpHeaders.IF_NONE_MATCH, + HttpHeaders.IF_MODIFIED_SINCE, + HttpHeaders.IF_UNMODIFIED_SINCE); @Inject com.atomgraph.linkeddatahub.Application system; @Inject MediaTypes mediaTypes; @@ -210,6 +223,15 @@ else if (agentContext instanceof IDTokenSecurityContext idTokenSecurityContext) accept(clientAcceptTypes). header(HttpHeaders.USER_AGENT, GraphStoreClient.USER_AGENT); + // forward conditional request headers so preconditions reach the origin, which owns the + // validators - without this the origin sees an unconditional request and a proxied If-Match + // write silently loses its optimistic-concurrency guard + for (String name : FORWARDED_REQUEST_HEADERS) + { + String value = requestContext.getHeaderString(name); + if (value != null) builder.header(name, value); + } + Response clientResponse = requestContext.hasEntity() ? builder.method(requestContext.getMethod(), Entity.entity(requestContext.getEntityStream(), requestContext.getMediaType())) @@ -289,9 +311,9 @@ protected Optional resolveTargetURI(ContainerRequestContext requestContext) * entity class is known. *

* {@code Link} headers and end-to-end cache/content headers from upstream are overlaid on top - * of all three branches; {@code ETag}/{@code Last-Modified} are skipped on the typed branches - * because the Model/ResultSet builders stamp their own validators that describe the - * re-serialized representation, not the upstream bytes. + * of all three branches. The Model and raw branches forward the origin's {@code ETag}/{@code Last-Modified} + * (writes against the proxied document send {@code If-Match} through this proxy to the origin, which + * compares against its own validator); only the ResultSet branch keeps the builder-stamped validators. * * @param clientResponse response from the proxy target * @param targetURI upstream URI (used as the parse base URI hint for {@code ModelProvider}) @@ -323,6 +345,21 @@ protected Response getResponse(Response clientResponse, URI targetURI, String me return rb.build(); } + // error responses relay verbatim: the body is a diagnostic representation, not negotiable + // content, so it must not go through the Model/ResultSet re-serialization branches - parsing a + // non-RDF or empty error body there throws and masks the origin's status as 502/406. A proxied + // write that the origin rejects (412 on a stale If-Match, 401/403 on an unauthorized delta) + // must reach the client as that status, with the origin's validators forwarded + Response.Status.Family family = clientResponse.getStatusInfo().getFamily(); + if (family == Response.Status.Family.CLIENT_ERROR || family == Response.Status.Family.SERVER_ERROR) + { + clientResponse.bufferEntity(); + Response.ResponseBuilder rb = Response.status(clientResponse.getStatus()). + type(clientResponse.getMediaType()). + entity(clientResponse.readEntity(InputStream.class)); + return overlayHeaders(rb.build(), clientResponse, true); + } + // dispatch on the live Jena RIOT registry — same predicate ModelProvider.isReadable uses, // so any RDF lang Jersey can read into a Model (including HTML via HtmlJsonLDReader and // RDFPOST) routes to the Model branch. We can't use MediaTypes.getReadable(Model.class) @@ -344,7 +381,12 @@ protected Response getResponse(Response clientResponse, URI targetURI, String me // base URI hint so ModelProvider (and HtmlJsonLDReader through it) resolve relative IRIs against the upstream URI clientResponse.getHeaders().putSingle(ModelProvider.REQUEST_URI_HEADER, targetURI.toString()); Model model = clientResponse.readEntity(Model.class); - return overlayHeaders(getResponse(model, clientResponse.getStatusInfo()), clientResponse, false); + // forward the origin's validators (replacing the ones the Model builder stamps off the re-serialized + // bytes): a client editing the proxied document sends If-Match through this proxy to the origin, which + // compares against its own ETag - a re-serialization validator would 412 every proxied write. The proxy + // performs no byte-validator-dependent features of its own (no Range, no conditional evaluation), so the + // origin's resource-state validator is the correct one to surface + return overlayHeaders(getResponse(model, clientResponse.getStatusInfo()), clientResponse, true); } // upstream is neither RDF nor SPARQL results — pipe raw bytes @@ -361,9 +403,10 @@ protected Response getResponse(Response clientResponse, URI targetURI, String me /** * Copies the upstream {@code Link} and end-to-end cache/content headers onto the given - * built response. {@code ETag}/{@code Last-Modified} are skipped when {@code copyValidators} - * is {@code false} (typed branches), because the Model/ResultSet builders stamp their own - * validators that describe the re-serialized representation rather than the upstream bytes. + * built response, replacing any locally stamped values. {@code ETag}/{@code Last-Modified} + * are skipped when {@code copyValidators} is {@code false} (the ResultSet branch), where the + * builder-stamped validators stand. The Model branch forwards the origin's validators so + * {@code If-Match} preconditions on proxied writes validate against the origin's own ETag. * * @param response the response built by the typed or raw branch * @param clientResponse upstream response to copy headers from @@ -386,7 +429,7 @@ private Response overlayHeaders(Response response, Response clientResponse, bool { if (!copyValidators && (HttpHeaders.ETAG.equalsIgnoreCase(name) || HttpHeaders.LAST_MODIFIED.equalsIgnoreCase(name))) continue; String value = clientResponse.getHeaderString(name); - if (value != null) rb.header(name, value); + if (value != null) rb.header(name, null).header(name, value); // replace, not append - the upstream value overlays any locally stamped one } return rb.build(); diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/CacheInvalidationFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/CacheInvalidationFilter.java index 58af006bef..26536ddd8c 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/CacheInvalidationFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/CacheInvalidationFilter.java @@ -16,7 +16,6 @@ */ package com.atomgraph.linkeddatahub.server.filter.response; -import com.atomgraph.client.vocabulary.AC; import com.atomgraph.linkeddatahub.apps.model.AdminApplication; import com.atomgraph.linkeddatahub.apps.model.EndUserApplication; import java.io.IOException; @@ -84,14 +83,6 @@ public void filter(ContainerRequestContext req, ContainerResponseContext resp) t banIfNotNull(getSystem().getFrontendProxy(), relativeParentURI.toString()); banIfNotNull(getSystem().getServiceContext(getApplication().get().getService()).getBackendProxy(), relativeParentURI.toString()); } - - // ban all results of queries that use forClass type - if (req.getUriInfo().getQueryParameters().containsKey(AC.forClass.getLocalName())) - { - String forClass = req.getUriInfo().getQueryParameters().getFirst(AC.forClass.getLocalName()); - banIfNotNull(getSystem().getFrontendProxy(), forClass); - banIfNotNull(getSystem().getServiceContext(getApplication().get().getService()).getBackendProxy(), forClass); - } } if (Set.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.PATCH).contains(req.getMethod())) diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ProvenanceFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ProvenanceFilter.java deleted file mode 100644 index fab67d9cec..0000000000 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ProvenanceFilter.java +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Copyright 2021 Martynas Jusevičius - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.atomgraph.linkeddatahub.server.filter.response; - -import com.atomgraph.core.vocabulary.SD; -import com.atomgraph.linkeddatahub.model.auth.Agent; -import com.atomgraph.linkeddatahub.model.Service; -import com.atomgraph.linkeddatahub.vocabulary.PROV; -import java.io.IOException; -import java.util.GregorianCalendar; -import java.util.Optional; -import java.util.UUID; -import jakarta.annotation.Priority; -import jakarta.inject.Inject; -import jakarta.ws.rs.HttpMethod; -import jakarta.ws.rs.Priorities; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.container.ContainerResponseContext; -import jakarta.ws.rs.container.ContainerResponseFilter; -import org.apache.jena.rdf.model.Model; -import org.apache.jena.rdf.model.ModelFactory; -import org.apache.jena.rdf.model.Resource; -import org.apache.jena.vocabulary.RDF; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Records each HTTP interaction in a timestamped meta named graph. - * Currently unused. - * - * @author {@literal Martynas Jusevičius } - */ -@Priority(Priorities.USER + 100) -public class ProvenanceFilter implements ContainerResponseFilter -{ - - private static final Logger log = LoggerFactory.getLogger(ProvenanceFilter.class); - - @Inject jakarta.inject.Provider> service; - @Inject com.atomgraph.linkeddatahub.Application system; - - @Override - public void filter(ContainerRequestContext request, ContainerResponseContext response)throws IOException - { - if (getService().isPresent() && - (request.getMethod().equals(HttpMethod.POST) || - request.getMethod().equals(HttpMethod.PUT) || - request.getMethod().equals(HttpMethod.PATCH) || - request.getMethod().equals(HttpMethod.DELETE))) - { - String graphUri = request.getUriInfo().getAbsolutePath().toString(); - String graphGraphUri = "urn:uuid:" + UUID.randomUUID().toString(); - - Model model = ModelFactory.createDefaultModel(); - Resource graph = model.createResource(). - addProperty(RDF.type, SD.NamedGraph). - addProperty(SD.name, model.createResource(graphUri)). - addLiteral(PROV.generatedAtTime, GregorianCalendar.getInstance()); - // TO-DO: ACL access mode? - - if (request.getSecurityContext().getUserPrincipal() instanceof Agent) - { - Agent agent = ((Agent)(request.getSecurityContext().getUserPrincipal())); - graph.addProperty(PROV.wasAttributedTo, agent); - } - - if (log.isDebugEnabled()) log.debug("PUTting {} triples of provenance metadata", graph.getModel().size()); - system.getServiceContext(getService().get()).getGraphStoreClient().putModel(graphGraphUri, model); - } - } - - /** - * Returns (optional) SPARQL service of the current application. - * - * @return optional service - */ - public Optional getService() - { - return service.get(); - } - -} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ResponseHeadersFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ResponseHeadersFilter.java index 4175459259..619f0806b7 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ResponseHeadersFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ResponseHeadersFilter.java @@ -18,7 +18,6 @@ import com.atomgraph.client.vocabulary.AC; import com.atomgraph.client.vocabulary.LDT; -import com.atomgraph.core.util.Link; import com.atomgraph.core.vocabulary.SD; import com.atomgraph.linkeddatahub.apps.model.Application; import com.atomgraph.linkeddatahub.apps.model.Dataset; @@ -26,9 +25,10 @@ import com.atomgraph.linkeddatahub.server.model.impl.Dispatcher; import com.atomgraph.linkeddatahub.server.model.impl.DocumentHierarchyGraphStoreImpl; import com.atomgraph.linkeddatahub.server.security.AuthorizationContext; +import com.atomgraph.linkeddatahub.server.util.Link; import com.atomgraph.linkeddatahub.vocabulary.ACL; import com.atomgraph.linkeddatahub.vocabulary.LAPP; -import com.atomgraph.linkeddatahub.vocabulary.MEM; +import com.atomgraph.linkeddatahub.writer.TimeMapWriter; import java.io.IOException; import java.net.URI; import java.util.Optional; @@ -71,9 +71,10 @@ public void filter(ContainerRequestContext request, ContainerResponseContext res response.getHeaders().add(HttpHeaders.LINK, new Link(URI.create(agent.getURI()), ACL.agent.getURI(), null)); } - // historical version and TimeMap views are read-only: advertise acl:Read at most, so the UI disables edit affordances - boolean isSnapshotRequest = request.getUriInfo().getQueryParameters().containsKey(DocumentHierarchyGraphStoreImpl.VERSION_PARAM_NAME) || - request.getUriInfo().getQueryParameters().containsKey(DocumentHierarchyGraphStoreImpl.TIMEMAP_PARAM_NAME); + boolean isTimeMap = request.getUriInfo().getQueryParameters().containsKey(DocumentHierarchyGraphStoreImpl.TIMEMAP_PARAM_NAME); + boolean isTimeGate = request.getUriInfo().getQueryParameters().containsKey(DocumentHierarchyGraphStoreImpl.TIMEGATE_PARAM_NAME); + // historical version, TimeMap and TimeGate views are read-only: advertise acl:Read at most, so the UI disables edit affordances + boolean isSnapshotRequest = DocumentHierarchyGraphStoreImpl.isSnapshotRequest(request.getUriInfo()); if (getAuthorizationContext().isPresent()) getAuthorizationContext().get().getModeURIs().stream(). @@ -95,10 +96,29 @@ public void filter(ContainerRequestContext request, ContainerResponseContext res // add Link rel=ldt:ontology, if the ontology URI is specified if (application.getOntology() != null) response.getHeaders().add(HttpHeaders.LINK, new Link(URI.create(application.getOntology().getURI()), LDT.ontology.getURI(), null)); - // add Link rel=mem:timemap, if the document is versioned + // add Memento (RFC 7089) hypermedia, if the document is versioned if (getSystem().getGraphVersioningService().getRepository(application.getURI()).isPresent() && request.getUriInfo().getMatchedResources().stream().anyMatch(DocumentHierarchyGraphStoreImpl.class::isInstance)) - response.getHeaders().add(HttpHeaders.LINK, new Link(URI.create(request.getUriInfo().getAbsolutePath() + "?" + DocumentHierarchyGraphStoreImpl.TIMEMAP_PARAM_NAME), MEM.timemap.getURI(), null)); + { + URI originalURI = request.getUriInfo().getAbsolutePath(); // the query is what makes a URI a Memento, TimeMap or TimeGate + URI timeMapURI = URI.create(originalURI + "?" + DocumentHierarchyGraphStoreImpl.TIMEMAP_PARAM_NAME); + URI timeGateURI = URI.create(originalURI + "?" + DocumentHierarchyGraphStoreImpl.TIMEGATE_PARAM_NAME); + + // the TimeMap identifies itself with rel=self; everything else points at it with rel=timemap + if (isTimeMap) + response.getHeaders().add(HttpHeaders.LINK, new Link(timeMapURI, "self", TimeMapWriter.APPLICATION_LINK_FORMAT)); + else + response.getHeaders().add(HttpHeaders.LINK, new Link(timeMapURI, "timemap", TimeMapWriter.APPLICATION_LINK_FORMAT)); + + // the Original Resource MUST advertise a preferred TimeGate; the TimeGate does not link to itself + if (!isTimeGate) + response.getHeaders().add(HttpHeaders.LINK, new Link(timeGateURI, "timegate", null)); + + // a Memento and a TimeGate MUST link to the Original Resource, and a TimeMap lists it; + // the Original Resource itself MUST NOT carry rel=original + if (isSnapshotRequest) + response.getHeaders().add(HttpHeaders.LINK, new Link(originalURI, "original", null)); + } // add Link rel=ac:stylesheet, if the stylesheet URI is specified if (application.getStylesheet() != null) response.getHeaders().add(HttpHeaders.LINK, new Link(URI.create(application.getStylesheet().getURI()), AC.stylesheet.getURI(), null)); diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilter.java index 427cd23824..be71ed7d3a 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilter.java @@ -18,13 +18,20 @@ import com.atomgraph.client.vocabulary.AC; import com.atomgraph.linkeddatahub.MediaType; +import com.atomgraph.linkeddatahub.server.util.SecureXML; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.net.MalformedURLException; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; import java.util.Map; import jakarta.annotation.Priority; import jakarta.inject.Inject; +import jakarta.servlet.ServletContext; import jakarta.ws.rs.InternalServerErrorException; import jakarta.ws.rs.Priorities; import jakarta.ws.rs.client.Client; @@ -36,15 +43,26 @@ import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; +import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; +import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.Source; +import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamSource; import net.sf.saxon.s9api.SaxonApiException; import net.sf.saxon.s9api.XsltCompiler; import net.sf.saxon.s9api.XsltExecutable; +import org.apache.commons.codec.binary.Hex; import org.apache.commons.io.IOUtils; +import org.apache.jena.rdf.model.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; /** * Response filter that loads and compiles the XSLT stylesheet of the application. @@ -57,11 +75,14 @@ public class XsltExecutableFilter implements ContainerResponseFilter private static final Logger log = LoggerFactory.getLogger(XsltExecutableFilter.class); + private static final String XSL_NS = "http://www.w3.org/1999/XSL/Transform"; + @Inject com.atomgraph.linkeddatahub.Application system; @Inject jakarta.inject.Provider> application; @Context UriInfo uriInfo; - + @Context ServletContext servletContext; + @Override public void filter(ContainerRequestContext req, ContainerResponseContext resp) throws IOException { @@ -73,12 +94,212 @@ public void filter(ContainerRequestContext req, ContainerResponseContext resp) t if (getApplication().isPresent() && getApplication().get().getStylesheet() != null) stylesheet = URI.create(getApplication().get().getStylesheet().getURI()); - if (stylesheet != null) req.setProperty(AC.stylesheet.getURI(), getXsltExecutable(stylesheet)); + if (stylesheet != null) + { + List packages = getPackages(getApplication().get()); + + if (packages.isEmpty()) req.setProperty(AC.stylesheet.getURI(), getXsltExecutable(stylesheet)); + else req.setProperty(AC.stylesheet.getURI(), getXsltExecutable(getApplication().get(), stylesheet, packages)); + } else req.setProperty(AC.stylesheet.getURI(), getSystem().getXsltExecutable()); - + } } - + + /** + * Returns URIs of the packages imported by the application, ordered by URI. + * + * @param app application resource + * @return list of package URIs + */ + public List getPackages(com.atomgraph.linkeddatahub.apps.model.Application app) + { + return app.getImportedPackages().stream(). + filter(Resource::isURIResource). + map(pkg -> URI.create(pkg.getURI())). + sorted(). + collect(Collectors.toList()); + } + + /** + * Returns XSLT executable of the application stylesheet composed with the stylesheets of the + * imported packages. Falls back to the executable of the stylesheet alone if the composed + * stylesheet fails to compile (e.g. a package stylesheet URL cannot be loaded). + * + * @param app application resource + * @param stylesheet stylesheet URI + * @param packages imported package URIs + * @return XSLT executable + */ + public XsltExecutable getXsltExecutable(com.atomgraph.linkeddatahub.apps.model.Application app, URI stylesheet, List packages) + { + try + { + URI key = getCacheKey(stylesheet, packages); + Map xsltExecCache = getXsltExecutableCache(); + + if (isCacheStylesheet()) + { + // create cache entry if it does not exist + if (!xsltExecCache.containsKey(key)) + xsltExecCache.put(key, getXsltExecutable(getComposedSource(app, stylesheet, packages))); + + return xsltExecCache.get(key); + } + + return getXsltExecutable(getComposedSource(app, stylesheet, packages)); + } + catch (SaxonApiException | IOException | ParserConfigurationException | SAXException ex) + { + if (log.isErrorEnabled()) log.error("Could not compile stylesheet '{}' composed with packages {}, falling back to the stylesheet alone", stylesheet, packages, ex); + return getXsltExecutable(stylesheet); + } + } + + /** + * Composes the application stylesheet document with the stylesheets of the imported packages. + * The stylesheet URLs are read from the package descriptions, which are resolved from the package + * URIs. The xsl:import elements are inserted after the last existing import, so package + * imports rank below the stylesheet's own declarations in import precedence. + * The source's system ID is the stylesheet's public URL, so its relative imports resolve on the + * application's origin. + * + * @param app application resource + * @param stylesheet stylesheet URI + * @param packages imported package URIs + * @return composed stylesheet source + * @throws IOException I/O error + * @throws ParserConfigurationException parser configuration error + * @throws SAXException XML parsing error + */ + public Source getComposedSource(com.atomgraph.linkeddatahub.apps.model.Application app, URI stylesheet, List packages) throws IOException, ParserConfigurationException, SAXException + { + Source source = getSource(stylesheet.toString()); + if (!(source instanceof StreamSource)) throw new IOException("XSLT stylesheet could not be loaded from URI: " + stylesheet); + + Document doc = SecureXML.newDocumentBuilderFactory().newDocumentBuilder().parse(((StreamSource)source).getInputStream()); + appendImports(doc, getStylesheets(packages)); + + return new DOMSource(doc, getPublicURI(app, stylesheet).toString()); + } + + /** + * Resolves the package descriptions and returns their stylesheet URLs, in package order. + * Packages whose description cannot be resolved, or without a stylesheet (ontology-only), + * are skipped. + * + * @param packages package URIs + * @return list of stylesheet URLs + */ + public List getStylesheets(List packages) + { + return packages.stream(). + map(pkg -> getPackage(pkg.toString())). + filter(Objects::nonNull). + map(com.atomgraph.linkeddatahub.apps.model.Package::getStylesheet). + filter(Objects::nonNull). + map(stylesheet -> URI.create(stylesheet.getURI())). + collect(Collectors.toList()); + } + + /** + * Loads the package description from its URI. + * Mapped locations (e.g. bundled package descriptions) and cached graphs are read from the graph + * repository; other URIs are dereferenced over HTTP. + * + * @param packageURI package URI + * @return package resource, or null if the description could not be resolved + */ + public com.atomgraph.linkeddatahub.apps.model.Package getPackage(String packageURI) + { + return getSystem().getPackage(packageURI); + } + + /** + * Appends xsl:import elements for the given stylesheet URLs to the stylesheet document, + * after the last existing import. + * + * @param doc stylesheet document + * @param imports stylesheet URLs to import + */ + public void appendImports(Document doc, List imports) + { + Element stylesheetElem = doc.getDocumentElement(); + + Node lastImport = null; + NodeList children = stylesheetElem.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) + { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE && + XSL_NS.equals(child.getNamespaceURI()) && + "import".equals(child.getLocalName())) + lastImport = child; + } + + for (URI importURI : imports) + { + Element newImport = doc.createElementNS(XSL_NS, "xsl:import"); + newImport.setAttribute("href", importURI.toString()); + + Node anchor = (lastImport != null) ? lastImport.getNextSibling() : stylesheetElem.getFirstChild(); + stylesheetElem.insertBefore(newImport, anchor); + lastImport = newImport; + } + } + + /** + * Maps the stylesheet URI to its public URL on the application's origin. + * The inverse of the absolutization of relative stylesheet URIs against the webapp root at context + * dataset parse time. URIs that are already HTTP(S), or fall outside the webapp root, are returned as-is. + * + * @param app application resource + * @param stylesheet stylesheet URI + * @return public stylesheet URL + * @throws MalformedURLException URL error + */ + public URI getPublicURI(com.atomgraph.linkeddatahub.apps.model.Application app, URI stylesheet) throws MalformedURLException + { + if ("http".equals(stylesheet.getScheme()) || "https".equals(stylesheet.getScheme())) return stylesheet; + + URI root = URI.create(getServletContext().getResource("/").toString()); + URI relative = root.relativize(stylesheet); + if (relative.isAbsolute()) return stylesheet; + + return app.getBaseURI().resolve(relative); + } + + /** + * Returns the cache key for a stylesheet composed with package imports. + * The key is derived from the stylesheet URI and the sorted package URIs, so a changed import set + * yields a new key and a fresh compilation on the next lookup. + * + * @param stylesheet stylesheet URI + * @param packages imported package URIs + * @return cache key + */ + public URI getCacheKey(URI stylesheet, List packages) + { + if (packages.isEmpty()) return stylesheet; + + try + { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + md.update(stylesheet.toString().getBytes(StandardCharsets.UTF_8)); + for (URI packageURI : packages.stream().sorted().collect(Collectors.toList())) + { + md.update((byte)'\n'); + md.update(packageURI.toString().getBytes(StandardCharsets.UTF_8)); + } + + return URI.create("urn:sha1:" + Hex.encodeHexString(md.digest())); + } + catch (NoSuchAlgorithmException ex) + { + throw new InternalServerErrorException(ex); + } + } + /** * Returns XSLT executable for the given stylesheet URI. * @@ -244,12 +465,22 @@ public Optional getApplicati /** * Returns URI info of the current request. - * + * * @return URI info */ public UriInfo getUriInfo() { return uriInfo; } - + + /** + * Returns servlet context. + * + * @return servlet context + */ + public ServletContext getServletContext() + { + return servletContext; + } + } diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java index c2bba34d18..811c41e2bf 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java @@ -18,8 +18,6 @@ import com.atomgraph.linkeddatahub.resource.Namespace; import com.atomgraph.linkeddatahub.resource.admin.ClearOntology; -import com.atomgraph.linkeddatahub.resource.admin.pkg.InstallPackage; -import com.atomgraph.linkeddatahub.resource.admin.pkg.UninstallPackage; import com.atomgraph.linkeddatahub.resource.Settings; import com.atomgraph.linkeddatahub.resource.admin.SignUp; import com.atomgraph.linkeddatahub.resource.acl.Access; @@ -142,28 +140,6 @@ public Class getClearEndpoint() return ClearOntology.class; } - /** - * Returns the endpoint for installing LinkedDataHub packages. - * - * @return endpoint resource - */ - @Path("packages/install") - public Class getInstallPackageEndpoint() - { - return InstallPackage.class; - } - - /** - * Returns the endpoint for uninstalling LinkedDataHub packages. - * - * @return endpoint resource - */ - @Path("packages/uninstall") - public Class getUninstallPackageEndpoint() - { - return UninstallPackage.class; - } - /** * Returns the endpoint for updating dataspace settings. * diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/DocumentHierarchyGraphStoreImpl.java b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/DocumentHierarchyGraphStoreImpl.java index 314ebc875c..f97d7d4ec3 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/DocumentHierarchyGraphStoreImpl.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/DocumentHierarchyGraphStoreImpl.java @@ -22,6 +22,7 @@ import com.atomgraph.core.model.EndpointAccessor; import com.atomgraph.core.riot.lang.RDFPostReader; import com.atomgraph.linkeddatahub.apps.model.EndUserApplication; +import com.atomgraph.linkeddatahub.client.GitHubClient; import com.atomgraph.linkeddatahub.client.GraphStoreClient; import com.atomgraph.linkeddatahub.model.CSVImport; import com.atomgraph.linkeddatahub.model.RDFImport; @@ -36,6 +37,7 @@ import com.atomgraph.linkeddatahub.vocabulary.LDH; import com.atomgraph.linkeddatahub.vocabulary.NFO; import com.atomgraph.linkeddatahub.vocabulary.SIOC; +import com.atomgraph.linkeddatahub.writer.TimeMapWriter; import static com.atomgraph.server.status.UnprocessableEntityStatus.UNPROCESSABLE_ENTITY; import java.net.URI; import java.net.URISyntaxException; @@ -78,6 +80,8 @@ import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; +import java.time.DateTimeException; +import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.security.DigestInputStream; @@ -146,6 +150,17 @@ public class DocumentHierarchyGraphStoreImpl extends com.atomgraph.core.model.im * Name of the query parameter that retrieves the graph's version history as a Memento TimeMap. */ public static final String TIMEMAP_PARAM_NAME = "timemap"; + + /** + * Name of the query parameter that addresses the graph's Memento TimeGate, which negotiates on + * Accept-Datetime. + */ + public static final String TIMEGATE_PARAM_NAME = "timegate"; + + /** + * Name of the request header conveying the datetime a TimeGate negotiates on. + */ + public static final String ACCEPT_DATETIME_HEADER = "Accept-Datetime"; private final com.atomgraph.linkeddatahub.apps.model.Application application; private final OntModel ontology; @@ -159,6 +174,7 @@ public class DocumentHierarchyGraphStoreImpl extends com.atomgraph.core.model.im private final SecurityContext securityContext; private final Optional agentContext; private final Set allowedMethods; + private final HttpHeaders httpHeaders; /** * Constructs Graph Store. @@ -173,12 +189,13 @@ public class DocumentHierarchyGraphStoreImpl extends com.atomgraph.core.model.im * @param agentContext authenticated agent's context * @param providers registry of JAX-RS providers * @param system system application + * @param httpHeaders request headers */ @Inject public DocumentHierarchyGraphStoreImpl(@Context Request request, @Context UriInfo uriInfo, MediaTypes mediaTypes, com.atomgraph.linkeddatahub.apps.model.Application application, Optional ontology, Optional service, @Context SecurityContext securityContext, Optional agentContext, - @Context Providers providers, com.atomgraph.linkeddatahub.Application system) + @Context Providers providers, com.atomgraph.linkeddatahub.Application system, @Context HttpHeaders httpHeaders) { super(request, system.getServiceContext(service.get()).getGraphStoreClient(), mediaTypes, uriInfo); if (ontology.isEmpty()) throw new InternalServerErrorException("Ontology is not specified"); @@ -190,6 +207,7 @@ public DocumentHierarchyGraphStoreImpl(@Context Request request, @Context UriInf this.agentContext = agentContext; this.providers = providers; this.system = system; + this.httpHeaders = httpHeaders; this.messageDigest = system.getMessageDigest(); uploadsUriBuilder = uriInfo.getBaseUriBuilder().path(UPLOADS_PATH); URI ownerURI = URI.create(application.getMaker().getURI()); @@ -217,18 +235,30 @@ public DocumentHierarchyGraphStoreImpl(@Context Request request, @Context UriInf !secretaryDocURI.equals(uri)) allowedMethods.add(HttpMethod.DELETE); - // historical version and TimeMap views are read-only - if (uriInfo.getQueryParameters().containsKey(VERSION_PARAM_NAME) || uriInfo.getQueryParameters().containsKey(TIMEMAP_PARAM_NAME)) - allowedMethods.retainAll(Set.of(HttpMethod.GET)); + // historical version, TimeMap and TimeGate views are read-only + if (isSnapshotRequest(uriInfo)) allowedMethods.retainAll(Set.of(HttpMethod.GET)); } /** - * Rejects the request if it addresses a historical version or TimeMap view, which are read-only. + * Returns true if the request addresses a historical version, a TimeMap or a TimeGate rather than the + * document itself. + * + * @param uriInfo URI info of the request + * @return true if this is a Memento, TimeMap or TimeGate request + */ + public static boolean isSnapshotRequest(UriInfo uriInfo) + { + return uriInfo.getQueryParameters().containsKey(VERSION_PARAM_NAME) || + uriInfo.getQueryParameters().containsKey(TIMEMAP_PARAM_NAME) || + uriInfo.getQueryParameters().containsKey(TIMEGATE_PARAM_NAME); + } + + /** + * Rejects the request if it addresses a historical version, TimeMap or TimeGate view, which are read-only. */ private void checkSnapshotReadOnly() { - if (getUriInfo().getQueryParameters().containsKey(VERSION_PARAM_NAME) || getUriInfo().getQueryParameters().containsKey(TIMEMAP_PARAM_NAME)) - throw new NotAllowedException(HttpMethod.GET, new String[]{ HttpMethod.OPTIONS }); + if (isSnapshotRequest(getUriInfo())) throw new NotAllowedException(HttpMethod.GET, new String[]{ HttpMethod.OPTIONS }); } /** @@ -242,13 +272,58 @@ private void checkSnapshotReadOnly() @GET public Response get() { + if (getUriInfo().getQueryParameters().containsKey(TIMEGATE_PARAM_NAME)) + { + String acceptDatetime = getHttpHeaders().getHeaderString(ACCEPT_DATETIME_HEADER); + final Instant datetime; + try + { + // parse leniently (RFC 1123 permits an unpadded day and numeric offsets), format strictly + datetime = acceptDatetime != null ? Instant.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(acceptDatetime)) : null; + } + catch (DateTimeException ex) + { + throw new BadRequestException("Value '" + acceptDatetime + "' of the '" + ACCEPT_DATETIME_HEADER + "' header is not an RFC 1123 datetime"); + } + + GitHubClient.CommitInfo commit = getSystem().getGraphVersioningService(). + getMemento(getApplication().getURI(), getApplication().getBaseURI(), getURI(), datetime). + orElseThrow(() -> new NotFoundException("Document <" + getURI() + "> has no version history")); + + // negotiation has to see the current history, and Accept-Datetime takes unbounded values, so the + // redirect is not stored: a cached one would outlive the commit that made it the most recent + CacheControl noStore = new CacheControl(); + noStore.setNoStore(true); + + // a 302 TimeGate response carries no Memento-Datetime; the Memento it points at does + return Response.status(Response.Status.FOUND). + location(getUriInfo().getAbsolutePathBuilder().queryParam(VERSION_PARAM_NAME, commit.sha()).build()). + header(HttpHeaders.VARY, ACCEPT_DATETIME_HEADER.toLowerCase(Locale.ROOT)). + cacheControl(noStore). + build(); + } + if (getUriInfo().getQueryParameters().containsKey(TIMEMAP_PARAM_NAME)) { Model timeMap = getSystem().getGraphVersioningService(). getTimeMap(getApplication().getURI(), getApplication().getBaseURI(), getURI()). - orElseThrow(() -> new NotFoundException("Document <" + getURI() + "> is not versioned")); - - return getResponseBuilder(timeMap, getURI()).build(); + orElseThrow(() -> new NotFoundException("Document <" + getURI() + "> has no version history")); + + // link-format is only offered on the TimeMap, where it is meaningful; it leads the list so that + // Accept: */* clients get the RFC 7089 serialization, while browsers still resolve to HTML on q-value + List timeMapMediaTypes = new ArrayList<>(); + timeMapMediaTypes.add(TimeMapWriter.APPLICATION_LINK_FORMAT_TYPE); + timeMapMediaTypes.addAll(getWritableMediaTypes(Model.class)); + + return new com.atomgraph.core.model.impl.Response(getRequest(), + timeMap, + null, + getEntityTag(timeMap), + timeMapMediaTypes, + getLanguages(), + getEncodings(), + new HTMLMediaTypePredicate()). + getResponseBuilder().build(); } String version = getUriInfo().getQueryParameters().getFirst(VERSION_PARAM_NAME); @@ -269,7 +344,7 @@ public Response get() tag(new EntityTag(version)). cacheControl(cacheControl); if (graphVersion.datetime() != null) - rb.header("Memento-Datetime", DateTimeFormatter.RFC_1123_DATE_TIME.format(graphVersion.datetime().atZone(ZoneId.of("GMT")))); + rb.header("Memento-Datetime", TimeMapWriter.RFC_1123_GMT.format(graphVersion.datetime().atZone(ZoneId.of("GMT")))); return rb.build(); } @@ -1169,7 +1244,17 @@ public com.atomgraph.linkeddatahub.Application getSystem() { return system; } - + + /** + * Returns the request headers. + * + * @return HTTP headers + */ + public HttpHeaders getHttpHeaders() + { + return httpHeaders; + } + /** * Returns URI of the WebID document of the applications owner. * diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/GraphVersioningService.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/GraphVersioningService.java index a6995328be..f60a371854 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/util/GraphVersioningService.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/GraphVersioningService.java @@ -21,7 +21,7 @@ import com.atomgraph.linkeddatahub.model.ServiceContext; import com.atomgraph.linkeddatahub.vocabulary.GitHub; import com.atomgraph.linkeddatahub.vocabulary.LAPP; -import com.atomgraph.linkeddatahub.vocabulary.MEM; +import com.atomgraph.linkeddatahub.vocabulary.PROV; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.client.Client; import java.io.ByteArrayInputStream; @@ -29,8 +29,10 @@ import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.time.Instant; import java.util.Arrays; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -223,12 +225,49 @@ public Optional getVersion(String appURI, URI appBase, URI graphURI, St public record Version(Model model, Instant datetime) { } /** - * Retrieves a graph's version history as a Memento TimeMap model. + * Selects the graph's Memento for a requested datetime, as a TimeGate does. * * @param appURI application URI * @param appBase application base URI * @param graphURI graph (document) URI - * @return TimeMap model, or empty if the application is not versioned + * @param datetime the requested datetime, or null for the most recent Memento + * @return the selected commit, or empty if the application is not versioned or the graph has no history + */ + public Optional getMemento(String appURI, URI appBase, URI graphURI, Instant datetime) + { + Repository repository = repositories.get(appURI); + if (repository == null) return Optional.empty(); + + String path = path(repository.pathPrefix(), appBase, graphURI); + return selectMemento(repository.client().listCommits(path), datetime); + } + + /** + * Selects the commit closest in time to a requested datetime. + * RFC 7089 leaves the algorithm to the server but requires it to be consistent: this one takes the + * smallest absolute distance, resolving ties towards the more recent Memento. With no datetime + * requested the most recent Memento is selected. + * + * @param commits commit history, most recent first + * @param datetime the requested datetime, or null for the most recent commit + * @return the selected commit, or empty if there is no history + */ + public static Optional selectMemento(List commits, Instant datetime) + { + if (commits.isEmpty()) return Optional.empty(); + if (datetime == null) return Optional.of(commits.get(0)); + + // min() keeps the first of equally distant commits, and the most recent one comes first + return commits.stream().min(Comparator.comparing(commit -> Duration.between(commit.datetime(), datetime).abs())); + } + + /** + * Retrieves a graph's version history as a TimeMap model. + * + * @param appURI application URI + * @param appBase application base URI + * @param graphURI graph (document) URI + * @return TimeMap model, or empty if the application is not versioned or the graph has no history */ public Optional getTimeMap(String appURI, URI appBase, URI graphURI) { @@ -236,12 +275,19 @@ public Optional getTimeMap(String appURI, URI appBase, URI graphURI) if (repository == null) return Optional.empty(); String path = path(repository.pathPrefix(), appBase, graphURI); - return Optional.of(toTimeMap(graphURI, repository.client().listCommits(path))); + List commits = repository.client().listCommits(path); + // a TimeMap is a list of Mementos; without any, there is no history to describe and the Original Resource + // would not be derivable from the model either (it is reached through prov:specializationOf) + if (commits.isEmpty()) return Optional.empty(); + + return Optional.of(toTimeMap(graphURI, commits)); } /** - * Builds a Memento TimeMap model from a graph's commit history. - * Memento URIs use the version query parameter with the commit SHA. + * Builds a TimeMap model from a graph's commit history, described with PROV-O. + * The TimeMap is a prov:Collection of Mementos, each a prov:Entity that is a + * prov:specializationOf the Original Resource. Memento URIs use the version + * query parameter with the commit SHA. * * @param graphURI graph (document) URI * @param commits commit history, most recent first @@ -250,22 +296,24 @@ public Optional getTimeMap(String appURI, URI appBase, URI graphURI) public static Model toTimeMap(URI graphURI, List commits) { Model model = ModelFactory.createDefaultModel(); - Resource original = model.createResource(graphURI.toString()). - addProperty(RDF.type, MEM.OriginalResource); + Resource original = model.createResource(graphURI.toString()); Resource timeMap = model.createResource(graphURI + "?timemap"). - addProperty(RDF.type, MEM.TimeMap). - addProperty(MEM.original, original); - original.addProperty(MEM.timemap, timeMap); + addProperty(RDF.type, PROV.Collection); + Resource successor = null; // the memento of the next-more-recent commit that touched this file for (GitHubClient.CommitInfo commit : commits) { Resource memento = model.createResource(graphURI + "?version=" + commit.sha()). - addProperty(RDF.type, MEM.Memento). - addProperty(MEM.original, original). - addProperty(MEM.mementoDatetime, model.createTypedLiteral(commit.datetime().toString(), XSDDatatype.XSDdateTime)); + addProperty(RDF.type, PROV.Entity). + addProperty(PROV.specializationOf, original). + addProperty(PROV.generatedAtTime, model.createTypedLiteral(commit.datetime().toString(), XSDDatatype.XSDdateTime)); if (isAbsoluteURI(commit.authorName())) memento.addProperty(DCTerms.creator, model.createResource(commit.authorName())); - original.addProperty(MEM.memento, memento); + timeMap.addProperty(PROV.hadMember, memento); + // the commit list is filtered by path, so adjacent entries are adjacent revisions of this graph + // (unlike git parents, which are repository-wide and usually did not touch the file) + if (successor != null) successor.addProperty(PROV.wasRevisionOf, memento); + successor = memento; } return model; diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/Link.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/Link.java new file mode 100644 index 0000000000..7c4d552adc --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/Link.java @@ -0,0 +1,54 @@ +/** + * Copyright 2026 Martynas Jusevičius + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.atomgraph.linkeddatahub.server.util; + +import java.net.URI; + +/** + * Link header value that quotes its type parameter. + * RFC 8288 allows a parameter value to be either a token or a quoted-string. A media type such as + * application/link-format contains a solidus, which is not a tchar, so it + * can only be conveyed as a quoted-string. + * + * @author Martynas Jusevičius {@literal } + * @see RFC 8288: Web Linking + */ +public class Link extends com.atomgraph.core.util.Link +{ + + /** + * Constructs a link from a target URI, relation type and media type. + * + * @param href target URI + * @param rel relation type + * @param type media type of the target, or null + */ + public Link(URI href, String rel, String type) + { + super(href, rel, type); + } + + @Override + public String toString() + { + StringBuilder builder = new StringBuilder("<"); + builder.append(getHref()).append(">; rel=").append(getRel()); + if (getType() != null) builder.append("; type=\"").append(getType()).append("\""); + return builder.toString(); + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolver.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolver.java new file mode 100644 index 0000000000..1f6745df8a --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolver.java @@ -0,0 +1,124 @@ +/** + * Copyright 2026 Martynas Jusevičius + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.atomgraph.linkeddatahub.server.util; + +import com.atomgraph.client.util.StylesheetResolver; +import com.atomgraph.linkeddatahub.vocabulary.LAPP; +import jakarta.servlet.ServletContext; +import jakarta.ws.rs.client.Client; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import javax.xml.transform.Source; +import javax.xml.transform.TransformerException; +import javax.xml.transform.stream.StreamSource; +import org.apache.commons.io.IOUtils; +import org.apache.jena.rdf.model.Resource; + +/** + * Resolves {@code xsl:import}/{@code xsl:include} URLs on the origins of this instance's applications + * to local webapp resources, avoiding HTTP round-trips back into the same server during XSLT compilation. + * URLs under /static/ of a known application origin are read via {@link ServletContext}, + * keeping the URL as the source's system ID so that nested relative imports stay on the origin and + * identical modules deduplicate regardless of which stylesheet imported them. + * All other locations are delegated to {@link StylesheetResolver}. + * + * @author Martynas Jusevičius {@literal } + */ +public class LocalStylesheetResolver extends StylesheetResolver +{ + + private static final String STATIC_PATH = "/static/"; + + private final com.atomgraph.linkeddatahub.Application system; + private final ServletContext servletContext; + + /** + * Constructs the resolver. + * + * @param system system application + * @param servletContext servlet context of the webapp + * @param client SSL-configured JAX-RS client for HTTP(S) stylesheet retrieval + */ + public LocalStylesheetResolver(com.atomgraph.linkeddatahub.Application system, ServletContext servletContext, Client client) + { + super(client); + this.system = system; + this.servletContext = servletContext; + } + + @Override + public Source resolve(String href, String base) throws TransformerException + { + URI baseURI = URI.create(base); + URI uri = href.isEmpty() ? baseURI : baseURI.resolve(href); + + if (("http".equals(uri.getScheme()) || "https".equals(uri.getScheme())) && + uri.getPath() != null && uri.getPath().startsWith(STATIC_PATH) && + getApp(uri) != null) + { + try (InputStream is = getServletContext().getResourceAsStream(uri.getPath())) + { + if (is != null) + { + // buffer the bytes so the stream can be closed + byte[] bytes = IOUtils.toByteArray(is); + return new StreamSource(new ByteArrayInputStream(bytes), uri.toString()); + } + } + catch (IOException ex) + { + throw new TransformerException(ex); + } + } + + return super.resolve(href, base); + } + + /** + * Matches an application of this instance by the URI's origin. + * + * @param uri stylesheet URI + * @return application resource or null, if none matched + */ + public Resource getApp(URI uri) + { + return getSystem().getAppByOrigin(getSystem().getContextModel(), LAPP.Application, uri); + } + + /** + * Returns system application. + * + * @return JAX-RS application + */ + public com.atomgraph.linkeddatahub.Application getSystem() + { + return system; + } + + /** + * Returns servlet context. + * + * @return servlet context + */ + public ServletContext getServletContext() + { + return servletContext; + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java deleted file mode 100644 index d193455dc9..0000000000 --- a/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Copyright 2025 Martynas Jusevičius - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.atomgraph.linkeddatahub.server.util; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import jakarta.servlet.ServletContext; -import javax.xml.XMLConstants; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.transform.OutputKeys; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; -import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.Paths; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.transform.TransformerException; -import org.w3c.dom.DOMException; -import org.xml.sax.SAXException; - -/** - * Updates master XSLT stylesheets with package import chains. - * Writes master stylesheets to the webapp's /static/ directory. - * - * @author Martynas Jusevičius {@literal } - */ -public class XSLTMasterUpdater -{ - private static final Logger log = LoggerFactory.getLogger(XSLTMasterUpdater.class); - - private static final String XSL_NS = "http://www.w3.org/1999/XSL/Transform"; - - private final ServletContext servletContext; - - /** - * Constructs updater with servlet context. - * - * @param servletContext the servlet context - */ - public XSLTMasterUpdater(ServletContext servletContext) - { - this.servletContext = servletContext; - } - - /** - * Adds a package import to the master stylesheet, preserving all existing content. - * Inserts a new xsl:import after the last existing import element. - * - * @param packagePath the package path (e.g., "com/linkeddatahub/packages/skos") - * @throws IOException if file operations fail - */ - public void addPackageImport(String packagePath) throws IOException - { - addPackageImport(getStaticPath().resolve("xsl").resolve("layout.xsl"), packagePath); - } - - /** - * Adds a package import to the specified master stylesheet, preserving all existing content. - * - * @param masterFile path to the master stylesheet - * @param packagePath the package path (e.g., "com/linkeddatahub/packages/skos") - * @throws IOException if file operations fail - */ - public void addPackageImport(Path masterFile, String packagePath) throws IOException - { - try - { - Document doc = parseDocument(masterFile); - Element stylesheet = doc.getDocumentElement(); - String href = "../" + packagePath + "/layout.xsl"; - - // Find the last xsl:import child element as insertion anchor, checking for duplicates - Node lastImport = null; - NodeList children = stylesheet.getChildNodes(); - for (int i = 0; i < children.getLength(); i++) - { - Node child = children.item(i); - if (child.getNodeType() == Node.ELEMENT_NODE - && XSL_NS.equals(child.getNamespaceURI()) - && "import".equals(child.getLocalName())) - { - if (href.equals(((Element) child).getAttribute("href"))) - { - if (log.isWarnEnabled()) log.warn("xsl:import href=\"{}\" already present in master stylesheet, skipping", href); - return; - } - lastImport = child; - } - } - - Element newImport = doc.createElementNS(XSL_NS, "xsl:import"); - newImport.setAttribute("href", href); - - if (lastImport != null) - { - // Capture anchor before any insertion — getNextSibling() shifts after insertBefore - Node anchor = lastImport.getNextSibling(); - stylesheet.insertBefore(newImport, anchor); - stylesheet.insertBefore(doc.createTextNode("\n "), newImport); - } - else - { - // No existing imports — prepend at start of stylesheet - Node firstChild = stylesheet.getFirstChild(); - stylesheet.insertBefore(newImport, firstChild); - stylesheet.insertBefore(doc.createTextNode("\n "), newImport); - } - - serializeDocument(doc, masterFile); - - if (log.isDebugEnabled()) log.debug("Added xsl:import href=\"{}\" to master stylesheet: {}", href, masterFile); - } - catch (ParserConfigurationException | SAXException | TransformerException | DOMException e) - { - throw new IOException("Failed to add package import to master stylesheet", e); - } - } - - /** - * Removes a package import from the master stylesheet, preserving all other content. - * - * @param packagePath the package path (e.g., "com/linkeddatahub/packages/skos") - * @throws IOException if file operations fail - */ - public void removePackageImport(String packagePath) throws IOException - { - removePackageImport(getStaticPath().resolve("xsl").resolve("layout.xsl"), packagePath); - } - - /** - * Removes a package import from the specified master stylesheet, preserving all other content. - * - * @param masterFile path to the master stylesheet - * @param packagePath the package path (e.g., "com/linkeddatahub/packages/skos") - * @throws IOException if file operations fail - */ - public void removePackageImport(Path masterFile, String packagePath) throws IOException - { - try - { - Document doc = parseDocument(masterFile); - Element stylesheet = doc.getDocumentElement(); - String href = "../" + packagePath + "/layout.xsl"; - - // Find and remove the matching xsl:import element - Node targetImport = null; - NodeList children = stylesheet.getChildNodes(); - for (int i = 0; i < children.getLength(); i++) - { - Node child = children.item(i); - if (child.getNodeType() == Node.ELEMENT_NODE - && XSL_NS.equals(child.getNamespaceURI()) - && "import".equals(child.getLocalName()) - && href.equals(((Element) child).getAttribute("href"))) - { - targetImport = child; - break; - } - } - - if (targetImport == null) - { - if (log.isWarnEnabled()) log.warn("xsl:import href=\"{}\" not found in master stylesheet: {}", href, masterFile); - return; - } - - // Also remove the preceding text node (whitespace/newline) if present - Node prev = targetImport.getPreviousSibling(); - if (prev != null && prev.getNodeType() == Node.TEXT_NODE) - stylesheet.removeChild(prev); - - stylesheet.removeChild(targetImport); - - serializeDocument(doc, masterFile); - - if (log.isDebugEnabled()) log.debug("Removed xsl:import href=\"{}\" from master stylesheet: {}", href, masterFile); - } - catch (ParserConfigurationException | SAXException | TransformerException | DOMException e) - { - throw new IOException("Failed to remove package import from master stylesheet", e); - } - } - - private Document parseDocument(Path file) throws ParserConfigurationException, SAXException, IOException - { - DocumentBuilder builder = SecureXML.newDocumentBuilderFactory().newDocumentBuilder(); - return builder.parse(file.toFile()); - } - - private void serializeDocument(Document doc, Path file) throws TransformerException - { - TransformerFactory transformerFactory = TransformerFactory.newInstance(); - transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - Transformer transformer = transformerFactory.newTransformer(); - transformer.setOutputProperty(OutputKeys.INDENT, "no"); - transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); - transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); - - DOMSource source = new DOMSource(doc); - StreamResult result = new StreamResult(file.toFile()); - transformer.transform(source, result); - } - - /** - * Gets the path to the webapp's /static/ directory. - * - * @return path to static directory - */ - private Path getStaticPath() - { - String realPath = getServletContext().getRealPath("/static"); - if (realPath == null) - throw new IllegalStateException("Could not resolve real path for /static directory"); - return Paths.get(realPath); - } - - /** - * Returns servlet context. - * - * @return servlet context - */ - public ServletContext getServletContext() - { - return servletContext; - } - -} diff --git a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/MEM.java b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/MEM.java deleted file mode 100644 index bbbd677469..0000000000 --- a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/MEM.java +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Copyright 2026 Martynas Jusevičius - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.atomgraph.linkeddatahub.vocabulary; - -import org.apache.jena.ontapi.OntModelFactory; -import org.apache.jena.ontapi.OntSpecification; -import org.apache.jena.ontapi.model.OntModel; -import org.apache.jena.rdf.model.Property; -import org.apache.jena.rdf.model.Resource; - -/** - * Memento vocabulary (RFC 7089). - * - * @author Martynas Jusevičius {@literal } - * @see RFC 7089: HTTP Framework for Time-Based Access to Resource States -- Memento - */ -public class MEM -{ - - static - { - org.apache.jena.sys.JenaSystem.init(); // ensure Jena (RDFS vocab) is initialized before ontapi touches it - } - - /** The RDF model that holds the vocabulary terms */ - private static OntModel m_model = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM); - - /** The namespace of the vocabulary as a string */ - public static final String NS = "http://mementoweb.org/ns#"; - - /** - * The namespace of the vocabulary as a string - * - * @return namespace URI - * @see #NS - */ - public static String getURI() - { - return NS; - } - - /** The namespace of the vocabulary as a resource */ - public static final Resource NAMESPACE = m_model.createResource( NS ); - - /** Original resource class */ - public static final Resource OriginalResource = m_model.createOntClass( NS + "OriginalResource" ); - - /** Memento class */ - public static final Resource Memento = m_model.createOntClass( NS + "Memento" ); - - /** TimeMap class */ - public static final Resource TimeMap = m_model.createOntClass( NS + "TimeMap" ); - - /** Original resource property */ - public static final Property original = m_model.createObjectProperty( NS + "original" ); - - /** Memento property */ - public static final Property memento = m_model.createObjectProperty( NS + "memento" ); - - /** Timemap property */ - public static final Property timemap = m_model.createObjectProperty( NS + "timemap" ); - - /** Memento datetime property */ - public static final Property mementoDatetime = m_model.createDataProperty( NS + "mementoDatetime" ); - -} diff --git a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/PROV.java b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/PROV.java index fb4655c533..0a79bcb028 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/PROV.java +++ b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/PROV.java @@ -64,6 +64,18 @@ public static String getURI() /** Agent class */ public static final Resource Agent = m_model.createOntClass( NS + "Agent" ); + /** Collection class */ + public static final Resource Collection = m_model.createOntClass( NS + "Collection" ); + + /** Had member property */ + public static final Property hadMember = m_model.createObjectProperty( NS + "hadMember" ); + + /** Specialization of property */ + public static final Property specializationOf = m_model.createObjectProperty( NS + "specializationOf" ); + + /** Was revision of property */ + public static final Property wasRevisionOf = m_model.createObjectProperty( NS + "wasRevisionOf" ); + /** Was attributed to property */ public static final Property wasAttributedTo = m_model.createObjectProperty( NS + "wasAttributedTo" ); diff --git a/src/main/java/com/atomgraph/linkeddatahub/writer/TimeMapWriter.java b/src/main/java/com/atomgraph/linkeddatahub/writer/TimeMapWriter.java new file mode 100644 index 0000000000..838a4ce7d0 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/writer/TimeMapWriter.java @@ -0,0 +1,195 @@ +/** + * Copyright 2026 Martynas Jusevičius + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.atomgraph.linkeddatahub.writer; + +import com.atomgraph.linkeddatahub.server.model.impl.DocumentHierarchyGraphStoreImpl; +import com.atomgraph.linkeddatahub.vocabulary.PROV; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.ext.MessageBodyWriter; +import jakarta.ws.rs.ext.Provider; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResIterator; +import org.apache.jena.vocabulary.RDF; + +/** + * Serializes a TimeMap model as application/link-format, the serialization RFC 7089 requires + * TimeMaps to support. The model is the PROV-O description built by + * {@link com.atomgraph.linkeddatahub.server.util.GraphVersioningService#toTimeMap}: the TimeMap is the + * prov:Collection, its prov:hadMember values are the Mementos, and the Original + * Resource is the prov:specializationOf target they share. + * + * @author Martynas Jusevičius {@literal } + * @see RFC 7089: TimeMap + * @see RFC 6690: Link Format + */ +@Provider +@Produces(TimeMapWriter.APPLICATION_LINK_FORMAT) +public class TimeMapWriter implements MessageBodyWriter +{ + + @Context private UriInfo uriInfo; + + /** The link-format media type as a string */ + public static final String APPLICATION_LINK_FORMAT = "application/link-format"; + + /** The link-format media type */ + public static final MediaType APPLICATION_LINK_FORMAT_TYPE = new MediaType("application", "link-format"); + + /** + * Datetime format required by RFC 7089. Not {@link DateTimeFormatter#RFC_1123_DATE_TIME}, which leaves the + * day of month unpadded, while the RFC 7089 grammar specifies date1 = 2DIGIT SP month SP 4DIGIT. + */ + public static final DateTimeFormatter RFC_1123_GMT = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US); + + @Override + public boolean isWriteable(Class type, Type genericType, Annotation[] annotations, MediaType mediaType) + { + return Model.class.isAssignableFrom(type); + } + + @Override + public void writeTo(Model model, Class type, Type genericType, Annotation[] annotations, MediaType mediaType, + MultivaluedMap httpHeaders, OutputStream entityStream) throws IOException, WebApplicationException + { + Resource timeMap = getTimeMap(model); + List mementos = getMementos(timeMap); + if (mementos.isEmpty()) throw new IllegalStateException("TimeMap <" + timeMap.getURI() + "> has no mementos"); + + Resource original = mementos.get(0).getPropertyResourceValue(PROV.specializationOf); + Resource first = mementos.get(0), last = mementos.get(mementos.size() - 1); + + List links = new ArrayList<>(); + links.add("<" + original.getURI() + ">;rel=\"original\""); + links.add("<" + timeMap.getURI() + ">;rel=\"self\";type=\"" + APPLICATION_LINK_FORMAT + "\"" + + ";from=\"" + datetime(first) + "\";until=\"" + datetime(last) + "\""); + // the TimeGate is deployment hypermedia rather than part of the version history, so it comes from the request + getTimeGateURI().ifPresent(timeGate -> links.add("<" + timeGate + ">;rel=\"timegate\"")); + + for (Resource memento : mementos) + { + // a single memento is both the first and the last one known + List rels = new ArrayList<>(); + if (memento.equals(first)) rels.add("first"); + if (memento.equals(last)) rels.add("last"); + rels.add("memento"); + + links.add("<" + memento.getURI() + ">;rel=\"" + String.join(" ", rels) + "\";datetime=\"" + datetime(memento) + "\""); + } + + Writer writer = new OutputStreamWriter(entityStream, StandardCharsets.UTF_8); + writer.write(String.join(",\n", links)); + writer.flush(); + } + + /** + * Returns the TimeGate URI of the resource being served, if there is a request to derive it from. + * + * @return TimeGate URI, or empty outside a request + */ + protected Optional getTimeGateURI() + { + if (getUriInfo() == null) return Optional.empty(); + + return Optional.of(URI.create(getUriInfo().getAbsolutePath() + "?" + DocumentHierarchyGraphStoreImpl.TIMEGATE_PARAM_NAME)); + } + + /** + * Returns the URI info of the current request. + * + * @return URI info, or null outside a request + */ + public UriInfo getUriInfo() + { + return uriInfo; + } + + /** + * Returns the TimeMap resource of the model. + * + * @param model TimeMap model + * @return the prov:Collection resource + */ + protected Resource getTimeMap(Model model) + { + ResIterator it = model.listResourcesWithProperty(RDF.type, PROV.Collection); + try + { + if (!it.hasNext()) throw new IllegalStateException("Model does not contain a prov:Collection TimeMap resource"); + return it.next(); + } + finally + { + it.close(); + } + } + + /** + * Returns the TimeMap's mementos, oldest first. + * + * @param timeMap TimeMap resource + * @return memento resources ordered by generation time + */ + protected List getMementos(Resource timeMap) + { + List mementos = new ArrayList<>(); + timeMap.listProperties(PROV.hadMember).forEachRemaining(stmt -> + { + RDFNode member = stmt.getObject(); + if (member.isURIResource()) mementos.add(member.asResource()); + }); + + mementos.sort(Comparator.comparing(memento -> Instant.parse(memento.getProperty(PROV.generatedAtTime).getString()))); + return mementos; + } + + /** + * Formats a memento's generation time as an RFC 1123 datetime. + * + * @param memento memento resource + * @return datetime in RFC 1123 format + */ + protected String datetime(Resource memento) + { + return RFC_1123_GMT.format( + ZonedDateTime.parse(memento.getProperty(PROV.generatedAtTime).getString()).withZoneSameInstant(ZoneId.of("GMT"))); + } + +} diff --git a/src/main/resources/com/linkeddatahub/packages/packages.ttl b/src/main/resources/com/linkeddatahub/packages/packages.ttl new file mode 100644 index 0000000000..5b8c2c80ae --- /dev/null +++ b/src/main/resources/com/linkeddatahub/packages/packages.ttl @@ -0,0 +1,12 @@ +@base . +@prefix rdfs: . +@prefix dct: . +@prefix foaf: . + + a foaf:Document ; + dct:title "LinkedDataHub packages" ; + dct:description "Catalog of packages available for LinkedDataHub applications" ; + rdfs:member . + + dct:title "SKOS" ; + dct:description "Simple Knowledge Organization System vocabulary support with custom templates for concept hierarchies, schemes, and collections" . diff --git a/src/main/resources/com/linkeddatahub/packages/skos/layout.xsl b/src/main/resources/com/linkeddatahub/packages/skos/layout.xsl deleted file mode 100644 index b38b64851a..0000000000 --- a/src/main/resources/com/linkeddatahub/packages/skos/layout.xsl +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - -]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/prefix-mapping.ttl b/src/main/resources/prefix-mapping.ttl index cf4565809f..f9ed70c1fa 100644 --- a/src/main/resources/prefix-mapping.ttl +++ b/src/main/resources/prefix-mapping.ttl @@ -72,6 +72,7 @@ [ lm:prefix "http://spinrdf.org/sp" ; lm:altName "etc/sp.ttl" ] , [ lm:prefix "http://spinrdf.org/spin" ; lm:altName "etc/spin.ttl" ] , [ lm:prefix "http://spinrdf.org/spl" ; lm:altName "etc/spl.spin.ttl" ] , + [ lm:prefix "https://packages.linkeddatahub.com/" ; lm:altName "com/linkeddatahub/packages/packages.ttl" ] , [ lm:prefix "https://packages.linkeddatahub.com/skos/" ; lm:altName "com/linkeddatahub/packages/skos/package.ttl" ] , [ lm:prefix "https://raw.githubusercontent.com/AtomGraph/LinkedDataHub-Apps/refs/heads/master/packages/skos/ns.ttl" ; lm:altName "com/linkeddatahub/packages/skos/ns.ttl" ] . \ No newline at end of file diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css index 4321cd5542..0f7583dda5 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css @@ -126,6 +126,11 @@ li button.btn-edit-constructors, li button.btn-add-data, li button.btn-add-ontol .content-body > [about].row-fluid.block { overflow-x: auto; margin-bottom: 20px; } .content-body > [about].row-fluid.block, .constructor-triple.row-fluid { border-bottom: 2px solid rgb(223, 223, 223); } .content-body > [about].row-fluid.block.drag-over { border-bottom: 4px dotted #0f82f5; } +.content-body > [about].row-fluid.block.diff-added { border: 2px solid #3fb618; } +.content-body > [about].row-fluid.block.diff-removed { border: 2px solid #ff0039; } +.content-body > [about].row-fluid.block.diff-changed { border: 2px solid #ff7518; } +dd.diff-added, .main > div.diff-added { border-left: 4px solid #3fb618; padding-left: 8px; } +dd.diff-removed, .main > div.diff-removed { border-left: 4px solid #ff0039; padding-left: 8px; } .row-fluid.block { max-height: 80em; } .row-fluid.block .drag-handle { display: none; width: 30px; background-color: #149bdf; background-image: radial-gradient(circle at 3px 3px, #0480be 1px, transparent 1.5px); background-size: 6px 6px; border-radius: 2px; cursor: move; } .list-mode.active { background-image: url('../icons/ic_navigate_before_black_24px.svg'); } diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl index 514ff7b1ba..173b274e9f 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl @@ -364,6 +364,20 @@ exclude-result-prefixes="#all" + + + + + + + + + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl index 86a2526bca..3eb915b80f 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl @@ -538,7 +538,6 @@ exclude-result-prefixes="#all" - @@ -593,7 +592,7 @@ exclude-result-prefixes="#all" - + @@ -608,7 +607,7 @@ exclude-result-prefixes="#all" - + @@ -617,17 +616,13 @@ exclude-result-prefixes="#all" - + - - - - - + - + @@ -718,11 +713,9 @@ exclude-result-prefixes="#all" - - + + - - - + \ No newline at end of file diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl index 81913d6376..30fc3973a0 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl @@ -101,13 +101,16 @@ WHERE + + + - + - + @@ -978,6 +981,7 @@ WHERE + @@ -1208,7 +1212,7 @@ WHERE - + @@ -1228,6 +1232,75 @@ WHERE on-failure="ldh:promise-failure#1"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1342,7 +1415,8 @@ WHERE ldh:row-form-submit-violation - + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/functions.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/functions.xsl index 0ab4f2357b..54ec483960 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/functions.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/functions.xsl @@ -198,6 +198,7 @@ exclude-result-prefixes="#all" + @@ -437,8 +438,116 @@ exclude-result-prefixes="#all" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Constructor skipped: a non-empty WHERE clause cannot be instantiated client-side: + + + + + + + Constructor template triple skipped: variable predicate + + + + + + + + + + + + + + + + + + + + + + + + + + + _:instance + &rdf;type + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -579,13 +688,18 @@ exclude-result-prefixes="#all" "/> - + - + - + @@ -594,8 +708,9 @@ exclude-result-prefixes="#all" - - + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/memento.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/memento.xsl index f3b46f4afc..d47426ee93 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/memento.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/memento.xsl @@ -4,7 +4,9 @@ - + + + ]> - + @@ -34,20 +38,38 @@ version="3.0" - + + + + + + + + + + + + + @@ -71,35 +93,72 @@ version="3.0"