Skip to content
149 changes: 149 additions & 0 deletions docs/Integrations/xwiki-openconnector-source.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# OpenConnector source template — XWiki
#
# Import this into OpenConnector (Settings → OpenConnector → Sources →
# Import) and fill in the `location` + auth fields for your XWiki
# instance. OpenRegister's `XwikiProvider` (id: `xwiki`, group:
# `external`) routes all of its CRUD through this source via
# ExternalIntegrationRouter; it never holds credentials itself.
#
# (The repo's `config/` directory is gitignored, so this template
# lives under docs/Integrations/ — copy it to wherever your
# OpenConnector instance imports source definitions from.)
#
# XWiki version notes:
# - 5.x / 10.x+ / 14.x+ all expose a REST API under /rest. The
# mapping below uses the generic page endpoints; OpenConnector's
# adapter normalises field-name drift across versions.
# - Authentication is customer-dependent: HTTP Basic works on every
# version; OAuth2 (via the xwiki-platform-oidc extension) is
# available on newer instances. Pick ONE of the `auth` blocks
# below and delete the other.
#
# Spec: openspec/changes/integration-xwiki/design.md
# - AD-1: detail-page preview is text-only — request the
# already-rendered HTML, the @conduction/nextcloud-vue widget
# strips it to text and truncates to ~500 chars; macros are NEVER
# executed in the preview.
# - AD-2: a page may be linked by full XWiki URL or by a `Space.Page`
# path — the `create` endpoint resolves both to canonical.
# - AD-3: rows carry a `breadcrumb` so the UI can disambiguate
# same-titled pages in different spaces.

source:
# Stable id — MUST be `xwiki` (matches XwikiProvider::getOpenConnectorSource()).
reference: xwiki
name: XWiki
description: >-
XWiki pages linked to OpenRegister objects. Surfaced as the
"Articles" integration (sidebar tab + dashboard/detail widgets).
type: api

# Base URL of the XWiki REST API. Example for a wiki at
# https://wiki.example.org with the default "xwiki" wiki:
# https://wiki.example.org/rest/wikis/xwiki
location: "https://wiki.example.org/rest/wikis/xwiki"

# ---------------------------------------------------------------
# Authentication — keep exactly ONE of the two blocks below.
# ---------------------------------------------------------------

# Option A — HTTP Basic (works on all XWiki versions).
auth:
type: basic
username: "${XWIKI_USERNAME}"
password: "${XWIKI_PASSWORD}"

# Option B — OAuth2 (xwiki-platform-oidc; newer instances only).
# auth:
# type: oauth2
# grantType: client_credentials
# tokenUrl: "https://wiki.example.org/oidc/token"
# clientId: "${XWIKI_OIDC_CLIENT_ID}"
# clientSecret: "${XWIKI_OIDC_CLIENT_SECRET}"
# scope: "openid profile"

headers:
Accept: application/json

# ---------------------------------------------------------------
# Endpoint mapping consumed by XwikiProvider. The provider sends
# the object context as query params (register / schema / object)
# plus optional _search / _limit / _page; OpenConnector resolves
# those to the appropriate XWiki REST calls and normalises the
# response rows to { reference, title, space, page, breadcrumb,
# url, content }.
# ---------------------------------------------------------------
#
# XWiki REST shape — verified against xwiki:lts 17.10 (Tomcat, ROOT
# context, REST at `/rest/`):
# GET /rest/wikis/xwiki/spaces/{Space}/pages/{Page} →
# { id: "xwiki:Space.Page", space, name, title, xwikiAbsoluteUrl,
# content (RAW xwiki/2.1 syntax — NOT rendered HTML),
# hierarchy: { items: [{ label, name, type: wiki|space|document,
# url }] }, version, modified, … }
#
# Two things to get right when wiring the response mapping:
# 1. AD-3 breadcrumb — XWiki's `hierarchy.items[].label` IS the
# native breadcrumb ("xwiki" / "Sandbox" / "IntegrationTest").
# Map `breadcrumb` from it. (If you don't, OpenRegister's
# XwikiProvider derives a coarse one from space + title.)
# 2. AD-1 preview content — the REST `content` field is RAW wiki
# syntax, not HTML. For a clean text preview, fetch the
# already-rendered page body from
# GET /bin/get/{Space}/{Page}?xpage=plain (→ HTML)
# (XWiki executes the page's macros server-side, in XWiki's own
# sandbox — they are NEVER executed inside Nextcloud; the widget
# only ever reads the text content) and map that to `content`.
# If instead you map `content` straight from the REST `content`
# field, the preview shows the raw wiki source — still safe
# (macro markup is inert text), just less polished.
endpoints:
# GET — list linked pages for an object
list:
method: GET
path: "" # relative to `location`
# The pairing store — which XWiki pages are linked to which OR
# object — lives in OpenConnector's own pairing model, keyed by
# the {register, schema, object} context. OpenConnector resolves
# the linked references, fetches each page's metadata from XWiki
# REST, and (for the preview) the rendered body from
# /bin/get/{Space}/{Page}?xpage=plain.
response:
rowsPath: "$" # bare array, or "$.results"
map:
reference: "id" # e.g. "xwiki:Sandbox.IntegrationTest"
title: "title"
space: "space"
page: "name"
url: "xwikiAbsoluteUrl"
breadcrumb: "hierarchy.items[*].label" # AD-3 — native XWiki breadcrumb
content: "renderedContent" # AD-1 — set from /bin/get/...?xpage=plain (see note above)

# GET {id} — one linked page (with rendered content for the preview)
get:
method: GET
path: "{id}"

# POST — link a page (body carries `reference`: a full XWiki URL
# or a `Space.Page` path; OpenConnector resolves both to canonical)
create:
method: POST
path: ""

# PUT {id} — update a pairing
update:
method: PUT
path: "{id}"

# DELETE {id} — unlink (removes the pairing; does NOT delete the
# page in XWiki)
delete:
method: DELETE
path: "{id}"

# Health probe used by OpenRegister's admin UI / OCS capabilities.
# A cheap authenticated GET that returns 200 when the instance is
# reachable and the credentials are valid.
healthCheck:
method: GET
path: ""
28 changes: 28 additions & 0 deletions docs/Integrations/xwiki.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# XWiki Integration ("Articles")

Link XWiki pages to OpenRegister objects. Appears as the **Articles** tab in the object sidebar, as an `xwiki` widget on dashboards and detail pages, and as a single-entity reference renderer for schema properties that declare `referenceType: 'xwiki'`.

This is an **external** integration (storage strategy `external`, group `external`) — it is the worked example for the [pluggable integration registry](pluggable-integration-registry.md). It carries no HTTP client and no credentials of its own: all CRUD is delegated to an OpenConnector source named `xwiki`.

## Setup

1. **Install OpenConnector** (the `openconnector` Nextcloud app). The Articles integration is hidden until it's installed and enabled.
2. **Create the `xwiki` source in OpenConnector** — import the template at [`xwiki-openconnector-source.yaml`](xwiki-openconnector-source.yaml), then fill in:
- `location` — your XWiki REST base URL, e.g. `https://wiki.example.org/rest/wikis/xwiki`
- the `auth` block — HTTP Basic (works on every XWiki version) **or** OAuth2 (xwiki-platform-oidc; newer instances). Keep exactly one.
3. **Verify** in OpenRegister → Administration → OpenRegister → Integrations: the `xwiki` row should show storage `external`, required app `openconnector`, and an auth/health status. The "Configure" link deep-links into OpenConnector's source page; "Test connection" probes the source.

## Using it

- **Sidebar (Articles tab)** — shows linked pages with their full breadcrumb ("Wiki / Department / Subspace / Page"), since two pages can share a title in different spaces. Link a page by pasting its **URL** (parsed to a canonical `Space.Page` reference) or by typing the **path** directly. Unlink removes the pairing only — it never deletes the page in XWiki. An "authentication expired" banner appears if the source's credentials need re-connecting.
- **Detail-page widget** — linked pages list plus a **text preview** (the first ~500 characters of the page's rendered content) and a link to the full page. **XWiki macros are not executed** in the preview (no Velocity templates / scripts run inside Nextcloud) — click through to XWiki for full rendering.
- **Dashboard widget** — recent linked pages (user dashboard) or app-scoped (app dashboard).
- **Reference property** — a schema property with `referenceType: 'xwiki'` renders the linked page's title + breadcrumb chip in `CnFormDialog` / `CnDetailGrid`.

## Notes

- **Permissions** — the integration inherits access from the underlying object's RBAC plus OpenConnector's own. If a user has Nextcloud access to the object but not XWiki access to a page, they see a "No access to page" placeholder, not an internal error.
- **XWiki versions** — 5.x / 10.x+ / 14.x+ are all supported; the OpenConnector adapter normalises REST field-name drift, so the provider stays version-agnostic.
- **Failure modes** — if OpenConnector is missing/disabled, the source is missing, or the remote XWiki is down, the tab degrades to an empty state with a clear message rather than a broken tab (AD-23).

See [pluggable-integration-registry.md](pluggable-integration-registry.md) for how this integration is wired into the registry, and `openspec/changes/integration-xwiki/` for the change spec.
18 changes: 18 additions & 0 deletions lib/AppInfo/Application.php
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.

Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,22 @@ function (ContainerInterface $container) {
);
}
);

// Leaf provider: XWiki (external, OpenConnector-backed). Ships
// in-repo as the worked external-storage example; routed
// through ExternalIntegrationRouter, credentials on the
// OpenConnector `xwiki` source.
// @spec openspec/changes/integration-xwiki/tasks.md
$context->registerService(
\OCA\OpenRegister\Service\Integration\Providers\XwikiProvider::class,
function (ContainerInterface $container) {
return new \OCA\OpenRegister\Service\Integration\Providers\XwikiProvider(
router: $container->get(\OCA\OpenRegister\Service\Integration\ExternalIntegrationRouter::class),
appManager: $container->get('OCP\App\IAppManager'),
l10n: $container->get('OCP\IL10N'),
);
}
);
}//end registerBuiltinIntegrationProviders()

/**
Expand Down Expand Up @@ -1183,6 +1199,8 @@ private function bootBuiltinIntegrationProviders($server): void
\OCA\OpenRegister\Service\Integration\BuiltinProviders\TasksProvider::class,
\OCA\OpenRegister\Service\Integration\BuiltinProviders\TagsProvider::class,
\OCA\OpenRegister\Service\Integration\BuiltinProviders\AuditTrailProvider::class,
// Leaf: XWiki (external) — see openspec/changes/integration-xwiki.
\OCA\OpenRegister\Service\Integration\Providers\XwikiProvider::class,
];

foreach ($providerClasses as $providerClass) {
Expand Down
1 change: 1 addition & 0 deletions lib/Exception/ProviderUnavailableException.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class ProviderUnavailableException extends \RuntimeException
public const CAUSE_OPENCONNECTOR_DOWN = 'openconnector-down';
public const CAUSE_OPENCONNECTOR_SOURCE_MISSING = 'openconnector-source-missing';
public const CAUSE_UPSTREAM_SERVICE_DOWN = 'upstream-service-down';
public const CAUSE_PROVIDER_AUTH = 'provider-auth';

/**
* The cause classification.
Expand Down
62 changes: 59 additions & 3 deletions lib/Service/Integration/ExternalIntegrationRouter.php
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,13 @@ private function invoke($source, string $method, string $path, array $options):

if (method_exists($callService, 'call') === true) {
$response = $callService->call($source, $path, $method, $options);
$this->assertUpstreamOk($response);
return $this->decodeResponse($response);
}

if (method_exists($callService, 'request') === true) {
$response = $callService->request($source, $method, $path, $options);
$this->assertUpstreamOk($response);
return $this->decodeResponse($response);
}

Expand All @@ -343,12 +345,49 @@ private function invoke($source, string $method, string $path, array $options):
);
}//end invoke()

/**
* Treat a >= 400 upstream status (carried on the CallLog OpenConnector
* returns) as an upstream failure rather than letting an error page
* leak through as "rows". A 401/403 specifically is re-flagged as
* `provider-auth` so the UI shows the "reconnect connector" banner.
*
* @param mixed $response The CallService return value.
*
* @return void
*
* @throws ProviderUnavailableException When the upstream answered >= 400.
*/
private function assertUpstreamOk($response): void
{
if (is_object($response) === false || method_exists($response, 'getStatusCode') === false) {
return;
}

$status = (int) $response->getStatusCode();
if ($status < 400) {
return;
}

$cause = ($status === 401 || $status === 403)
? ProviderUnavailableException::CAUSE_PROVIDER_AUTH
: ProviderUnavailableException::CAUSE_UPSTREAM_SERVICE_DOWN;

throw new ProviderUnavailableException(
sprintf('Upstream service answered HTTP %d.', $status),
$cause
);
}//end assertUpstreamOk()

/**
* Normalise a CallService response into a decoded array.
*
* CallService returns either a CallLog entity (carrying a JSON
* body), a raw array, or a scalar string. We always normalise to
* an array; non-arrays are wrapped under a `body` key so callers
* OpenConnector's CallService returns a `CallLog` whose
* `getResponse()` is `{ statusCode, headers, body, encoding, … }` —
* the actual upstream payload is the (usually JSON) `body` string
* (base64-encoded when the upstream wasn't UTF-8). We unwrap that,
* JSON-decode it, and hand the caller the upstream body directly.
* A raw array / scalar string from an older CallService is decoded
* in place; non-arrays are wrapped under a `body` key so callers
* have a stable shape to introspect.
*
* @param mixed $response The raw return from CallService.
Expand All @@ -361,6 +400,23 @@ private function decodeResponse($response): array
return $response;
}

// CallLog (OpenConnector) — pull the upstream body out of getResponse().
if (is_object($response) === true && method_exists($response, 'getResponse') === true) {
$payload = $response->getResponse();
if (is_array($payload) === true && array_key_exists('body', $payload) === true) {
$body = $payload['body'];
if (($payload['encoding'] ?? null) === 'base64' && is_string($body) === true) {
$body = (string) base64_decode($body, true);
}

return $this->decodeResponse($body);
}

if (is_array($payload) === true) {
return $payload;
}
}

if (is_object($response) === true && method_exists($response, 'jsonSerialize') === true) {
$data = $response->jsonSerialize();
return is_array($data) === true ? $data : ['body' => $data];
Expand Down
Loading
Loading