diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e3272a7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,207 @@ +# AGENTS.md + +Instructions for AI coding agents working in this repository. + +## Repository purpose + +Java SDK for Hyland CIC services: modular HTTP clients, extensible +JSON serialization/mapping, and high-level APIs for Ingest, Agent, QnA, +Nucleus, Knowledge Enrichment and Data Curation. See `README.md` for the +architecture diagram and per-layer documentation. + +## Module layout + +Multi-module Maven reactor, root `pom.xml` (`packaging=pom`): + +- `cic-http-client` — shared foundation: `AbstractHttpClient` / + `AbstractHttpClientBuilder` (+ authenticated variants), retry/backoff + (`RetryPolicy`, `BackoffStrategy`, `RetryCondition`), the `CICObject` / + `CICArray` / `CICNode` mapper-object API, `CICError`, pagination helpers. + Every other module depends on it; generic behavior changes here affect + all modules — call that out explicitly in the PR description. +- `cic-http-client-jackson2` — Jackson2-based `SerializerFactory` + implementation plugged into `cic-http-client`. +- `cic-ingest`, `cic-agent`, `cic-qna`, `cic-nucleus`, `cic-ke` — one + module per CIC product area; a module may host several API surfaces + (e.g. `cic-ke` covers both KE and Data Curation). Each surface follows + the same internal shape: `XxxHttpClient` (wire/auth layer) → + `XxxService` (higher-level API) → `object/` (public POJOs/records) → + `mapper/` (`CICObject`/`CICArray` ⇄ POJO conversion), with a single + per-module `XxxMapperFactory` registering all mappers. +- New modules extend `AbstractAuthenticatedHttpClient` / + `AbstractAuthenticatedHttpClientBuilder` from `cic-http-client`. + +## Build, format, test + +- JDK version is set via `maven.compiler.release` in the root `pom.xml` + (currently 17). +- Build/install everything: `mvn --batch-mode -nsu install` +- Format (required before committing — Spotless runs in the `validate` + phase and fails the build if unformatted): `mvn spotless:apply` + - Config: `sdk-formatter.xml` (Eclipse formatter) + `sdk.importorder` + (import order), wildcard imports forbidden, unused imports removed. + - If Spotless can't produce readable multi-line array/constant + layouts, prefer adding a trailing `//` comment on each line over + fighting the formatter config. +- E2E tests live behind the `e2e` Maven profile (`maven-failsafe-plugin`), + run per-module, e.g.: `mvn --batch-mode -nsu verify -Pe2e -pl cic-ke -am` + - They hit real CIC staging endpoints via module-prefixed secrets + (e.g. `CIC_KE_*`); keep secret/env-var naming scoped to the module + that actually uses them, don't reuse generic names across modules. +- Follow Maven Failsafe's default include/exclude naming conventions for + IT classes instead of hand-rolled include/exclude patterns in the POM. +- CI: `.github/workflows/build.yml` (install + deploy to GitHub Packages + on push to `main`) and `.github/workflows/release.yml`. + +## Design conventions (distilled from PR review history) + +These are the recurring, consistently-enforced points from code review on +this repo (`@kevinleturc`, `@damianujma` and others). Apply them +proactively, don't wait to be asked: + +- **The SDK's job is to define the contract.** Prefer typed POJOs/records + over `Object`, raw `CICObject`/`CICArray`, or stringly-typed fields — + the point of the SDK is to describe what the CIC REST API actually + returns/accepts. Don't make endpoint paths configurable "just in case"; + if a client needs a different (e.g. deprecated) endpoint, that's a sign + the client is using the wrong contract, not that the SDK should bend. +- **Default to `record` for immutable value objects — but not always.** + Use a `record` when a public, canonical (all-args) constructor is an + acceptable construction path — a convenience `Builder` can still sit on + top as long as its `build()` just forwards 1:1 to + `new MyRecord(a, b, c, ...)`; even non-trivial validation (including + "exactly one of X or Y" checks) belongs in the compact constructor + (e.g. `ObjectKey`). Collection-typed record components must never be + null — normalize `null` to `List.of()`/`Map.of()` in the compact + constructor, and defensively copy (`List.copyOf(...)`) mutable inputs. + If "absent" is a meaningful state distinct from "empty", model it + explicitly rather than overloading null. + Reach for a plain `final class` instead (with a manually written + `equals`/`hashCode`/`toString`) when any of these apply: + - It must `extend` another class — records can't (e.g. the `XxxPage` + type-token classes extending `CursorPageableResponse`). + - A field is genuinely optional and you want the accessor to expose it + as `Optional` for callers — record components are never declared + `Optional` (see above), but a class can still store the field as a + plain nullable internally while returning `Optional.ofNullable(...)` + from its getter. + - Construction should be fully encapsulated behind the `Builder` — + i.e. there is deliberately no public all-args constructor, only a + `private`/`protected Xxx(Builder builder)` — typically because the + field set is large and/or optional-heavy enough that a positional + constructor would be error-prone, or because the type is only ever + meant to be obtained via a cached singleton/static factory (e.g. + `ActionConfig.empty()`). + - It has **more than 5 fields** — beyond that point a flat positional + constructor (even hidden behind a thin `Builder`) becomes hard to + read and error-prone to maintain; prefer a plain class with a + properly encapsulated `Builder`, or decompose into smaller + sub-objects grouping related fields. + - The `Builder` itself is stateful/incremental rather than a 1:1 + field-setter mirror of the components — e.g. `addClass(String)`, + `instruction(key, value)`, or a `putProperty(key, value)` style API + that accumulates into an internal `ArrayList`/`LinkedHashMap` across + multiple calls (see `IngestEvent.Builder`, `ActionConfig.Builder`). +- **Prefer `Duration` and standard types** over raw nullable fields, + bare seconds/millis longs, etc. (e.g. `uptimeSeconds` → use `Duration`). + Do not use `Optional` as a record/field component — no record in the + SDK does this today; model a by-contract-nullable field as a plain + nullable field instead. +- **Mapper hierarchy, not monolith mappers.** Each nested object/value + gets its own mapper class; a parent mapper delegates to child mappers + and should mostly just `.map(...)` over an `Optional`/list rather than + inlining nested parsing logic. Avoid one big `XxxMapperUtils` grab-bag. + Centralize registration in a single `XxxMapperFactory` + (see `AgentMapperFactory` / `NucleusMapperFactory` / `QnaMapperFactory` + for the expected `List, CICMapper>>` static-init + pattern) so mappers are instantiated once. +- **Use the `CICObject`/`CICArray` typed getters** (`getStringOrThrow`, + `getOptionalString`, `getOptionalObject`, `getOptionalArray`, + `getObjectOrThrow`, `getArrayOrThrow`, `getLocalDate`/`putLocalDate`, + etc.) instead of generic `getProperties`/manual null/type checks. If a + getter you need doesn't exist yet, add it to `CICObject`/`CICArray` + rather than reimplementing the logic locally — this is a common, + welcomed pattern across PRs. +- **Don't leak the mapper-object API into public POJOs.** Public + SDK objects (e.g. `IngestEvent` properties) should stay as plain + bean-shaped types; don't expose `CICObject`/`CICArray` directly on the + public surface just because it's convenient internally. +- **Collections returned/accepted by public APIs should be unmodifiable** + (e.g. `CICArray`/`CICObject` backing collections) so callers are forced + through the typed `add*`/mutation API instead of mutating state + directly. +- **Null-check constructor/builder arguments** with + `Objects.requireNonNull(arg, "arg cannot be null")`, and validate + semantically-invalid values (e.g. null entries in an ID list) explicitly + rather than trusting callers. +- **Don't duplicate logic across modules.** If something in a new module + looks like it was copy-pasted from another module's client/service + (e.g. presigned-URL handling in `cic-ke` vs `cic-ingest`), flag it for + factoring into a shared location (follow-up PR is fine, but call it out + in review). +- **Exception messages matter to integrators.** Don't rewrite or augment + a caller-provided exception message with internal detail by default — + some callers surface that string directly (e.g. straight into an HTTP + response). Preserve the original message/cause chain (suppressed + exceptions) rather than discarding error context. +- **No customer names or confidential references** anywhere in the repo + (code, tests, fixtures, README) — this repo is public. Use generic + placeholders instead (e.g. `pretrained-model-a`). +- **Binary/media test fixtures go through Git LFS**, not committed + directly — check `.gitattributes` coverage when adding new fixture + types, and double-check there's no privacy/licensing concern with any + fixture content before adding it. +- **Prefer braces on every `if`/`else`/loop body**, even single-line ones. +- **Test classes and test methods must be `public`.** +- **Keep Javadoc accurate**, including `@since` tags matching the actual + next release version (this repo publishes no GitHub Releases — read + the latest tag with `git tag --sort=-v:refname | head -1`, and don't + assume the current `-SNAPSHOT` version number is it). +- When a mapper or object design choice isn't obvious from the OpenAPI + spec, **verify against the spec** (or staging) rather than guessing, + and note in the PR/commit what was confirmed and how. + +## Backward compatibility + +The project follows semantic versioning (`MAJOR.MINOR.PATCH`). The +public API (anything publicly accessible: classes, interfaces, methods, +constructors, fields, enum constants, record components, etc. not marked +internal/SPI-only) **must not break between patch or minor releases**. +A breaking change is only allowed in the next **major** release, and only +after going through a deprecation cycle: + +- To remove/change public API, first mark it `@Deprecated(since = "x.y.z", + forRemoval = true)` and keep it fully functional (delegating to the new + API internally if needed), released in a minor/patch version. +- Document the deprecation and the replacement in the relevant + `README.md` section (see the `PropertyArray` entry for the expected + format) and in the Javadoc (`@deprecated` tag pointing to the + replacement). +- Only remove the deprecated API in the next major version. +- This applies to source and binary compatibility alike, including + changes that are easy to overlook: + - Turning a `class` into a `record` (or vice versa) — this changes the + type hierarchy (a `record` is implicitly `final` and extends + `Record`), can alter `equals`/`hashCode`/`toString` semantics, and + changes accessor method names (`getFoo()` vs `foo()`). + - Removing or reordering record components — this changes the + canonical constructor signature. Adding a new component is allowed + without a major bump only if an overload constructor matching the + previous signature is kept (delegating to the new canonical + constructor with a sensible default for the added component). + - Adding a new method to a public `interface` — this breaks existing + implementers unless given a `default` implementation. + - Changing a method's return type, a parameter type, or widening an + exception, even if the change looks "more correct". + - Changing the semantics of a getter (e.g. switching what counts as + "empty" vs `null`) without a version bump and changelog entry. + - Renaming a public class, method, or field. +- When unsure whether a change is breaking, treat it as breaking and + follow the deprecation cycle above. + +## Commit / PR conventions + +- Reference the driving Jira ticket in the PR title, e.g. + `NXENG-724: add CICBlob.builder(Path) for file-backed blobs`. +- See `CONTRIBUTING.md` for contributor requirements, including the CLA, + build/format commands, and PR expectations. diff --git a/cic-governance/pom.xml b/cic-governance/pom.xml new file mode 100644 index 0000000..d3ca6a8 --- /dev/null +++ b/cic-governance/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + org.hyland.sdk + cic-java-sdk + 1.1.1-SNAPSHOT + + cic-governance + jar + Hyland CIC Governance Client + Client to integrate with CIC Governance + + + + org.hyland.sdk + cic-http-client + + + org.hyland.sdk + cic-http-client-jackson2 + test + + + + diff --git a/cic-governance/src/main/java/org/hyland/sdk/cic/governance/GovernanceHttpClient.java b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/GovernanceHttpClient.java new file mode 100644 index 0000000..dfd154c --- /dev/null +++ b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/GovernanceHttpClient.java @@ -0,0 +1,104 @@ +/* + * (C) Copyright 2026 Hyland (https://hyland.com/) and others. + * + * 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. + * + * Contributors: + * Kevin Leturc + */ +package org.hyland.sdk.cic.governance; + +import static org.hyland.sdk.cic.http.client.base.CICHttpRequest.POST; + +import org.hyland.sdk.cic.governance.object.ContentEvent; +import org.hyland.sdk.cic.http.client.CICSdkException; +import org.hyland.sdk.cic.http.client.auth.AbstractAuthenticatedHttpClient; +import org.hyland.sdk.cic.http.client.auth.AbstractAuthenticatedHttpClientBuilder; +import org.hyland.sdk.cic.http.client.auth.AuthenticationHttpClient; +import org.hyland.sdk.cic.http.client.base.CICHttpRequest.CICEntity; +import org.hyland.sdk.cic.http.client.util.ErrorUtils; + +/** + * HTTP client for interacting with the CIC Governance service. + * + * @since 1.2.0 + */ +public class GovernanceHttpClient extends AbstractAuthenticatedHttpClient { + + private static final String RECORDS_EVENT_PATH = "/api/records/event"; + + // --------------- + // Instantiation + // --------------- + + protected GovernanceHttpClient(Builder builder) { + super(builder); + } + + public static Builder from() { + // TODO turn this to production + return from("https://api.governance.sandbox.experience.hyland.com"); + } + + public static Builder from(String baseUrl) { + return from(baseUrl, AuthenticationHttpClient.from()); + } + + public static Builder from(String baseUrl, AuthenticationHttpClient.Builder authenticationBuilder) { + return new Builder(baseUrl, authenticationBuilder); + } + + // -------------- + // Service APIs + // -------------- + + /** + * Sends a content event to the CIC Governance service. + * + * @param event the content event to send + * @throws CICSdkException if the request fails or returns a non-202 status code + */ + public void sendEvent(ContentEvent event) { + var request = this.requestBuilder(POST, RECORDS_EVENT_PATH) + .header("Content-Type", "application/json") + .entity(new CICEntity(event)) + .build(); + + var response = sendThenReadAsString(request); + int statusCode = response.statusCode(); + if (statusCode != 202) { + ErrorUtils.throwException(response, + "Couldn't send the content event, HTTP response returned with status code: " + statusCode); + } + } + + public static class Builder extends AbstractAuthenticatedHttpClientBuilder { + + /** + * Creates a builder for GovernanceHttpClient. + * + * @param baseUrl the base URL of the CIC Governance service + * @param authenticationBuilder the authentication builder + */ + public Builder(String baseUrl, AuthenticationHttpClient.Builder authenticationBuilder) { + super(baseUrl, authenticationBuilder); + header("Accept", "application/json"); + } + + @Override + public GovernanceHttpClient build() { + return new GovernanceHttpClient(this); + } + } + +} diff --git a/cic-governance/src/main/java/org/hyland/sdk/cic/governance/GovernanceService.java b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/GovernanceService.java new file mode 100644 index 0000000..13a616f --- /dev/null +++ b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/GovernanceService.java @@ -0,0 +1,46 @@ +/* + * (C) Copyright 2026 Hyland (https://hyland.com/) and others. + * + * 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. + * + * Contributors: + * Kevin Leturc + */ +package org.hyland.sdk.cic.governance; + +import org.hyland.sdk.cic.governance.object.ContentEvent; +import org.hyland.sdk.cic.http.client.CICSdkException; + +/** + * High-level service for CIC Governance API operations. + * + * @since 1.2.0 + */ +public class GovernanceService { + + protected final GovernanceHttpClient httpClient; + + public GovernanceService(GovernanceHttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Sends a content event to the CIC Governance service. + * + * @param event the content event to send + * @throws CICSdkException if the request fails + */ + public void sendEvent(ContentEvent event) { + httpClient.sendEvent(event); + } +} diff --git a/cic-governance/src/main/java/org/hyland/sdk/cic/governance/mapper/ContentEventDataMapper.java b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/mapper/ContentEventDataMapper.java new file mode 100644 index 0000000..1157a90 --- /dev/null +++ b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/mapper/ContentEventDataMapper.java @@ -0,0 +1,42 @@ +/* + * (C) Copyright 2026 Hyland (https://hyland.com/) and others. + * + * 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. + * + * Contributors: + * Kevin Leturc + */ +package org.hyland.sdk.cic.governance.mapper; + +import org.hyland.sdk.cic.governance.object.ContentEventData; +import org.hyland.sdk.cic.http.client.mapper.CICMapper; +import org.hyland.sdk.cic.http.client.mapper.object.CICObject; + +/** + * @since 1.2.0 + */ +class ContentEventDataMapper implements CICMapper { + + @Override + public CICObject toCICNode(ContentEventData data) { + var cicObject = CICObject.create(); + cicObject.putString("contentId", data.contentId()); + cicObject.putString("environmentId", data.environmentId()); + cicObject.putString("fileName", data.fileName()); + cicObject.putString("contentFocus", data.contentFocus()); + if (!data.metadata().isEmpty()) { + cicObject.putObject("metadata", CICObject.from(data.metadata())); + } + return cicObject; + } +} diff --git a/cic-governance/src/main/java/org/hyland/sdk/cic/governance/mapper/ContentEventMapper.java b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/mapper/ContentEventMapper.java new file mode 100644 index 0000000..88f32aa --- /dev/null +++ b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/mapper/ContentEventMapper.java @@ -0,0 +1,43 @@ +/* + * (C) Copyright 2026 Hyland (https://hyland.com/) and others. + * + * 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. + * + * Contributors: + * Kevin Leturc + */ +package org.hyland.sdk.cic.governance.mapper; + +import org.hyland.sdk.cic.governance.object.ContentEvent; +import org.hyland.sdk.cic.http.client.mapper.CICMapper; +import org.hyland.sdk.cic.http.client.mapper.object.CICObject; + +/** + * @since 1.2.0 + */ +class ContentEventMapper implements CICMapper { + + private final ContentEventDataMapper dataMapper = new ContentEventDataMapper(); + + @Override + public CICObject toCICNode(ContentEvent event) { + var cicObject = CICObject.create(); + cicObject.putString("specversion", event.specversion()); + cicObject.putString("type", event.type().label()); + cicObject.putString("dataSourceType", event.dataSourceType().label()); + cicObject.putString("id", event.id()); + cicObject.putString("time", event.time().toString()); + cicObject.putObject("data", dataMapper.toCICNode(event.data())); + return cicObject; + } +} diff --git a/cic-governance/src/main/java/org/hyland/sdk/cic/governance/mapper/GovernanceMapperFactory.java b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/mapper/GovernanceMapperFactory.java new file mode 100644 index 0000000..71f99e9 --- /dev/null +++ b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/mapper/GovernanceMapperFactory.java @@ -0,0 +1,39 @@ +/* + * (C) Copyright 2026 Hyland (https://hyland.com/) and others. + * + * 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. + * + * Contributors: + * Kevin Leturc + */ +package org.hyland.sdk.cic.governance.mapper; + +import java.util.Map; + +import org.hyland.sdk.cic.governance.object.ContentEvent; +import org.hyland.sdk.cic.http.client.mapper.CICMapper; +import org.hyland.sdk.cic.http.client.mapper.MapperService; + +/** + * @since 1.2.0 + */ +public class GovernanceMapperFactory implements MapperService.MapperFactory { + + private static final Map, CICMapper> MAPPERS = Map.of(ContentEvent.class, new ContentEventMapper()); + + @Override + @SuppressWarnings("unchecked") + public CICMapper getMapper(Class type) { + return (CICMapper) MAPPERS.get(type); + } +} diff --git a/cic-governance/src/main/java/org/hyland/sdk/cic/governance/object/ContentEvent.java b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/object/ContentEvent.java new file mode 100644 index 0000000..c3a4445 --- /dev/null +++ b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/object/ContentEvent.java @@ -0,0 +1,193 @@ +/* + * (C) Copyright 2026 Hyland (https://hyland.com/) and others. + * + * 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. + * + * Contributors: + * Kevin Leturc + */ +package org.hyland.sdk.cic.governance.object; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * An event sent to the CIC Governance {@code POST /api/records/event} endpoint. + * + * @since 1.2.0 + */ +public final class ContentEvent { + + private static final String DEFAULT_SPEC_VERSION = "1.0"; + + protected final String specversion; + + protected final Type type; + + protected final DataSourceType dataSourceType; + + protected final String id; + + protected final Instant time; + + protected final ContentEventData data; + + protected ContentEvent(Builder builder) { + this.specversion = Objects.requireNonNull(builder.specversion, "specversion cannot be null"); + this.type = Objects.requireNonNull(builder.type, "type cannot be null"); + this.dataSourceType = Objects.requireNonNull(builder.dataSourceType, "dataSourceType cannot be null"); + this.id = Objects.requireNonNull(builder.id, "id cannot be null"); + this.time = Objects.requireNonNull(builder.time, "time cannot be null"); + this.data = Objects.requireNonNull(builder.data, "data cannot be null"); + } + + public static Builder builder(Type type, DataSourceType dataSourceType, ContentEventData data) { + return new Builder(type, dataSourceType, data); + } + + public String specversion() { + return specversion; + } + + public Type type() { + return type; + } + + public DataSourceType dataSourceType() { + return dataSourceType; + } + + public String id() { + return id; + } + + public Instant time() { + return time; + } + + public ContentEventData data() { + return data; + } + + public Builder toBuilder() { + return new Builder(type, dataSourceType, data).specversion(specversion).id(id).time(time); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + var that = (ContentEvent) obj; + return Objects.equals(this.specversion, that.specversion) && this.type == that.type + && this.dataSourceType == that.dataSourceType && Objects.equals(this.id, that.id) + && Objects.equals(this.time, that.time) && Objects.equals(this.data, that.data); + } + + @Override + public int hashCode() { + return Objects.hash(specversion, type, dataSourceType, id, time, data); + } + + @Override + public String toString() { + return "ContentEvent[specversion=" + specversion + ", type=" + type + ", dataSourceType=" + dataSourceType + + ", id=" + id + ", time=" + time + ", data=" + data + "]"; + } + + public static final class Builder { + + private final Type type; + + private final DataSourceType dataSourceType; + + private final ContentEventData data; + + private String specversion; + + private String id; + + private Instant time; + + private Builder(Type type, DataSourceType dataSourceType, ContentEventData data) { + this.type = Objects.requireNonNull(type, "type cannot be null"); + this.dataSourceType = Objects.requireNonNull(dataSourceType, "dataSourceType cannot be null"); + this.data = Objects.requireNonNull(data, "data cannot be null"); + this.specversion = DEFAULT_SPEC_VERSION; + this.id = UUID.randomUUID().toString(); + this.time = Instant.now(); + } + + public Builder specversion(String specversion) { + this.specversion = Objects.requireNonNull(specversion, "specversion cannot be null"); + return this; + } + + public Builder id(String id) { + this.id = Objects.requireNonNull(id, "id cannot be null"); + return this; + } + + public Builder time(Instant time) { + this.time = Objects.requireNonNull(time, "time cannot be null"); + return this; + } + + public ContentEvent build() { + return new ContentEvent(this); + } + } + + /** + * The {@code type} values accepted by the CIC Governance {@code POST /api/records/event} endpoint. + */ + public enum Type { + CREATED("cic.governance.content.v1.created"), + MODIFIED("cic.governance.content.v1.modified"), + DELETED("cic.governance.content.v1.deleted"); + + protected final String label; + + Type(String label) { + this.label = label; + } + + public String label() { + return label; + } + } + + /** + * The {@code dataSourceType} values accepted by the CIC Governance {@code POST /api/records/event} endpoint. + */ + public enum DataSourceType { + HX_PR("HxPR"), + ON_BASE("OnBase"), + ALFRESCO("Alfresco"), + NUXEO("Nuxeo"); + + protected final String label; + + DataSourceType(String label) { + this.label = label; + } + + public String label() { + return label; + } + } +} diff --git a/cic-governance/src/main/java/org/hyland/sdk/cic/governance/object/ContentEventData.java b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/object/ContentEventData.java new file mode 100644 index 0000000..bd8b9f3 --- /dev/null +++ b/cic-governance/src/main/java/org/hyland/sdk/cic/governance/object/ContentEventData.java @@ -0,0 +1,146 @@ +/* + * (C) Copyright 2026 Hyland (https://hyland.com/) and others. + * + * 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. + * + * Contributors: + * Kevin Leturc + */ +package org.hyland.sdk.cic.governance.object; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import org.hyland.sdk.cic.http.client.util.StringUtils; + +/** + * The {@code data} payload of a {@link ContentEvent}. + * + * @since 1.2.0 + */ +public final class ContentEventData { + + protected final String contentId; + + protected final String environmentId; + + protected final String fileName; + + protected final String contentFocus; + + protected final Map metadata; + + protected ContentEventData(Builder builder) { + this.contentId = StringUtils.requireNonBlank(builder.contentId, "contentId cannot be blank"); + this.environmentId = StringUtils.requireNonBlank(builder.environmentId, "environmentId cannot be blank"); + this.fileName = StringUtils.requireNonBlank(builder.fileName, "fileName cannot be blank"); + this.contentFocus = StringUtils.requireNonBlank(builder.contentFocus, "contentFocus cannot be blank"); + this.metadata = Map.copyOf(builder.metadata); + } + + /** + * @param contentId the document id on the repository side; CIC Governance echoes it back when requesting a + * retention action, so the repository can locate the document and apply the retention + */ + public static Builder builder(String contentId, String environmentId, String fileName, String contentFocus) { + return new Builder(contentId, environmentId, fileName, contentFocus); + } + + /** + * The document id on the repository side. CIC Governance echoes this id back when requesting a retention action on + * the content, so the repository can use it to locate the document and apply the retention. + */ + public String contentId() { + return contentId; + } + + public String environmentId() { + return environmentId; + } + + public String fileName() { + return fileName; + } + + public String contentFocus() { + return contentFocus; + } + + public Map metadata() { + return metadata; + } + + public Builder toBuilder() { + return new Builder(contentId, environmentId, fileName, contentFocus).metadata(metadata); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + var that = (ContentEventData) obj; + return Objects.equals(this.contentId, that.contentId) && Objects.equals(this.environmentId, that.environmentId) + && Objects.equals(this.fileName, that.fileName) && Objects.equals(this.contentFocus, that.contentFocus) + && Objects.equals(this.metadata, that.metadata); + } + + @Override + public int hashCode() { + return Objects.hash(contentId, environmentId, fileName, contentFocus, metadata); + } + + @Override + public String toString() { + return "ContentEventData[contentId=" + contentId + ", environmentId=" + environmentId + ", fileName=" + fileName + + ", contentFocus=" + contentFocus + ", metadata=" + metadata + "]"; + } + + public static final class Builder { + + private final String contentId; + + private final String environmentId; + + private final String fileName; + + private final String contentFocus; + + private Map metadata = new LinkedHashMap<>(); + + private Builder(String contentId, String environmentId, String fileName, String contentFocus) { + this.contentId = Objects.requireNonNull(contentId, "contentId cannot be null"); + this.environmentId = Objects.requireNonNull(environmentId, "environmentId cannot be null"); + this.fileName = Objects.requireNonNull(fileName, "fileName cannot be null"); + this.contentFocus = Objects.requireNonNull(contentFocus, "contentFocus cannot be null"); + } + + public Builder metadata(Map metadata) { + this.metadata = new LinkedHashMap<>(Objects.requireNonNull(metadata, "metadata cannot be null")); + return this; + } + + public Builder putMetadata(String key, String value) { + this.metadata.put(Objects.requireNonNull(key, "key cannot be null"), value); + return this; + } + + public ContentEventData build() { + return new ContentEventData(this); + } + } +} diff --git a/cic-governance/src/main/resources/META-INF/services/org.hyland.sdk.cic.http.client.mapper.MapperService$MapperFactory b/cic-governance/src/main/resources/META-INF/services/org.hyland.sdk.cic.http.client.mapper.MapperService$MapperFactory new file mode 100644 index 0000000..287fb7f --- /dev/null +++ b/cic-governance/src/main/resources/META-INF/services/org.hyland.sdk.cic.http.client.mapper.MapperService$MapperFactory @@ -0,0 +1 @@ +org.hyland.sdk.cic.governance.mapper.GovernanceMapperFactory diff --git a/cic-governance/src/test/java/org/hyland/sdk/cic/governance/GovernanceServiceTest.java b/cic-governance/src/test/java/org/hyland/sdk/cic/governance/GovernanceServiceTest.java new file mode 100644 index 0000000..6faa281 --- /dev/null +++ b/cic-governance/src/test/java/org/hyland/sdk/cic/governance/GovernanceServiceTest.java @@ -0,0 +1,76 @@ +/* + * (C) Copyright 2026 Hyland (https://hyland.com/) and others. + * + * 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. + * + * Contributors: + * Kevin Leturc + */ +package org.hyland.sdk.cic.governance; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.hyland.sdk.cic.governance.object.ContentEvent; +import org.hyland.sdk.cic.governance.object.ContentEventData; +import org.hyland.sdk.cic.http.client.auth.AuthenticationHttpClient; + +/** + * @since 1.2.0 + */ +public class GovernanceServiceTest { + + private TestGovernanceHttpClient httpClient; + + private GovernanceService service; + + @BeforeEach + public void setUp() { + httpClient = new TestGovernanceHttpClient(); + service = new GovernanceService(httpClient); + } + + @Test + public void testSendEvent() { + var data = ContentEventData.builder("content-123", "env-1", "document.pdf", "primary").build(); + var event = ContentEvent.builder(ContentEvent.Type.CREATED, ContentEvent.DataSourceType.NUXEO, data) + .time(Instant.ofEpochMilli(1609459200000L)) + .build(); + + service.sendEvent(event); + + assertEquals(1, httpClient.sendEventCalls.size()); + assertEquals(event, httpClient.sendEventCalls.get(0)); + } + + private static class TestGovernanceHttpClient extends GovernanceHttpClient { + + List sendEventCalls = new ArrayList<>(); + + TestGovernanceHttpClient() { + super(GovernanceHttpClient.from("https://localhost", + AuthenticationHttpClient.from().clientId("test-client-id").clientSecret("test-client-secret"))); + } + + @Override + public void sendEvent(ContentEvent event) { + sendEventCalls.add(event); + } + } +} diff --git a/cic-governance/src/test/java/org/hyland/sdk/cic/governance/mapper/ContentEventMapperTest.java b/cic-governance/src/test/java/org/hyland/sdk/cic/governance/mapper/ContentEventMapperTest.java new file mode 100644 index 0000000..22f0eaa --- /dev/null +++ b/cic-governance/src/test/java/org/hyland/sdk/cic/governance/mapper/ContentEventMapperTest.java @@ -0,0 +1,115 @@ +/* + * (C) Copyright 2026 Hyland (https://hyland.com/) and others. + * + * 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. + * + * Contributors: + * Kevin Leturc + */ +package org.hyland.sdk.cic.governance.mapper; + +import java.time.Instant; +import java.util.Map; + +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import org.hyland.sdk.cic.governance.object.ContentEvent; +import org.hyland.sdk.cic.governance.object.ContentEventData; +import org.hyland.sdk.cic.http.client.mapper.MapperService; + +/** + * @since 1.2.0 + */ +public class ContentEventMapperTest { + + @Test + public void testSerializeContentEvent() throws JSONException { + var data = ContentEventData.builder("content-123", "env-1", "document.pdf", "primary") + .putMetadata("title", "Test Document") + .build(); + var event = ContentEvent.builder(ContentEvent.Type.CREATED, ContentEvent.DataSourceType.NUXEO, data) + .id("event-id-1") + .time(Instant.parse("2026-01-01T00:00:00Z")) + .build(); + + var json = MapperService.writeAsString(event); + var expected = """ + { + "specversion": "1.0", + "type": "cic.governance.content.v1.created", + "dataSourceType": "Nuxeo", + "id": "event-id-1", + "time": "2026-01-01T00:00:00Z", + "data": { + "contentId": "content-123", + "environmentId": "env-1", + "fileName": "document.pdf", + "contentFocus": "primary", + "metadata": { + "title": "Test Document" + } + } + } + """; + + JSONAssert.assertEquals(expected, json, true); + } + + @Test + public void testSerializeContentEventWithMinimalFields() throws JSONException { + var data = ContentEventData.builder("content-123", "env-1", "document.pdf", "primary").build(); + var event = ContentEvent.builder(ContentEvent.Type.CREATED, ContentEvent.DataSourceType.NUXEO, data) + .id("event-id-1") + .time(Instant.parse("2026-01-01T00:00:00Z")) + .build(); + + var json = MapperService.writeAsString(event); + var expected = """ + { + "specversion": "1.0", + "type": "cic.governance.content.v1.created", + "dataSourceType": "Nuxeo", + "id": "event-id-1", + "time": "2026-01-01T00:00:00Z", + "data": { + "contentId": "content-123", + "environmentId": "env-1", + "fileName": "document.pdf", + "contentFocus": "primary" + } + } + """; + + JSONAssert.assertEquals(expected, json, true); + } + + @Test + public void testContentEventDataMetadataIsImmutable() { + var data = ContentEventData.builder("content-123", "env-1", "document.pdf", "primary") + .putMetadata("key", "value") + .build(); + + assertThatMetadataIsUnmodifiable(data.metadata()); + } + + private void assertThatMetadataIsUnmodifiable(Map metadata) { + try { + metadata.put("new-key", "new-value"); + throw new AssertionError("Expected metadata to be unmodifiable"); + } catch (UnsupportedOperationException e) { + // expected + } + } +} diff --git a/pom.xml b/pom.xml index 5edc6c6..71e3d53 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,7 @@ cic-qna cic-nucleus cic-ke + cic-governance @@ -98,6 +99,11 @@ cic-ke ${project.version} + + org.hyland.sdk + cic-governance + ${project.version} + org.junit