Skip to content

feat(integration-xwiki): XwikiProvider — external, OpenConnector-backed (leaf, backend)#1493

Merged
rubenvdlinde merged 7 commits into
developmentfrom
feature/1326/integration-xwiki
May 18, 2026
Merged

feat(integration-xwiki): XwikiProvider — external, OpenConnector-backed (leaf, backend)#1493
rubenvdlinde merged 7 commits into
developmentfrom
feature/1326/integration-xwiki

Conversation

@rubenvdlinde
Copy link
Copy Markdown
Contributor

The PHP half of the integration-xwiki leaf — issue #1326, depends_on: pluggable-integration-registry. The worked external-storage example for the pluggable integration registry. Stacked on #1490 (docs/scaffold/tracking) → #1475 (admin UI) → … → development. The frontend half is ConductionNL/nextcloud-vue#213.

Summary

XwikiProvider declares storage external, group external, OpenConnector source xwiki, and delegates all CRUD to ExternalIntegrationRouter (AD-4) — it carries no HTTP client and no credentials; those live on the OpenConnector source (HTTP Basic or OAuth2, customer-dependent — AD-15).

Per the leaf design.md:

  • AD-1get() round-trips the page's rendered HTML under content; the frontend widget strips it to text + ~500 chars for the detail-page preview, macros are never executed in NC.
  • AD-2create() passes reference through (a full XWiki URL or a Space.Page path); the OpenConnector source resolves both to a canonical reference.
  • AD-3normalizeRow() ensures every row carries a breadcrumb (from the source, or derived from space + title) so the UI can disambiguate same-titled pages in different spaces.

Files

  • lib/Service/Integration/Providers/XwikiProvider.php (new)
  • lib/AppInfo/Application.php — DI service binding + added to bootBuiltinIntegrationProviders()'s list so it self-registers with IntegrationRegistry at boot
  • docs/Integrations/xwiki-openconnector-source.yaml (new) — the OpenConnector source template to import (the repo's config/ dir is gitignored, so it lives under docs/Integrations/)
  • docs/Integrations/xwiki.md (new) — user-facing setup + usage guide
  • tests/Unit/Service/Integration/Providers/XwikiProviderTest.php (new) — 10 tests, 38 assertions
  • openspec/changes/integration-xwiki/tasks.md — synced to reality

Spec deviations

  • requiredApp is 'openconnector' (not null as the original tasks.md said) — more accurate for the admin UI
  • the source-config template lives at docs/Integrations/xwiki-openconnector-source.yaml, not config/openconnector-sources/xwiki.yaml (the repo's config/ dir is gitignored)
  • the provider is registered via addProvider() at boot (not a DI tag) — consistent with the umbrella's mechanism

Test plan

  • composer phpcs (phpcs.xml) on XwikiProvider.php0 errors, 0 warnings (verified in the nextcloud container)
  • composer phpmd (phpmd.xml) — clean
  • composer psalm — no errors
  • phpunit --configuration phpunit-unit.xml tests/Unit/Service/Integration/Providers/XwikiProviderTest.php — 10 passed, 38 assertions
  • php -l Application.php — no syntax errors
  • Live smoke-test after the chain merges: with OpenConnector installed + an xwiki source configured against a running XWiki, the Articles tab/widget should populate and the admin Integrations page should show the xwiki row's health

…ed (leaf, backend)

The PHP half of the `integration-xwiki` leaf (issue #1326,
depends_on: pluggable-integration-registry) — the worked
external-storage example. `XwikiProvider` declares storage `external`,
group `external`, OpenConnector source `xwiki`, and delegates all CRUD
to `ExternalIntegrationRouter` (AD-4) — it carries no HTTP client and
no credentials; those live on the OpenConnector source (HTTP Basic or
OAuth2, customer-dependent — AD-15).

Per the leaf design.md:
  - AD-1: `get()` round-trips the page's rendered HTML under `content`;
    the @conduction/nextcloud-vue widget strips it to text + ~500 chars
    for the detail-page preview — macros are never executed in NC.
  - AD-2: `create()` passes `reference` through (a full XWiki URL or a
    `Space.Page` path); the OpenConnector source resolves both to a
    canonical reference.
  - AD-3: `normalizeRow()` ensures every row carries a `breadcrumb`
    (from the source, or derived from space + title) so the UI can
    disambiguate same-titled pages in different spaces.

Files:
  - lib/Service/Integration/Providers/XwikiProvider.php (new) —
    metadata getters + authRequirements (type: external, configuredVia:
    openconnector) + isEnabled (mirrors IAppManager::isInstalled
    'openconnector') + list/get/create/update/delete (all via
    router->call with the {register, schema, object} context) + health
    (defers to router->probe) + normalizeList/normalizeRow shaping
  - lib/AppInfo/Application.php — DI service registration for
    XwikiProvider + add it to bootBuiltinIntegrationProviders()'s list
    so it self-registers with the IntegrationRegistry at boot
  - docs/Integrations/xwiki-openconnector-source.yaml (new) — the
    OpenConnector source template to import (the repo's config/ dir is
    gitignored, so it lives under docs/Integrations/)
  - docs/Integrations/xwiki.md (new) — user-facing setup + usage guide
  - tests/Unit/Service/Integration/Providers/XwikiProviderTest.php (new)
    — 10 tests, 38 assertions: metadata matches the leaf spec,
    authRequirements shape, isEnabled mirrors OpenConnector install,
    list/get/create/update/delete delegate with the right method +
    path + context and normalise rows (breadcrumb fallback, id/title/
    space/page/url/content mapping, bare-array response), health defers
    to probe

Quality (verified in the nextcloud container):
  - composer phpcs (phpcs.xml) — 0 errors, 0 warnings
  - composer phpmd (phpmd.xml) — clean
  - composer psalm — no errors
  - phpunit (phpunit-unit.xml) XwikiProviderTest — 10 passed, 38 assertions
  - php -l Application.php — no syntax errors
…+ spec deviations

Syncs the integration-xwiki leaf tasks.md to reality:
  - backend (XwikiProvider + DI registration + source-config template
    + user guide + unit tests) — done in this branch
  - frontend (CnXwikiTab + CnXwikiCard + registration + tests) — done
    in ConductionNL/nextcloud-vue#213
  - acceptance: link-by-URL / reference-property / "macros not
    executed in preview" are covered by the component tests; the live
    E2E (against a running XWiki via an OpenConnector source) is
    deferred until the umbrella + leaf PRs merge and deploy

Spec deviations recorded inline:
  - `requiredApp` is `'openconnector'` (not `null`) — more accurate
    for the admin UI: the integration genuinely needs that app
  - the OpenConnector source-config template lives at
    `docs/Integrations/xwiki-openconnector-source.yaml`, not
    `config/openconnector-sources/xwiki.yaml` (the repo's `config/`
    dir is gitignored)
  - the provider is registered via `addProvider()` at boot (not a DI
    tag) — consistent with the umbrella's registration mechanism
  - `check-integration-parity.js` (Node, repo convention) not `.sh`
…ve XWiki REST verification

Spun up xwiki:lts 17.10 locally, created a test page via REST
(PUT /rest/wikis/xwiki/spaces/Sandbox/pages/IntegrationTest) and
inspected the page representation. Findings folded into the source
template:

  - XWiki is deployed at the ROOT context here — REST is at `/rest/`,
    not `/xwiki/rest/`. (The template's `location` is the wiki's REST
    base URL, so this is just a deployment note.)
  - The REST page representation's `content` field is RAW xwiki/2.1
    syntax, NOT rendered HTML. For a clean AD-1 text preview the
    source should fetch the rendered body from
    `GET /bin/get/{Space}/{Page}?xpage=plain` (XWiki renders macros
    server-side in its own sandbox — never inside Nextcloud) and map
    that to `content`. Mapping `content` straight from the REST field
    is still safe (macro markup is inert text) — documented both ways.
  - XWiki exposes a native breadcrumb at `hierarchy.items[].label`
    ("xwiki" / "Sandbox" / "IntegrationTest") — map `breadcrumb` from
    it (AD-3). Without it XwikiProvider derives a coarse one from
    space + title.
  - Confirmed the other mappings: `id` → `reference`
    ("xwiki:Space.Page"), `title`, `space`, `name` → `page`,
    `xwikiAbsoluteUrl` → `url`.

No PHP change needed — XwikiProvider::normalizeRow() already reads
`breadcrumb` (with the space+title fallback) and `content` (with the
`renderedContent` fallback), so it works against either mapping.
…s-built implementation

Brings the leaf's design.md and proposal.md in line with what
actually shipped (tasks.md was already synced):

  - design.md: AD-1 records the verified XWiki REST shape (the REST
    `content` field is raw xwiki/2.1 syntax — the rendered body comes
    from `/bin/get/{Space}/{Page}?xpage=plain`, macros run server-side
    in XWiki's sandbox, never in NC). AD-3 records that `breadcrumb`
    maps from XWiki's native `hierarchy.items[].label`. "Files
    Affected" updated to the real paths/structure. New "Implementation
    deviations" table (requiredApp `openconnector` not `null`; source
    template under `docs/Integrations/` not `config/`; `addProvider()`
    at boot not a DI tag; `check-integration-parity.js` not `.sh`).
    Softened the "established by integration-openproject" line — the
    external-storage machinery comes from the umbrella, this is the
    first external leaf.
  - proposal.md: `Required NC app` → `openconnector` (with the
    rationale); acceptance criteria checked off against the component/
    unit tests that cover them, with the live runtime check called out
    as deploy-gated.

Doc-only; no code change.
… / searchResults)

normalizeList() previously only un-wrapped `{ results: [...] }` / `{ items: [...] }`
(a purpose-built OpenConnector source) and otherwise iterated the response's *values*
as if they were rows — which mangles XWiki's own REST shapes. Now it also recognises
`pageSummaries` (space listing: GET /rest/wikis/<wiki>/spaces/<Space>/pages) and
`searchResults` (search), uses a bare list as-is, and returns an empty list for an
assoc envelope with no rows key (instead of garbage rows). normalizeRow() gains a
last-resort `url` fallback that pulls the `rel=".../rel/page"` href out of XWiki's
`links` array (present on page summaries / search hits) when the source didn't map a
flat URL field. This lets the `xwiki` integration work against an OpenConnector source
pointed straight at XWiki's REST API, not just a custom proxy endpoint.

Tests: +2 (raw pageSummaries envelope incl. the links-based url fallback; assoc
envelope with no rows key → []).

Relates to #1307 (umbrella) / #1326 (xwiki leaf).
$rows = $response;
if (isset($response['results']) === true && is_array($response['results']) === true) {
$rows = $response['results'];
} else if (isset($response['items']) === true && is_array($response['items']) === true) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] else if must be elseif — phpcs CI failure

The normalizeList method uses } else if ( which violates Squiz.ControlStructures.ElseIfDeclaration enforced in phpcs.xml. This is the direct cause of the PHP Quality phpcs CI failure. Fix: change } else if ( to } elseif (.

$reference = (string) ($row['reference'] ?? $row['pageReference'] ?? $row['id'] ?? '');
$space = (string) ($row['space'] ?? $row['spaceName'] ?? '');
$page = (string) ($row['page'] ?? $row['pageName'] ?? $row['name'] ?? '');
$title = (string) ($row['title'] ?? $page ?? $reference);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BLOCKER] Title fallback chain is broken — $reference can never be used

$page is assigned as (string)(... ?? '') — it is always a string, never null. The subsequent $title = (string)($row['title'] ?? $page ?? $reference) means $reference is dead code: if $row['title'] is absent, PHP coerces the empty string $page rather than falling through to $reference. When both $row['title'] and $row['page']/$row['name'] are missing, the title silently becomes '' instead of the canonical reference. Fix: change $page = (string)(... ?? '') to $page = (... ?? null) to preserve the null-fallthrough.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] No test covering ProviderUnavailableException propagation

The provider contract explicitly states that ProviderUnavailableException bubbles from the router to the controller. The test suite has 10 tests covering the happy path but zero tests where the router mock throws ProviderUnavailableException. At minimum list() and get() should have error-path tests asserting the exception propagates unmodified.

* the `@conduction/nextcloud-vue` xwiki tab + card expect. Unknown
* keys on the source row are preserved so the source can pass
* extra fields through without a provider change.
*
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] normalizeList has no fallback for 'data' or 'pages' wrapper keys

The method handles results and items but not data (used by many REST frameworks) or pages (a natural key for a paged XWiki endpoint). If OpenConnector normalises the XWiki REST response into { data: [...] } the code falls through to iterating the top-level map and returns [] instead of the expected list. A 'data' branch or a comment explicitly documenting why data is not supported would clarify intent.

if (is_array($breadcrumb) === false || $breadcrumb === []) {
// Best-effort breadcrumb from the reference if the source
// didn't supply one — "Space / Page" or just the reference.
$breadcrumb = array_values(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] Breadcrumb fallback code has inconsistent indentation — likely triggers additional phpcs violations

The nested array_values(array_filter(array_merge(...))) block has misaligned opening and closing parentheses that do not follow the 4-space indent rule. This may generate additional phpcs violations beyond the elseif issue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] XwikiProvider registered in bootBuiltinIntegrationProviders — misleading coupling for a leaf provider

bootBuiltinIntegrationProviders() is named for built-in providers that ship unconditionally. XwikiProvider is an external leaf that requires OpenConnector and a running XWiki. A separate bootLeafIntegrationProviders() or at minimum a descriptive comment block separating the sections would make the coupling explicit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[CONCERN] healthCheck targets the root wiki URL — no dedicated probe

The healthCheck.path is "" (the source base URL), which is also the implicit target for list calls. A 404 from a misconfigured location is indistinguishable from a network outage. XWiki REST exposes /rest/ as a lighter meta endpoint that is more appropriate as a health probe.

Copy link
Copy Markdown
Contributor

@WilcoLouwerse WilcoLouwerse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

🔴 Blockers (2)

🟡 Concerns (5)

🟢 Minor (3)

  • get() passes an empty $filters array to contextQuery unnecessarily (lib/Service/Integration/Providers/XwikiProvider.php:473)
    get() calls $this->contextQuery(..., filters: []) with a hardcoded empty array. Let contextQuery default $filters to [] and omit the explicit argument, consistent with the delete() call.
  • Class is in Providers\ namespace but registered alongside BuiltinProviders\ in Application.php (lib/Service/Integration/Providers/XwikiProvider.php:284)
    The provider lives in …\Integration\Providers\ while other providers live in …\BuiltinProviders\. A one-line comment in the class docblock noting the leaf namespace distinction would prevent confusion for future contributors.
  • File-level @author email differs from Application.php canonical address (lib/Service/Integration/Providers/XwikiProvider.php:1)
    The file docblock uses <info@conduction.nl> while other files use <dev@conductio.nl> (note: conductio.nl, not conduction.nl). Align with the repo-wide convention.

Reviewed by WilcoLouwerse via automated batch review.

…t: application/json

Three follow-ups that make the `xwiki` provider work against a stock
OpenConnector source (verified end-to-end against a live XWiki):

- ExternalIntegrationRouter::decodeResponse() — OpenConnector's CallService
  returns a CallLog whose getResponse() is { statusCode, headers, body, encoding };
  the upstream payload is the (usually JSON) `body` string. Unwrap + JSON-decode
  it (base64 bodies decoded first) instead of serialising the whole CallLog —
  previously the provider only ever saw the CallLog metadata, so list() returned [].
- ExternalIntegrationRouter::assertUpstreamOk() — a >= 400 upstream status (carried
  on the CallLog) now throws ProviderUnavailableException rather than letting an
  error page leak through as rows; 401/403 → the new CAUSE_PROVIDER_AUTH so the
  UI shows the reconnect banner.
- XwikiProvider — every call now carries `Accept: application/json` (XWiki's REST
  API negotiates XML by default; writes also declare a JSON Content-Type). A
  purpose-built source that already pins these is unaffected.
- ProviderUnavailableException::CAUSE_PROVIDER_AUTH constant added (the JS side —
  CnXwikiTab.degradedMessage — already treats `provider-auth` as a reconnect cause).

Tests: ExternalIntegrationRouterTest +4 (CallLog body unwrap, base64 body, 401→provider-auth,
500→upstream-down); XwikiProviderTest assertions updated for the new request headers.
phpunit: 21 green.

Relates to #1307 (umbrella) / #1326 (xwiki leaf) / ConductionNL/openconnector#755.
- normalizeRow: fix title fallback chain — $page was cast to string
  so `?? $reference` was unreachable. Use elvis so we land on
  whichever piece carries real content (title → page → reference).
- normalizeRow: re-indent breadcrumb array_values/filter/merge call
  per phpcs PSR-2.
- header: add SPDX-License-Identifier + SPDX-FileCopyrightText
  inside the file docblock per project convention
  (feedback_spdx-in-docblock.md).

Review: WilcoLouwerse on PR #1493.
Base automatically changed from feature/1307/registry-docs-ci-tracking to development May 18, 2026 11:19
@rubenvdlinde rubenvdlinde merged commit 523d00d into development May 18, 2026
16 of 19 checks passed
@rubenvdlinde rubenvdlinde deleted the feature/1326/integration-xwiki branch May 18, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants