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/.github/workflows/image.yml b/.github/workflows/image.yml deleted file mode 100644 index 0d5ac66e50..0000000000 --- a/.github/workflows/image.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: CI - -on: - push: - tags: - - '*' - -jobs: - docker: - runs-on: ubuntu-latest - steps: - - name: Login to Docker Hub - uses: docker/login-action@v4 - with: - username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Extract version parts - id: version - run: | - RAW_REF="${{ github.ref_name }}" - # strip the literal prefix - VERSION="${RAW_REF#linkeddatahub-}" - MAJOR="${VERSION%%.*}" - MINOR="${VERSION%.*}" - MINOR="${MINOR#*.}" - echo "MAJOR=$MAJOR" >> $GITHUB_ENV - echo "MINOR=$MAJOR.$MINOR" >> $GITHUB_ENV - echo "FULL_VERSION=$VERSION" >> $GITHUB_ENV - - - name: Build and push - uses: docker/build-push-action@v6 - with: - platforms: linux/amd64,linux/arm64 - push: true - tags: | - atomgraph/linkeddatahub:latest - atomgraph/linkeddatahub:${{ env.FULL_VERSION }} - atomgraph/linkeddatahub:${{ env.MINOR }} - atomgraph/linkeddatahub:${{ env.MAJOR }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..1bd40e867d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +on: + push: + tags: + - '*' + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Extract version parts + id: version + run: | + RAW_REF="${{ github.ref_name }}" + # strip the literal prefix + VERSION="${RAW_REF#linkeddatahub-}" + MAJOR="${VERSION%%.*}" + MINOR="${VERSION%.*}" + MINOR="${MINOR#*.}" + echo "MAJOR=$MAJOR" >> $GITHUB_ENV + echo "MINOR=$MAJOR.$MINOR" >> $GITHUB_ENV + echo "FULL_VERSION=$VERSION" >> $GITHUB_ENV + + - name: Build and push + uses: docker/build-push-action@v6 + with: + platforms: linux/amd64,linux/arm64 + push: true + tags: | + atomgraph/linkeddatahub:latest + atomgraph/linkeddatahub:${{ env.FULL_VERSION }} + atomgraph/linkeddatahub:${{ env.MINOR }} + atomgraph/linkeddatahub:${{ env.MAJOR }} + + cli: + name: Attach the ldh CLI to the release + runs-on: ubuntu-latest + permissions: + contents: write # gh release create/upload + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + cache: maven + + - name: Extract version + run: | + RAW_REF="${{ github.ref_name }}" + # strip the literal prefix + echo "FULL_VERSION=${RAW_REF#linkeddatahub-}" >> $GITHUB_ENV + + # release.sh keeps cli/pom.xml at the platform version, so the tagged commit already carries it + # and the jar manifest ldh --version reads gets it from there + - name: Build the ldh CLI + run: mvn -B package + working-directory: cli + + # the launcher prefers a jar beside it, so the archive runs from wherever it is unpacked + - name: Package the archive + run: | + mkdir -p "ldh-${FULL_VERSION}" + cp cli/bin/ldh cli/target/ldh.jar "ldh-${FULL_VERSION}/" + tar -czf "ldh-${FULL_VERSION}.tar.gz" "ldh-${FULL_VERSION}" + + - name: Attach to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # the tag may be pushed before the release exists (maven-release-plugin tags, the release + # is drafted afterwards), so create it if it is not there yet + gh release view "${{ github.ref_name }}" > /dev/null 2>&1 || \ + gh release create "${{ github.ref_name }}" --title "${{ github.ref_name }}" --notes "" + gh release upload "${{ github.ref_name }}" "ldh-${FULL_VERSION}.tar.gz" --clobber diff --git a/AGENTS.md b/AGENTS.md index 5305a85587..5dc36efe08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Writes go through the **document URLs**, never through the SPARQL endpoint (whic | Update a document in place | `PATCH` document URL | `Content-Type: application/sparql-update` | A SPARQL Update (`INSERT`/`DELETE`) applied to that named graph | | Delete a document | `DELETE` document URL | — | Removes the named graph | -Relative URIs in a request body resolve against the target URL. See `bin/post.sh`, `bin/put.sh`, `bin/patch.sh`, `bin/delete.sh` for exact, working invocations. +Relative URIs in a request body resolve against the target URL. The `ldh post`, `ldh put`, `ldh patch` and `ldh delete` commands are working implementations of the four rows above. ## Querying (read-only) @@ -55,7 +55,7 @@ A single instance hosts multiple **dataspaces**, each a subdomain (origin). Each ## Tooling -- **CLI**: the `bin/` scripts wrap every operation above (`get.sh`, `post.sh`, `put.sh`, `patch.sh`, `delete.sh`, `create-container.sh`, `create-item.sh`, `add-view.sh`, `add-select.sh`, `add-construct.sh`, `add-result-set-chart.sh`, `add-file.sh`, `webid-keygen.sh`). They are the authoritative reference for request shapes. +- **CLI**: `ldh` (built from `cli/`) wraps every operation above — `get`, `post`, `put`, `patch`, `delete`, `create-container`, `create-item`, `add-view`, `add-select`, `add-construct`, `add-result-set-chart`, `add-file`, plus the `admin`, `content` and `imports` subcommand groups. It is the authoritative reference for request shapes. Authentication is a PKCS12 WebID keystore (`-f ssl/owner/keystore.p12 -p `, or `LDH_CERT_FILE`/`LDH_CERT_PASSWORD`); commands print the created document's URL on stdout so they compose in pipelines. Certificate tooling (`webid-keygen.sh`, `server-cert-gen.sh`) remains in `bin/`; the `bin/` HTTP API scripts `ldh` replaces are deprecated. - **Programmatic / MCP**: [Web-Algebra](https://github.com/AtomGraph/Web-Algebra) is the recommended path for agent-composed workflows — a JSON DSL and MCP server whose operations (create container/item, add view/chart, generate portal, …) compose multi-step LDH writes atomically under WebID auth. ## Standards diff --git a/CHANGELOG.md b/CHANGELOG.md index 0790891527..2c27ef29b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,80 @@ +## [5.10.0] - 2026-08-30 +### 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 +- Every release attaches an `ldh-.tar.gz` archive of the CLI launcher and jar, stamped with the platform version, so using `ldh` needs a Java runtime rather than a source checkout and a Maven build +- `make cli` builds the CLI and prints the `PATH` export to run, and `make tests` depends on it — the suite builds its fixtures with `ldh` and used to abort telling you to go build it by hand +- `make cli-version` sets `cli/pom.xml` to the platform version, which `release.sh` now runs around both release bumps so the CLI shares the platform's version line instead of its own `1.0.0-SNAPSHOT` +- 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 +- `ldh --version` reported a hardcoded `1.0.0-SNAPSHOT` regardless of the build; it now reads `Implementation-Version` back from the jar manifest +- `--help` was unrecognized on every `ldh` subcommand — `mixinStandardHelpOptions` only reaches the root command, so the option now lives in `BaseCommand` and in a new `CommandGroup` base that the five subcommand groups extend in place of their duplicated `@Spec`/`call()` pairs +- 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..7b4ec903ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,14 +14,14 @@ LinkedDataHub uses Maven as the primary build system with Docker for containeriz ```bash # Initial setup (requires .env file configuration) ./bin/server-cert-gen.sh .env nginx ssl -docker-compose up --build +make up -- --build ``` Service credentials (used by the entrypoint for Bearer auth) are stored in `secrets/credentials.trig`. ### Core Build Commands ```bash -# Maven build (Java 17 required) +# Maven build (Java 21 required) mvn clean install # Build specific profiles @@ -29,15 +29,27 @@ mvn -Pstandalone clean install # Standalone WAR mvn -Pdependency clean install # JAR dependency mvn -Prelease clean install # Release with signing -# Docker-based development -docker-compose up --build # Start all services -docker-compose down -v # Stop and remove volumes -sudo rm -rf data uploads && docker-compose down -v # Complete reset +# Docker-based development. `make up`/`make down` forward their arguments to +# `docker-compose`; `--` is needed before any argument starting with `-` so make +# does not claim it as one of its own options +make up # Start all services +make up -- --build # Rebuild images and start +make up nginx # Start named services only +make down # Stop the services +make down -- -v # Stop and remove volumes +make drop # Complete reset (down -v, then wipe local dirs) + +make cli # Build the ldh CLI, print the PATH export to run +make cli-version # Set cli/pom.xml to the platform version in pom.xml ``` ### Testing ```bash -# HTTP tests (requires running application) +# HTTP tests (requires a running application). Depends on the `cli` target, so it builds +# the CLI and puts it on PATH for run.sh, which builds the suite's fixtures with it +make tests # runs http-tests/run.sh with the certificates and secrets/ passwords + +# For other certificates, invoke the runner directly cd http-tests ./run.sh ssl/owner/cert.pem [password] ssl/secretary/cert.pem [password] @@ -134,19 +146,44 @@ 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. Built with Maven on Java 21 +into a shaded `cli/target/ldh.jar` that `cli/bin/ldh` launches. See `cli/README.md` for the full +script → command table and the behavioral differences from the scripts. + +```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/" +``` + +`cli/` is not a module of the platform reactor (the root pom is the webapp artifact, so it cannot +carry ``), but it shares the platform's version: `release.sh` runs `versions:set` on it +around both release bumps, and `make cli-version` re-aligns it if it drifts. + +`LDH_CERT_FILE`, `LDH_CERT_PASSWORD`, `LDH_BASE` and `LDH_PROXY` supply defaults for `-f`, `-p`, +`-b` and `--proxy`. Commands that create or append to a document print its URL as the only line on +stdout (diagnostics go to stderr), so `item=$(ldh create-item ...)` works; exit codes are `0` +success, `1` HTTP or runtime failure, `2` usage error. + +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. + +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" ``` ## Development Notes -- Java 17 is required for compilation +- Java 21 is required for compilation (both the platform and the `cli/` project) - The application uses AtomGraph's Processor and Web-Client libraries as core dependencies - XSLT stylesheets are processed during build to inline XML entities - Saxon-JS SEF files are generated during Maven package phase for client-side XSLT diff --git a/Makefile b/Makefile index 38b9fd0260..a77ee7bfa4 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,36 @@ -.PHONY: sef drop cert release tests +TARGETS := sef drop cert release tests up down cli cli-version +COMPOSE_TARGETS := up down +.PHONY: $(TARGETS) + +# Treat goals that are not targets as arguments for docker-compose, not as make goals +ifneq (,$(filter $(COMPOSE_TARGETS),$(MAKECMDGOALS))) +COMPOSE_ARGS := $(filter-out $(TARGETS),$(MAKECMDGOALS)) +$(eval $(COMPOSE_ARGS):;@:) +endif + +# Start the Docker Compose stack; extra arguments are passed to `docker-compose up` +# (e.g. `make up -- --build -d`, `make up nginx`, or `make up ARGS="--build -d"`) +up: + docker-compose up $(ARGS) $(COMPOSE_ARGS) + +# Stop the Docker Compose stack; extra arguments are passed to `docker-compose down` +# (e.g. `make down -- -v` to remove the Varnish cache volumes as well) +down: + docker-compose down $(ARGS) $(COMPOSE_ARGS) # Generate Saxon-JS SEF files for client-side XSLT transformations sef: - ./generate-sef.sh + mvn war:war +# expand entities in XSLT stylesheets. Same logic as in pom.xml using net.sf.saxon.Query. + find ./target/ROOT/static/com/atomgraph -type f -name "*.xsl" -exec sh -c 'xmlstarlet c14n "$$1" > "$$1".c14n && mv "$$1".c14n "$$1"' x {} \; +# compile client.xsl to SEF. The output path is mounted in docker-compose.override.yml + npx xslt3-he -t -xsl:./target/ROOT/static/com/atomgraph/linkeddatahub/xsl/client.xsl -export:./target/ROOT/static/com/atomgraph/linkeddatahub/xsl/client.xsl.sef.json -nogo -ns:##html5 -relocate:on -# Wipe local data directories (datasets, Fuseki, SSL certs, uploads) — irreversible! +# Tear down the stack (including the Varnish cache volumes) and wipe local data +# directories (datasets, Fuseki, SSL certs, uploads) — irreversible! drop: - @read -p "Are you sure? [y/N] " ans && [ "$$ans" = "y" ] && sudo rm -rf datasets fuseki ssl uploads || echo "Aborted." + @read -p "Are you sure? [y/N] " ans && [ "$$ans" = "y" ] || { echo "Aborted."; exit 0; }; \ + docker-compose down -v && sudo rm -rf datasets fuseki ssl uploads # Generate server SSL certificate using the .env config cert: @@ -16,6 +40,23 @@ cert: release: ./release.sh -# Run HTTP tests using owner and secretary certificates with passwords from secrets/ -tests: - cd http-tests && ./run.sh ../ssl/owner/cert.pem $$(cat ../secrets/owner_cert_password.txt) ../ssl/secretary/cert.pem $$(cat ../secrets/secretary_cert_password.txt) +# Set cli/pom.xml to the platform version in pom.xml. The CLI ships with the platform release, so +# the two versions are kept in step; release.sh runs this around the release version bumps, and this +# target is for drift and for manual SNAPSHOT bumps +cli-version: + @version=$$(mvn -q help:evaluate -Dexpression=project.version -DforceStdout); \ + cd cli && mvn -B -q versions:set -DnewVersion="$$version" -DgenerateBackupPoms=false && \ + echo "cli/pom.xml set to $$version" + +# Build the ldh CLI (requires Java 21 and Maven) and print the line that puts it on $PATH. +# Released versions are also attached to the GitHub release, which needs neither. +cli: + cd cli && mvn -B package + @echo + @echo "Add the ldh launcher to your \$$PATH:" + @echo " export PATH=\"$(CURDIR)/cli/bin:\$$PATH\"" + +# Run HTTP tests using owner and secretary certificates with passwords from secrets/. +# The suite builds its fixtures with ldh, so the CLI is built first and put on $PATH for run.sh +tests: cli + cd http-tests && PATH="$(CURDIR)/cli/bin:$$PATH" ./run.sh ../ssl/owner/cert.pem $$(cat ../secrets/owner_cert_password.txt) ../ssl/secretary/cert.pem $$(cat ../secrets/secretary_cert_password.txt) diff --git a/README.md b/README.md index e7bd5cbe6d..fec89f62bb 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,9 @@ It takes a few clicks and filling out a form to install the product into your ow * [Docker](https://docs.docker.com/install/) installed. At least 8GB of memory dedicated to Docker is recommended. * [Docker Compose](https://docs.docker.com/compose/install/) installed -#### CLI scripts +#### CLI -The following tools are required for CLI scripts in the `bin/` directory: - -* [`curl`](https://curl.se/) -* [`openssl`](https://www.openssl.org/) -* `python` 3.x +The [`ldh` command line interface](#command-line-interface) is attached to every release and needs only a Java 21 runtime; building it from source additionally requires [Maven](https://maven.apache.org/). The certificate and WebID scripts that remain in the `bin/` directory require [`openssl`](https://www.openssl.org/) and `keytool` (part of the JDK). ### Steps @@ -76,8 +72,10 @@ The following tools are required for CLI scripts in the `bin/` directory: The one you will need to remember in order to authenticate with LinkedDataHub using WebID client certificate is `owner_cert_password`. 5. Launch the application services by running this from command line: ```shell - docker-compose up --build + make up -- --build ``` + `make up` passes its arguments on to `docker-compose up`. The `--` is required before any argument starting with `-`, otherwise `make` treats it as one of its own options. + It will build LinkedDataHub's Docker image, start its container and mount the following sub-folders: - `ssl` * `owner` stores root owner's WebID certificate, keystore, and public key @@ -92,15 +90,18 @@ The following tools are required for CLI scripts in the `bin/` directory: - Mozilla Firefox: `Options > Privacy > Security > View Certificates... > Import...` - Apple Safari: The file is installed directly into the operating system. Open the file and import it using the [Keychain Access](https://support.apple.com/guide/keychain-access/what-is-keychain-access-kyca1083/mac) tool (drag it to the `local` section). - Microsoft Edge: Does not support certificate management, you need to install the file into Windows. [Read more here](https://social.technet.microsoft.com/Forums/en-US/18301fff-0467-4e41-8dee-4e44823ed5bf/microsoft-edge-browser-and-ssl-certificates?forum=win10itprogeneral). - 7. For authenticated API access use the `ssl/owner/cert.pem` HTTPS client certificate. + 7. For authenticated API access use the `ssl/owner/cert.pem` HTTPS client certificate with `curl`, or the `ssl/owner/keystore.p12` keystore beside it with the [`ldh` CLI](#command-line-interface). If you are running Linux with user other than `root`, you might need to fix the certificate permissions because Docker bind mounts are owned by `root` by default. For example: ```shell sudo setfacl -m u:$(whoami):r ./ssl/owner/* ``` - 8. Open **https://localhost:4443/** in the web browser or use `curl` for API access, for example: + 8. Open **https://localhost:4443/** in the web browser or use the API, for example: ```shell curl -k -E ./ssl/owner/cert.pem: -H "Accept: text/turtle" 'https://localhost:4443/' ``` + ```shell + ldh get -f ./ssl/owner/keystore.p12 -p --accept text/turtle 'https://localhost:4443/' + ``` ### Notes @@ -129,7 +130,7 @@ The following tools are required for CLI scripts in the `bin/` directory: ``` and re-login with your user. An alternative, but not recommended, is to run ```shell - sudo docker-compose up + sudo make up ``` @@ -204,7 +205,7 @@ _:warning: Do not use blank nodes to identify applications or services. We recom } ``` 5. Enable the `credentials` secret in `docker-compose.yml` by uncommenting it in the top-level `secrets:` block and in the `linkeddatahub` service's `secrets:` list. - 6. Restart with `docker-compose up`. The startup log will confirm: `Graph versioning enabled for application <...>`. + 6. Restart with `make up`. The startup log will confirm: `Graph versioning enabled for application <...>`. Multiple dataspaces can be versioned into different repositories with different tokens. The token never appears in the environment or the process table — it is merged into the internal context dataset from the Docker secret, the same mechanism used for SPARQL service credentials. @@ -255,8 +256,9 @@ The options are described in more detail in the [configuration documentation](ht If you need to start fresh and wipe the existing setup (e.g. after configuring a new base URI), you can do that using ```shell - sudo rm -rf fuseki uploads ssl datasets && docker-compose down -v + make drop ``` + It asks for confirmation, then stops the services and removes their volumes before deleting the `datasets`, `fuseki`, `ssl`, and `uploads` folders. Stopping first matters: deleting those folders while the containers are running leaves Fuseki writing into directories that no longer exist. _:warning: This will **remove the persisted data and files** as well as Docker volumes._ @@ -269,23 +271,39 @@ _:warning: This will **remove the persisted data and files** as well as Docker v ## [Command line interface](https://atomgraph.github.io/LinkedDataHub/linkeddatahub/docs/reference/command-line-interface/) -LinkedDataHub CLI wraps the HTTP API into a set of shell scripts with convenient parameters. The scripts can be used for testing, automation, scheduled execution and such. It is usually much quicker to perform actions using CLI rather than the user interface, as well as easier to reproduce. +`ldh` wraps the HTTP API into a single executable with convenient parameters. It can be used for testing, automation, scheduled execution and such. It is usually much quicker to perform actions using the CLI rather than the user interface, as well as easier to reproduce. -The scripts can be found in the [`bin`](https://github.com/AtomGraph/LinkedDataHub/tree/master/bin) subfolder. In order to use them, add the `bin` folder and its subfolders to the `$PATH`. For example: +Every release attaches an `ldh-.tar.gz` archive, which needs only a Java 21 runtime — no build tools and no source checkout: ```shell -export PATH="$(find bin -type d -exec realpath {} \; | tr '\n' ':')$PATH" +tar -xzf ldh-.tar.gz +export PATH="$PWD/ldh-:$PATH" + +ldh --help ``` -If you will be using LinkedDataHub's CLI regurarly, add the above command to your shell profile. -_:warning: The CLI scripts internally use [Jena's CLI commands](https://jena.apache.org/documentation/tools/). Set up the Jena environment before running the scripts._ +To build it from source instead — the CLI lives in the [`cli`](https://github.com/AtomGraph/LinkedDataHub/tree/master/cli) subfolder and needs Java 21 and Maven: -The environment variable `JENA_HOME` is used by all the command line tools to configure the class path automatically for you. You can set this up as follows: +```shell +make cli +``` + +which prints the `export PATH=...` line to run afterwards. If you will be using LinkedDataHub's CLI regularly, add that `export` to your shell profile. + +Commands authenticate with a WebID client certificate read from a **PKCS12 keystore** — `ssl/owner/keystore.p12` for the owner. Options that repeat across commands can be set once as environment variables: + +```shell +export LDH_CERT_FILE=./ssl/owner/keystore.p12 +export LDH_CERT_PASSWORD=$(cat secrets/owner_cert_password.txt) +export LDH_BASE=https://localhost:4443/ + +ldh create-container --parent "$LDH_BASE" --title "Concepts" --slug concepts +ldh create-item --container "${LDH_BASE}concepts/" --title "Example" --slug example +``` -**On Linux / Mac** +Commands that create or append to a document print its URL as the only line on stdout, so they compose in shell pipelines: `item=$(ldh create-item ...)`. The `bin/` subdirectories became nested subcommand groups — `ldh admin acl create-group`, `ldh content add-xhtml-block`, `ldh imports import-csv`. See [`cli/README.md`](https://github.com/AtomGraph/LinkedDataHub/blob/master/cli/README.md) for the full command table and the differences from the scripts. - export JENA_HOME=the directory you downloaded Jena to - export PATH="$PATH:$JENA_HOME/bin" +_:warning: The `bin/` HTTP API scripts that `ldh` replaces are **deprecated**. 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 stays in `bin/`._ ## Sample applications @@ -332,7 +350,7 @@ See the [Web-Algebra repository](https://github.com/AtomGraph/Web-Algebra) for s ## Test suite -LinkedDataHub includes an HTTP [test suite](https://github.com/AtomGraph/LinkedDataHub/tree/master/http-tests). The server implementation is also covered by the [Processor test suite](https://github.com/AtomGraph/Processor/tree/master/http-tests). +LinkedDataHub includes an HTTP [test suite](https://github.com/AtomGraph/LinkedDataHub/tree/master/http-tests), run with `make tests`. It builds its fixtures with `ldh`, which `make tests` builds and puts on the `$PATH` for the run. The server implementation is also covered by the [Processor test suite](https://github.com/AtomGraph/Processor/tree/master/http-tests). ![HTTP-tests](https://github.com/AtomGraph/LinkedDataHub/actions/workflows/http-tests.yml/badge.svg) 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..fa41b32a39 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,161 @@ +# 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`. + +## Install + +Every LinkedDataHub release attaches an `ldh-.tar.gz` archive holding the launcher and the +jar. It needs a Java 21 runtime and nothing else: + +```bash +tar -xzf ldh-.tar.gz +export PATH="$PWD/ldh-:$PATH" +ldh --help +``` + +## Build + +Building from source requires Java 21 and Maven. From the repository root: + +```bash +make cli +``` + +which prints the `export PATH=...` line to run afterwards. It is the equivalent of: + +```bash +cd cli +mvn package +export PATH="$PWD/bin:$PATH" +``` + +This produces the self-contained `target/ldh.jar`, which the `cli/bin/ldh` launcher runs. The +launcher prefers `LDH_JAR`, then a jar sitting beside it (the release archive layout), then +`../target/ldh.jar` (the source checkout layout). + +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. + +The CLI carries the same version as the platform: it ships with a LinkedDataHub release and is +exercised by the same http-tests, so `cli/pom.xml` tracks the root `pom.xml`. `release.sh` keeps +the two in step across the release bumps, and `make cli-version` sets `cli/pom.xml` from the +platform version if they ever drift. + +`ldh --version` reports the version the jar was built at, read back from its `Implementation-Version` +manifest entry. + +## 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..21ce3638db --- /dev/null +++ b/cli/bin/ldh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# Launcher for the LinkedDataHub CLI. +# +# 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)" + +# LDH_JAR wins; otherwise the jar beside the launcher (release archive layout), then the one +# `mvn package` writes (source checkout layout) + +if [ -n "${LDH_JAR:-}" ]; then jar="$LDH_JAR" +elif [ -f "$dir/ldh.jar" ]; then jar="$dir/ldh.jar" +else jar="$dir/../target/ldh.jar" +fi + +if [ ! -f "$jar" ]; then + echo >&2 "ldh jar not found at $jar. Build it with 'make cli' (or 'mvn package' in cli/), or point LDH_JAR at one." + exit 1 +fi + +# shellcheck disable=SC2086 # unquoted on purpose: the flags must word-split +exec java ${LDH_JAVA_OPTS:--XX:TieredStopAtLevel=1 -XX:+UseSerialGC} -jar "$jar" "$@" diff --git a/cli/pom.xml b/cli/pom.xml new file mode 100644 index 0000000000..0670a088cb --- /dev/null +++ b/cli/pom.xml @@ -0,0 +1,173 @@ + + + 4.0.0 + + com.atomgraph + linkeddatahub-cli + 5.10.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.codehaus.mojo + versions-maven-plugin + 2.21.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + + + com.atomgraph.linkeddatahub.cli.LDH + + true + + ${project.artifactId} + ${project.version} + + + + + + + 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..4cbd406b56 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/BaseCommand.java @@ -0,0 +1,213 @@ +/* + * 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.Option; +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; + + @Option(names = { "-h", "--help" }, usageHelp = true, description = "Show this help message and exit.") + private boolean help; + + @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/CommandGroup.java b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/CommandGroup.java new file mode 100644 index 0000000000..97a01bb2a1 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/CommandGroup.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; + +import java.util.concurrent.Callable; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.Spec; + +/** + * Base class for commands that only group subcommands and do nothing on their own, + * mirroring the subdirectories of the deprecated bin/ scripts. + * + * @author Martynas Jusevičius {@literal } + */ +public abstract class CommandGroup implements Callable +{ + + @Option(names = { "-h", "--help" }, usageHelp = true, description = "Show this help message and exit.") + private boolean help; + + @Spec + private CommandSpec spec; + + @Override + public Integer call() + { + throw new ParameterException(getSpec().commandLine(), "Missing required subcommand"); + } + + /** + * Returns the command specification of the group. + * + * @return command spec + */ + protected CommandSpec getSpec() + { + return spec; + } + +} 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..363eadfcec --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/LDH.java @@ -0,0 +1,115 @@ +/* + * 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, + versionProvider = LDH.ManifestVersion.class, + 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; + + /** + * Reports the version the jar was built at, read from its manifest. Classes loaded outside a jar + * (an IDE run, the unit tests) have no manifest, hence the fallback. + */ + static class ManifestVersion implements CommandLine.IVersionProvider + { + + @Override + public String[] getVersion() + { + String version = LDH.class.getPackage().getImplementationVersion(); + + return new String[] { "ldh " + (version != null ? version : "(development build)") }; + } + + } + + /** + * 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..40a0540aad --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/Admin.java @@ -0,0 +1,34 @@ +/* + * 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.CommandGroup; +import com.atomgraph.linkeddatahub.cli.command.admin.acl.Acl; +import com.atomgraph.linkeddatahub.cli.command.admin.ontologies.Ontologies; +import picocli.CommandLine.Command; + +/** + * 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 extends CommandGroup +{ +} 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..395f6f03eb --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/acl/Acl.java @@ -0,0 +1,32 @@ +/* + * 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.CommandGroup; +import picocli.CommandLine.Command; + +/** + * 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 extends CommandGroup +{ +} 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..75386ceb4e --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/admin/ontologies/Ontologies.java @@ -0,0 +1,33 @@ +/* + * 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.CommandGroup; +import picocli.CommandLine.Command; + +/** + * 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 extends CommandGroup +{ +} 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..0458ead6c7 --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/content/Content.java @@ -0,0 +1,32 @@ +/* + * 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.CommandGroup; +import picocli.CommandLine.Command; + +/** + * 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 extends CommandGroup +{ +} 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..6900f3e87b --- /dev/null +++ b/cli/src/main/java/com/atomgraph/linkeddatahub/cli/command/imports/Imports.java @@ -0,0 +1,32 @@ +/* + * 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.CommandGroup; +import picocli.CommandLine.Command; + +/** + * 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 extends CommandGroup +{ +} 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..20d4d1877c --- /dev/null +++ b/cli/src/test/java/com/atomgraph/linkeddatahub/cli/CommandParsingTest.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; + +import java.io.PrintWriter; +import java.io.StringWriter; +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 helpIsRecognizedAtEveryNestingLevel() + { + List.of(new String[] { "--help" }, + new String[] { "get", "--help" }, + new String[] { "admin", "--help" }, + new String[] { "content", "--help" }, + new String[] { "imports", "--help" }, + new String[] { "admin", "acl", "--help" }, + new String[] { "admin", "ontologies", "--help" }, + new String[] { "content", "remove-block", "--help" }, + new String[] { "imports", "import-csv", "-h" }, + new String[] { "admin", "ontologies", "add-class", "--help" }). + forEach(args -> assertEquals(CommandLine.ExitCode.OK, commandLine().execute(args), String.join(" ", args))); + } + + @Test + public void helpPrintsTheUsageOfTheCommandItWasAskedOn() + { + StringWriter out = new StringWriter(); + CommandLine cmd = commandLine(); + cmd.setOut(new PrintWriter(out)); + cmd.execute("content", "remove-block", "--help"); + + assertTrue(out.toString().startsWith("Usage: ldh content remove-block"), out.toString()); + } + + @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/generate-sef.sh b/generate-sef.sh deleted file mode 100755 index e1df9d117e..0000000000 --- a/generate-sef.sh +++ /dev/null @@ -1,11 +0,0 @@ -# build WAR file - -mvn war:war - -# expand entities in XSLT stylesheets. Same logic as in pom.xml using net.sf.saxon.Query. - -find ./target/ROOT/static/com/atomgraph -type f -name "*.xsl" -exec sh -c 'xmlstarlet c14n "$1" > "$1".c14n && mv "$1".c14n "$1"' x {} \; - -# compile client.xsl to SEF. The output path is mounted in docker-compose.override.yml - -npx xslt3-he -t -xsl:./target/ROOT/static/com/atomgraph/linkeddatahub/xsl/client.xsl -export:./target/ROOT/static/com/atomgraph/linkeddatahub/xsl/client.xsl.sef.json -nogo -ns:##html5 -relocate:on \ No newline at end of file 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..88c8372539 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" \ @@ -43,9 +43,18 @@ container=$(create-container.sh \ --parent "$END_USER_BASE_URL") # execute SPARQL query again - the new container should appear (verifies cache invalidation) +# +# The assertion reads from a here-string rather than piping curl into `grep -q`: `grep -q` +# closes the pipe on its first match, and with `set -o pipefail` the SIGPIPE'd curl fails +# the whole pipeline whenever it is still writing at that moment. -curl -k -f -s \ +response=$(curl -k -f -s \ -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ -H "Accept: application/n-triples" \ - "${END_USER_BASE_URL}sparql?query=${encoded_query}" \ -| grep -q "<${container}>" + "${END_USER_BASE_URL}sparql?query=${encoded_query}") + +if ! grep -qF "<${container}>" <<< "$response"; then + echo "DEBUG: Expected the new container in the query results: <${container}>" + echo "DEBUG: Got: $response" + exit 1 +fi 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..a01570203d 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/" @@ -19,8 +19,7 @@ add-agent-to-group.sh \ item="${END_USER_BASE_URL}new-item/" -( -curl -k -w "%{http_code}\n" -o /dev/null -f -s \ +status=$(curl -k -w "%{http_code}" -o /dev/null -s \ -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ -X PUT \ -H "Accept: application/n-triples" \ @@ -30,15 +29,31 @@ curl -k -w "%{http_code}\n" -o /dev/null -f -s \ "named object PUT" . "another named object PUT" . EOF -) \ -| grep -q "$STATUS_CREATED" +) + +if [ "$status" != "$STATUS_CREATED" ]; then + echo "DEBUG: Expected $STATUS_CREATED from the PUT, got: $status" + exit 1 +fi # check that resource is accessible +# +# Assertions read from a here-string rather than piping curl into `grep -q`: `grep -q` +# closes the pipe on its first match, and with `set -o pipefail` the SIGPIPE'd upstream +# command fails the whole pipeline whenever it is still writing at that moment. -curl -k -f -G -s \ +response=$(curl -k -f -G -s \ -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ -H "Accept: application/n-triples" \ - "$item" \ -| tr -d '\n' \ -| grep "<${item}> " \ -| grep -q "<${item}named-subject-put> \"named object PUT\" ." + "$item") + +for triple in \ + "<${item}> " \ + "<${item}named-subject-put> \"named object PUT\" ." +do + if ! grep -qF "$triple" <<< "$response"; then + echo "DEBUG: Expected triple: $triple" + echo "DEBUG: Got: $response" + exit 1 + fi +done 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..f9e080e3c1 --- /dev/null +++ b/http-tests/misc/PATCH-settings-package-import.sh @@ -0,0 +1,105 @@ +#!/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" + +# The rendered homepage is tens of KiB and the marker sits in , within its first +# 2 KiB. Assertions therefore read the response from a here-string rather than piping +# curl into `grep -q`: `grep -q` closes the pipe on its first match, and with +# `set -o pipefail` the SIGPIPE'd curl fails the whole pipeline. That only happens when +# curl is still writing at the moment grep exits, which made this test fail randomly. + +function homepage() +{ + curl -k -s \ + -H "Accept: text/html" \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + "$END_USER_BASE_URL" +} + +function patch_settings() +{ + curl -k -w "%{http_code}" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-update" \ + -d "$1" \ + "${END_USER_BASE_URL}settings" +} + +function import_triple_count() +{ + curl -k -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "${END_USER_BASE_URL}settings" \ + | grep -c "linkeddatahub#import" || true +} + +# The settings live in the in-memory application model, which initialize_dataset does not +# reset - an import left behind by a failed assertion would stay for the rest of the suite. + +function remove_import() +{ + patch_settings "DELETE { <${app_uri}> <${package_uri}> . } WHERE { }" > /dev/null || true +} + +trap remove_import EXIT + +# verify the homepage is not rendered with the package stylesheet initially +response=$(homepage) + +if grep -qF "$marker" <<< "$response"; then + echo "DEBUG: package stylesheet marker present before import" + exit 1 +fi + +# declare the package import +status=$(patch_settings "INSERT { <${app_uri}> <${package_uri}> . } WHERE { }") + +if [[ ! "$status" =~ ^($STATUS_NO_CONTENT)$ ]]; then + echo "DEBUG: Expected $STATUS_NO_CONTENT from the INSERT PATCH, got: $status" + exit 1 +fi + +# verify the homepage is rendered with the package stylesheet — no restart, no sleep +response=$(homepage) + +if ! grep -qF "$marker" <<< "$response"; then + echo "DEBUG: package stylesheet marker missing after import" + echo "DEBUG: Expected marker: $marker" + echo "DEBUG: Response size: $(wc -c <<< "$response") bytes" + echo "DEBUG: ldh:import triple count in settings = $(import_triple_count)" + exit 1 +fi + +# remove the package import +status=$(patch_settings "DELETE { <${app_uri}> <${package_uri}> . } WHERE { }") + +if [[ ! "$status" =~ ^($STATUS_NO_CONTENT)$ ]]; then + echo "DEBUG: Expected $STATUS_NO_CONTENT from the DELETE PATCH, got: $status" + exit 1 +fi + +# verify the homepage is no longer rendered with the package stylesheet +response=$(homepage) + +if grep -qF "$marker" <<< "$response"; then + echo "DEBUG: package stylesheet marker still present after removal" + echo "DEBUG: ldh:import triple count in settings = $(import_triple_count)" + 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..2edbceebe2 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" @@ -57,18 +57,31 @@ WHERE { OPTIONAL { <${constructor}> sp:text ?old . } } EOF ) -curl -k -w "%{http_code}" -o /dev/null -s \ +status=$(curl -k -w "%{http_code}" -o /dev/null -s \ -X PATCH \ -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ -H "Content-Type: application/sparql-update" \ --url-query "uri=${ontology_doc}" \ --data-binary "$update" \ - "$END_USER_BASE_URL" \ -| grep -qE "$STATUS_PATCH_SUCCESS" + "$END_USER_BASE_URL") + +if [[ ! "$status" =~ ^($STATUS_PATCH_SUCCESS)$ ]]; then + echo "DEBUG: Expected $STATUS_PATCH_SUCCESS from the proxied PATCH, got: $status" + exit 1 +fi # Verify the update landed in the admin document (not silently swallowed). -curl -k -f -s \ +# Assertions read from a here-string rather than piping curl into `grep -q`: `grep -q` +# closes the pipe on its first match, and with `set -o pipefail` the SIGPIPE'd curl +# fails the whole pipeline whenever it is still writing at that moment. + +response=$(curl -k -f -s \ -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ -H "Accept: application/n-triples" \ - "$ontology_doc" \ -| grep -q "TestClassUpdated" + "$ontology_doc") + +if ! grep -qF "TestClassUpdated" <<< "$response"; then + echo "DEBUG: Expected the constructor text to contain: TestClassUpdated" + echo "DEBUG: Got: $response" + exit 1 +fi 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..ba21112b2f 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ com.atomgraph linkeddatahub - 5.9.1 + 5.10.0-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/release.sh b/release.sh index 50d37f3d1f..8e2166020a 100755 --- a/release.sh +++ b/release.sh @@ -95,6 +95,23 @@ fi print_status "GPG check passed" +# Set cli/pom.xml to the given version and commit it. The CLI is not a module of the platform +# reactor, so maven-release-plugin does not rewrite it - it is kept in step here instead, once for +# the release version and once for the next development version. +sync_cli_version() { + local version="$1" + + (cd cli && mvn -B -q versions:set -DnewVersion="$version" -DgenerateBackupPoms=false) + + if git diff --quiet -- cli/pom.xml; then + print_status "cli/pom.xml already at $version" + else + git add cli/pom.xml + git commit -m "Set the CLI version to $version" + print_status "cli/pom.xml set to $version" + fi +} + # Get current version from pom.xml CURRENT_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) print_status "Current version: $CURRENT_VERSION" @@ -127,6 +144,11 @@ if git tag -l | grep -q "^$RELEASE_TAG$"; then fi fi +# Align the CLI before release:prepare, so its version is already correct in the commit that gets +# tagged - and so the two commits release:prepare adds stay the last two, which the merge below reads +# by position +sync_cli_version "$RELEASE_VERSION" + # Configure Maven release plugin to not push changes automatically mvn release:clean release:prepare -DpushChanges=false -DlocalCheckout=true @@ -140,6 +162,12 @@ SNAPSHOT_COMMIT=$(git log --oneline -1 --pretty=format:"%H") print_status "Release commit: $RELEASE_COMMIT" print_status "Development commit (SNAPSHOT bump): $SNAPSHOT_COMMIT" +# Follow the platform onto the next development version. Deliberately after the two hashes are +# captured: it adds a commit on top, and both are read by position. Master merges $RELEASE_COMMIT +# alone so it does not go there, develop merges the whole branch so it does. +NEXT_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) +sync_cli_version "$NEXT_VERSION" + # Switch to master and merge only the release commit print_status "Merging release commit to master branch..." git checkout master 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"