diff --git a/.cursor/rules/branch-backport.mdc b/.cursor/rules/branch-backport.mdc new file mode 100644 index 000000000..aa4eb00b7 --- /dev/null +++ b/.cursor/rules/branch-backport.mdc @@ -0,0 +1,90 @@ +--- +description: Generic workflow for backporting changes from a source branch to a target branch without regressions +alwaysApply: true +--- + +# Branch backport rules (generic — mandatory) + +Use whenever porting work **from a source branch to a target branch** (release line, long-lived dev branch, stacked PR parent, etc.). + +Set branch names once per task (examples: `SOURCE=feature/x` `TARGET=main`, or `SOURCE=unomi-3-dev` `TARGET=master`): + +```bash +cd "$(git rev-parse --show-toplevel)" +git fetch origin +SOURCE= # where the change lives today +TARGET= # where it must land (canonical for existing files) +``` + +Repo-specific overlays (e.g. UNOMI-875): `.cursor/rules/unomi-3-dev-backport.mdc` — read **after** this file when applicable. + +## Never do this + +- **Never** `git checkout origin/$SOURCE -- ` on files that **already exist on** `origin/$TARGET`. +- **Never** assume “file differs on source ⇒ target is missing it” — the target often has **review-only fixes** the source never received. +- **Never** `git cherry-pick` commits from `$SOURCE` history onto `$TARGET` for bulk backport work without per-commit review (source history is often pre-merge noise). +- **Never** downgrade pinned versions on `$TARGET` (images, BOM, lockfiles) to match `$SOURCE` without explicit review. + +## Always do this + +1. **Branch from** `origin/$TARGET` (or current stack parent), not from `$SOURCE`. +2. **Inventory:** `git diff --stat origin/$TARGET origin/$SOURCE -- ` — size only; not a port list. +3. **Commit history audit** on every path that exists on `$TARGET` — see below. +4. **Classify each path:** ADD (new on target) · MODIFY (exists on target) · MERGE (build/CI/deps — both sides changed). +5. **Port:** + - **ADD:** `git checkout origin/$SOURCE -- ` is OK if the file is absent on `$TARGET`. + - **MODIFY:** start from `origin/$TARGET`; apply additive hunks from `$SOURCE` by hand. + - **MERGE:** never replace whole file; keep `$TARGET`-only lines while adding `$SOURCE` features. +6. **Pre-commit diff audit** on every modified existing file — see below. +7. **Build/test** affected modules before opening PR; state in PR body that history + diff audits were run. + +## Commit history audit (MODIFY / MERGE) + +> Read **`$TARGET` history** to protect review work. Do **not** use `$SOURCE` commit SHAs as a port shortcut. + +```bash +PATH=path/to/file + +# Target-only commits (review PRs, bugfixes never merged to source): +git log origin/$SOURCE..origin/$TARGET --oneline -- "$PATH" + +# Recent target context (PR numbers in subjects): +git log origin/$TARGET -20 --oneline -- "$PATH" + +# If your branch deletes lines, trace them on target: +git diff origin/$TARGET -- "$PATH" +git blame origin/$TARGET -L , -- "$PATH" +git log -1 --format='%h %s (%ci)' +``` + +| Signal | Interpret | +|---|---| +| Non-empty `origin/$SOURCE..origin/$TARGET` log | Target has fixes source lacks — **do not overwrite** file from source | +| Empty target-only log | Still run diff audit; may be safe ADD or symmetric drift | +| Non-empty target-only log **and** substantive removals in your branch | **STOP** — read those commits before continuing | + +Optional: `git log origin/$TARGET..origin/$SOURCE --oneline -- "$PATH"` lists what source added — candidate material, **not** a license to blind checkout. + +## Pre-commit diff regression audit + +```bash +PATH=path/to/file + +# What target has that source lacks (fixes we must keep): +git diff origin/$SOURCE origin/$TARGET -- "$PATH" + +# What our branch removes from target (should be empty or whitespace/comments only): +git diff origin/$TARGET -- "$PATH" | rg '^-' | rg -v '^---|^-import |^-package |^-\s*$|^- \*|^-\s*/\*|^-\s*\*' + +# Gate: substantive removal count (threshold ~5 → investigate) +git diff origin/$TARGET -- "$PATH" | rg '^-' | rg -v '^---|^-import |^-package |^-\s*$|^- \*|^-\s*/\*|^-\s*\*' | wc -l +``` + +Cross-check: if target-only log is non-empty **and** removal count > 0, read target-only commits before committing. + +## Validation before PR + +- [ ] `SOURCE` / `TARGET` named in PR description +- [ ] Commit history audit for every modified **existing** file +- [ ] No substantive `-` lines vs `origin/$TARGET` on modified existing files +- [ ] Module compile / targeted tests for touched areas diff --git a/.cursor/rules/unomi-3-dev-backport.mdc b/.cursor/rules/unomi-3-dev-backport.mdc new file mode 100644 index 000000000..c69e9fa10 --- /dev/null +++ b/.cursor/rules/unomi-3-dev-backport.mdc @@ -0,0 +1,55 @@ +--- +description: UNOMI-875 overlay — backporting unomi-3-dev → master (extends branch-backport.mdc) +alwaysApply: true +--- + +# unomi-3-dev → master (UNOMI-875 Phase 2) + +**Generic workflow first:** `.cursor/rules/branch-backport.mdc` (history audit, diff audit, ADD/MODIFY/MERGE). + +For this epic, set: + +```bash +SOURCE=unomi-3-dev +TARGET=master +``` + +Local tracker (git-ignored): `.local-notes/unomi-3-dev-backport-plan-phase2.md` — §2e playbook, §1 exclusion register. + +## Unomi-specific never + +- **Never** replace `master` review refactors with 3-dev monoliths (REST mappers, tracing lifecycle, test harness, shell CRUD fixes). +- **Never** port items in plan §1 exclusion register: REST exception mappers (implement on master patterns), migration scripts (UNOMI-943), security WIP, wholesale 3-dev test harness. +- **Never** wholesale-checkout `.github/workflows` (master ahead: #780, #957), `AGENTS.md` (#769), migration groovy, `.asf.yaml` rulesets. + +## Unomi-specific always + +- Check merged Phase 2 PRs (#771–#783, #755, #763, etc.) before assuming master lacks a 3-dev feature. +- **MERGE surgically:** `build.sh`, root `pom.xml`, CI — keep master non-interactive CI, Javadoc, IT memory sampler (#780, #957). +- **Docker compose:** additive only (`container_name`, debug ports); **keep** master OpenSearch/ES image pins (3-dev had 3.4.0 regressions vs master 3.0.0). + +## Shell CRUD (PR J) — learned 2026-06-10 + +Shell CSV/table polish from 3-dev is **mostly already on master** (#755, #763). Remaining 3-dev shell diffs often **revert** review fixes (`UndeployDefinition`, `ConsentCrudCommand`, `ApiKeyCrudCommand`, `RuleCrudCommand`, docker pins). Port only: + +- New classes (`DistributionCompleter`) +- Additive CLI flags (`Setup --show`, `@Completion`) on top of master defaults + +**PR 1 incident:** `git log origin/unomi-3-dev..origin/master -- tools/shell-dev-commands/` would have flagged #755/#763 before blind checkout reverted ~20 fixes. + +## Per–mega-PR risk (plan §2e) + +| PR | Extra risk | +|---|---| +| **2** (V+U) | Wrong feature name breaks Karaf boot — diff `feature.xml` vs master names | +| **3** (L–P) | Security + persistence — never replace mappers/tracing | +| **4** (F3–F4) | Extend master `AbstractRestExceptionMapper`; do not copy 3-dev monoliths | +| **5** (K) | Router only — never bundle; never blind-checkout `extensions/router/**` | + +## Validation before PR (Unomi) + +Run generic checklist from `branch-backport.mdc`, plus: + +- `./build.sh --help` +- `mvn -pl -am compile -DskipTests` +- PR description: “master-first backport; branch-backport + §2e history/diff audit run” diff --git a/.gitignore b/.gitignore index 49f40df85..20420b42a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,10 @@ rest/.miredot-offline.json itests/src/main dependency_tree.txt .mvn/.develocity/develocity-workspace-id -/.cursor/ +# Cursor IDE — commit shared agent rules; ignore local IDE state +/.cursor/* +!/.cursor/rules/ +!/.cursor/rules/** /.claude/ /.local-notes/ itests/snapshots_repository/ diff --git a/AGENTS.md b/AGENTS.md index 882533f5c..8f8798315 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,3 +25,46 @@ Security model: [SECURITY.md](./SECURITY.md) Agents that scan this repository should consult `SECURITY.md` and the threat model it links before reporting issues. + +## Branch backporting (generic) + +When porting changes **from a source branch to a target branch**, follow +`.cursor/rules/branch-backport.mdc`. Summary: + +1. **Target is canonical** for files that already exist there — branch from + `origin/`, not from source. +2. **Target may be ahead** — merged review PRs on the target often improve + code beyond the source. A diff ≠ missing feature. +3. **Commit history on the target** — for each existing file: + `git log origin/..origin/ -- `. Non-empty output + means target-only fixes you must not drop. +4. **Never blind-checkout** existing files from source + (`git checkout origin/ -- `). Port additive hunks only. +5. **Audit before commit** — diff regression scan plus history cross-check; + use `git blame origin/` on any line you remove. +6. **Classify paths:** ADD (new on target) · MODIFY (hand-merge from + target) · MERGE (build/CI/deps — target wins on target-only lines). +7. **Do not** bulk-cherry-pick from source branch history without + per-commit review. + +Cursor rule: `.cursor/rules/branch-backport.mdc` + +## Backporting `unomi-3-dev` → `master` (UNOMI-875) + +Active Phase 2 epic. **Apply generic rules above first**, then +`.cursor/rules/unomi-3-dev-backport.mdc` for Unomi-specific exclusions and +mega-PR risks. + +| | | +|---|---| +| Source | `unomi-3-dev` | +| Target | `master` | +| Local plan | `.local-notes/unomi-3-dev-backport-plan-phase2.md` (git-ignored) | + +Unomi-specific reminders: + +- Master is ahead on REST mappers, tracing, shell CRUD (#755, #763), CI + (#780, #957), docker image pins — do not revert. +- Safe wholesale adds: new scripts, Postman, docs, new Java classes. +- Do not port: migration scripts (UNOMI-943), 3-dev REST mappers, security + WIP, wholesale test harness from 3-dev. diff --git a/CODING_GUIDELINES.md b/CODING_GUIDELINES.md new file mode 100644 index 000000000..747caaab9 --- /dev/null +++ b/CODING_GUIDELINES.md @@ -0,0 +1,630 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +# Apache Unomi Coding Guidelines + +This document outlines coding conventions and guidelines for the Apache Unomi project. It covers both standard Java best practices and project-specific conventions. + +## Table of Contents + +1. [Quick Reference](#quick-reference) +2. [Naming Conventions](#naming-conventions) +3. [Code Patterns](#code-patterns) +4. [OSGi Service Implementation](#osgi-service-implementation) +5. [Maven POM Organization](#maven-pom-organization) +6. [Testing Guidelines](#testing-guidelines) +7. [Project Goals](#project-goals) + +--- + +## Quick Reference + +### Standard Java Best Practices + +This project follows standard Java best practices. For comprehensive guidelines, refer to: +- [Oracle Java Code Conventions](https://www.oracle.com/java/technologies/javase/codeconventions-contents.html) +- [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html) +- **Effective Java** by Joshua Bloch +- [Java Platform Documentation](https://docs.oracle.com/javase/) + +**Key Principles**: Code clarity, consistency, documentation, proper error handling, resource management, immutability, thread safety. + +### Checkstyle Rules + +Key rules: JavaDoc for public classes/methods, remove unused imports, UPPER_CASE constants, no magic numbers (exceptions: -1, 0, 1, 2, 3, 17, 24, 31, 37, 60, 255, 256, 1000), always use braces, equals/hashCode together. + +Run: `mvn clean install -P checkstyle` + +--- + +## Naming Conventions + +### Item Classes + +All classes extending `org.apache.unomi.api.Item` **must** define: +```java +public static final String ITEM_TYPE = "myItem"; +``` + +**Example**: +```java +public class Profile extends Item { + public static final String ITEM_TYPE = "profile"; +} +``` + +### Package Structure + +- **API**: `org.apache.unomi.api.*` - Public APIs +- **Services**: `org.apache.unomi.services.impl.*` - Internal implementations +- **Extensions**: `org.apache.unomi.*.services.*` - Extension-specific services +- **Plugins**: `org.apache.unomi.plugin.*` - Plugin implementations + +### Class Naming + +- **Services**: `*Service` (interface), `*ServiceImpl` (implementation) +- **Actions**: `*ActionExecutor` +- **Conditions**: `*ConditionEvaluator` +- **Queries**: `*QueryBuilder` + +--- + +## Code Patterns + +### Logging + +Use SLF4J with `LoggerFactory.getLogger(ClassName.class)` (not `.getName()`): + +```java +private static final Logger LOGGER = LoggerFactory.getLogger(MyClass.class); + +LOGGER.info("Processing item: {}", itemId); // Parameterized logging +LOGGER.error("Error processing item: {}", itemId, exception); // Exception last +``` + +**Best Practices**: Include context (itemId, tenantId, eventType), use parameterized logging, pass exceptions as last parameter. + +### Collections + +- **Thread-safe**: `ConcurrentHashMap`, `ConcurrentLinkedQueue`, `AtomicBoolean/Integer/Long` +- **Immutable**: `Collections.emptyList()`, `Collections.singletonList()`, `Collections.unmodifiableList()` +- **Lazy init**: `map.computeIfAbsent(key, k -> new ArrayList<>()).add(value)` +- **Initialization**: Use explicit constructors, set initial capacity when known + +### Exception Handling + +```java +try { + // operation +} catch (Exception e) { + LOGGER.error("Error processing item: {}", itemId, e); + throw new ProcessingException("Failed to process item: " + itemId, e); +} +``` + +- Create custom exceptions for domain errors +- Always log with context +- Use `ExceptionMapper` for REST endpoints + +### Null Checks and Validation + +```java +Objects.requireNonNull(item, "Item cannot be null"); +if (collection.isEmpty()) { /* ... */ } // Not size() == 0 +``` + +- Validate early (fail-fast) +- Use `Objects.requireNonNull()` for required parameters +- Check collections with `isEmpty()` + +### Resource Management + +```java +try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + // use resource +} +``` + +- Always use try-with-resources for `AutoCloseable` +- Clean up in `@Deactivate`/`preDestroy()`: close trackers, shutdown executors, cancel timers + +### Concurrency + +- Use `ExecutorService` for async operations (shutdown in `@Deactivate`) +- Prefer `ConcurrentHashMap` over `synchronizedMap` +- Use `volatile` for simple flags, `AtomicReference` for object references +- Document thread-safety guarantees + +### Execution Context + +```java +contextManager.executeAsSystem(() -> { /* system operation */ }); +contextManager.executeAsTenant(tenantId, () -> { /* tenant operation */ }); +``` + +- Contexts are `ThreadLocal` - don't share across threads +- Always restore in `finally` blocks + +### Service Lifecycle + +**OSGi DS (preferred)**: +```java +@Component(service = MyService.class, configurationPid = "org.apache.unomi.myservice") +public class MyServiceImpl implements MyService { + @Activate + public void activate(MyServiceConfig config) { /* init */ } + + @Deactivate + public void deactivate() { /* cleanup */ } + + @Modified + public void modified(MyServiceConfig config) { /* config change */ } +} +``` + +**Blueprint (legacy)**: Use `postConstruct()` and `preDestroy()`. + +### REST API Endpoints + +```java +@Path("/profiles") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Component(service = ProfileEndpoint.class, property = "osgi.jaxrs.resource=true") +public class ProfileEndpoint { + @Reference + private ProfileService profileService; + + @GET + @Path("/{id}") + public Profile getProfile(@PathParam("id") String id) { + return profileService.load(id); + } +} +``` + +### OSGi Service References + +```java +@Reference(cardinality = ReferenceCardinality.MANDATORY) +private PersistenceService persistenceService; + +@Reference(cardinality = ReferenceCardinality.OPTIONAL) +private MetricsService metricsService; + +@Reference(cardinality = ReferenceCardinality.MULTIPLE) +private List actionExecutors; +``` + +**Dynamic binding**: Use `bind`/`unbind` methods with `CopyOnWriteArrayList` or `ConcurrentHashMap`. + +**Service Trackers**: Always close in `@Deactivate`: +```java +@Deactivate +public void deactivate() { + if (serviceTracker != null) { + serviceTracker.close(); + serviceTracker = null; + } +} +``` + +### Serialization + +- All `Item` subclasses must be `Serializable` +- Use `serialVersionUID` for version control +- Use `CustomObjectMapper` for Unomi-specific serialization +- Use `ItemDeserializer` for polymorphic Item deserialization + +### Builder Patterns + +- Use `ConditionBuilder` for complex condition trees +- Use `TaskBuilder` for scheduled tasks +- Return `this` from builder methods for chaining + +### Metrics + +```java +int result = new MetricAdapter(metricsService, "ClassName.operation") { + @Override + public Integer execute(Object... args) throws Exception { + return performOperation(); + } +}.runWithTimer(); +``` + +- Check `metricsService != null && metricsService.isActivated()` before updating +- Use descriptive names: `ClassName.operationName` + +### Validation Patterns + +Use `ValidationError` with context (parameter name, condition ID, etc.): +```java +errors.add(new ValidationError( + param.getId(), + "Required parameter is missing", + ValidationErrorType.MISSING_REQUIRED_PARAMETER, + condition.getConditionTypeId(), + type.getItemId(), + context, + null +)); +``` + +### Query Building + +- Register query builders with dispatcher +- Handle null conditions and missing builders gracefully +- Use thread-safe collections for query builder maps + +### Graceful Shutdown + +```java +private volatile boolean shutdownNow = false; + +public void preDestroy() { + shutdownNow = true; // Set flag first + // Cancel tasks, close trackers, shutdown executors +} + +public void processItems() { + while (!shutdownNow) { + if (shutdownNow) return; // Check flag in loop + processItem(getNextItem()); + } +} +``` + +**Shutdown sequence**: Set flag → Cancel tasks → Close trackers → Shutdown executors → Release references → Clear collections. + +--- + +## OSGi Service Implementation + +### Migrate from Blueprint to DS Annotations + +**Goal**: Use OSGi Declarative Services (DS) annotations instead of Blueprint XML. + +**Benefits**: Type-safe references, better IDE support, reduced boilerplate, compile-time validation. + +**Strategy**: New services use DS; migrate existing services gradually when modifying. + +### Configuration Management + +**CRITICAL**: Use OSGi Managed Services with `@Modified` for real-time updates (no restart required). + +**Example**: +```java +@Component(service = WebConfig.class, configurationPid = "org.apache.unomi.web") +@Designate(ocd = WebConfig.Config.class) +public class WebConfig { + @ObjectClassDefinition(name = "Apache Unomi Web Configuration") + public @interface Config { + @AttributeDefinition(name = "Context Server Domain") + String contextserver_domain() default ""; + } + + @Activate + public void activate(Config config) { modified(config); } + + @Modified + public void modified(Config config) { + // Configuration updated in real-time without restart + this.contextserverDomain = config.contextserver_domain(); + } +} +``` + +### Environment Variables + +**CRITICAL**: All configuration must be wired to environment variables for Docker. + +**Pattern** in `custom.system.properties`: +```properties +org.apache.unomi.my.property=${env:UNOMI_MY_PROPERTY:-defaultValue} +``` + +**Naming**: `UNOMI_{CATEGORY}_{PROPERTY_NAME}` (uppercase, underscores, replace dots with underscores). + +**Example**: +```properties +org.osgi.service.http.port=${env:UNOMI_HTTP_PORT:-8181} +org.apache.unomi.elasticsearch.addresses=${env:UNOMI_ELASTICSEARCH_ADDRESSES:-localhost:9200} +``` + +**Docker**: +```bash +docker run -e UNOMI_HTTP_PORT=8080 -e UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 apache/unomi:latest +``` + +**Priority** (highest to lowest): Environment variables → Custom config files → Default config files. + +**Real-time updates**: Environment variables set initial state; `@Modified` allows runtime changes without restart (for tests, future UI, operational flexibility). + +**Adding new property**: +1. Add to `custom.system.properties` with `${env:UNOMI_*:-default}` +2. Add to OSGi service `@ObjectClassDefinition` interface +3. Document in user docs + +--- + +## Maven POM Organization + +### Dependency Order + +1. **Unomi dependencies** (`org.apache.unomi.*`) +2. **Standard API Dependencies** (Java/Jakarta EE, OSGi, JSR/JCP specs) +3. **Libraries** (all other third-party) +4. **Test dependencies** (scope=test) + +**Standard API Dependencies**: `javax.*`, `jakarta.*`, `org.osgi.*` packages (typically `scope=provided`). + +**Example**: +```xml + + + + org.apache.unomi + unomi-api + + + + + org.osgi + osgi.core + provided + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + junit + junit + test + + +``` + +### Dependency Version Management + +**CRITICAL**: Do NOT hardcode versions. All versions managed through: +1. **BOM** (`unomi-bom`) - third-party dependencies +2. **Root POM** - additional dependencies and properties +3. **BOM Artifacts** (`unomi-bom-artifacts`) - Unomi internal dependencies + +**Adding a dependency**: +1. Check if managed in `bom/pom.xml` or `bom/artifacts/pom.xml` +2. If not, add version property to root `pom.xml`, then add to appropriate BOM +3. Use in module `pom.xml` **without** `` tag + +**Example**: +```xml + + + 1.2.3 + + + + + + com.example + my-library + ${my-library.version} + + + + + + com.example + my-library + + +``` + +**Exceptions**: Versions allowed only in root `pom.xml` properties, BOM files, and parent POM references. + +**See also**: `DEPENDENCY_ORGANIZATION_REPORT.md` + +--- + +## Testing Guidelines + +### Unit Tests (JUnit 5) + +**Framework**: JUnit 5 (Jupiter), Mockito with `@ExtendWith(MockitoExtension.class)` + +**Conventions**: +- Use `org.junit.jupiter.api.*` exclusively +- Use `@BeforeEach`/`@AfterEach` for lifecycle +- Use `@Tag` instead of `@Category` +- Use `assertThrows()` instead of `@Test(expected = ...)` +- Assertions: message as last parameter, include context (itemId, tenantId, etc.) + +**Example**: +```java +@ExtendWith(MockitoExtension.class) +class MyServiceTest { + @Mock + private PersistenceService persistenceService; + + @Test + void testMethod() { + String itemId = "test-item-123"; + when(persistenceService.load(itemId, MyItem.class)).thenReturn(new MyItem(itemId)); + + MyItem result = myService.loadItem(itemId); + + assertNotNull(result, "Should load item with id: " + itemId); + assertEquals(itemId, result.getItemId(), "Item ID should match"); + } +} +``` + +### Integration Tests (JUnit 4) + +**Framework**: JUnit 4 (Pax Exam) + +**Conventions**: +- Extend `org.apache.unomi.itests.BaseIT` +- **NEVER** create dependencies between tests +- Use `getOsgiService(ServiceClass.class, timeout)` for OSGi services +- Clean up test data in `@After` + +**Example**: +```java +public class MyServiceIT extends BaseIT { + private String testItemId; + + @Before + public void setUp() { + testItemId = "test-item-" + System.currentTimeMillis(); + } + + @Test + public void testMyService() throws Exception { + // Test implementation + } + + @After + public void tearDown() { + if (testItemId != null) { + try { + persistenceService.remove(testItemId, MyItem.class); + } catch (Exception e) { + // Log but don't fail + } + } + } +} +``` + +**Running**: `mvn clean install -P integration-tests` (or `-Duse.opensearch=true` for OpenSearch) + +### Test Best Practices + +**AVOID `Thread.sleep()`** - Use: +- **Awaitility** (unit tests): `await().atMost(10, TimeUnit.SECONDS).until(() -> condition)` +- **BaseIT helpers** (integration): `keepTrying("message", supplier, predicate, timeout, retries)` +- **CountDownLatch/CyclicBarrier** (thread synchronization) + +**When `Thread.sleep()` is acceptable**: Only in helper methods implementing retry logic, testing time-based behavior, or as last resort (< 100ms with justification). + +**Test Execution Time**: +- **Targets**: Unit tests < 1s (most < 100ms), Integration tests < 10s (most < 5s) +- **Strategies**: Use mocks, in-memory implementations, efficient waits, minimize I/O, parallelize when possible +- **Long tests**: Document reason, consider splitting, use `@Tag("slow")`, ensure value justifies time + +**Performance and Reliability**: +- Use `ExecutorService` for concurrency tests (shutdown in teardown) +- Use fixed seeds for randomness: `new Random(42)` +- Use `@TempDir` (JUnit 5) or `Files.createTempDirectory()` (integration), always clean up +- Aggregate exceptions in concurrency tests, assert emptiness with list in message +- Use `TimeUnit` constants, not raw millisecond literals +- Assert on structured fields, not `toString()` (unless that's the behavior) +- Assert collection size before contents +- Compile regex patterns as `static final` +- No static mutable state (or reset in `@BeforeEach`/`@AfterEach`) +- Generate unique IDs per test, clean up data +- Prefer condition-based waits over global timeouts +- Isolate state for parallel execution + +**See also**: `itests/README.md` + +### JUnit 4 to JUnit 5 Migration Plan + +**Status**: **NOT YET STARTED** - Planned migration for consistency. + +**Key Differences**: +- Package: `org.junit.*` → `org.junit.jupiter.api.*` +- Lifecycle: `@Before`/`@After` → `@BeforeEach`/`@AfterEach` +- Assertions: Message first → Message last parameter +- Exceptions: `@Test(expected = ...)` → `assertThrows(...)` +- Categories: `@Category` → `@Tag` +- Runner: `@RunWith` → `@ExtendWith` + +**Strategy**: Verify Pax Exam JUnit 5 support, migrate incrementally in separate branch/PR. + +--- + +## Project Goals + +### 1. Migrate from Blueprint to DS Annotations + +**Goal**: All services use OSGi DS annotations. + +**Strategy**: New services use DS; migrate existing when modifying. + +### 2. Merge Plugins and Extensions + +**Goal**: Consolidate `plugins/` and `extensions/` into unified extension mechanism. + +**Rationale**: Reduces confusion, simplifies structure, easier to understand. + +### 3. Prefer Plugins Over New Services + +**Goal**: Use plugins for new functionality instead of core services. + +**When to use plugins**: Custom actions/conditions, external integrations, domain-specific features, optional functionality. + +**When to use core services**: Fundamental persistence, core event processing, essential profile/segment management, critical infrastructure. + +### 4. Migrate Integration Tests to JUnit 5 + +**Status**: **NOT YET STARTED** - See [JUnit 4 to JUnit 5 Migration Plan](#junit-4-to-junit-5-migration-plan). + +### 5. Increase Unit Test Coverage + +**Focus**: Service implementations, complex business logic, utility classes, error handling paths. + +**Strategy**: Add tests when modifying code, require tests for new features, use JaCoCo to track progress. + +### 6. Standardize Logger Initialization + +**Goal**: Use `LoggerFactory.getLogger(ClassName.class)` (not `.getName()`). + +**Strategy**: New code uses standard form; update existing when modifying. + +### 7. Code Quality and Maintainability + +**Ongoing**: Reduce duplication, improve documentation, refactor complex methods, follow SOLID principles, keep dependencies updated, address technical debt incrementally. + +--- + +## Additional Resources + +- **Apache Unomi Website**: https://unomi.apache.org +- **Contribution Guidelines**: https://unomi.apache.org/contribute.html +- **JIRA Issue Tracker**: https://issues.apache.org/jira/browse/UNOMI +- **Integration Test README**: `itests/README.md` +- **Dependency Organization Report**: `DEPENDENCY_ORGANIZATION_REPORT.md` + +--- + +## Questions or Suggestions? + +1. Open a discussion on the Apache Unomi developer mailing list +2. Create a JIRA issue +3. Submit a pull request with proposed changes + +--- + +*Last updated: November 2025* diff --git a/INMEMORY_PERSISTENCE_REQUIREMENTS.md b/INMEMORY_PERSISTENCE_REQUIREMENTS.md new file mode 100644 index 000000000..2f0e87c62 --- /dev/null +++ b/INMEMORY_PERSISTENCE_REQUIREMENTS.md @@ -0,0 +1,1024 @@ + +# Requirements: Making InMemoryPersistenceServiceImpl a Production-Ready Persistence Manager + +## Overview + +This document outlines what it would take to make `InMemoryPersistenceServiceImpl` (currently in `services/src/test/java/`) a full-blown persistence manager available as an alternative to Elasticsearch and OpenSearch persistence managers, while maintaining the ability for unit tests to instantiate it directly. + +## Current State + +- **Location**: `services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java` +- **Status**: Test-only implementation +- **Implements**: `PersistenceService` interface +- **Key Features**: Already implements most PersistenceService methods, includes refresh delay simulation, tenant transformations, file storage + +## Required Changes + +### 1. Project Structure & Module Organization + +#### 1.1 Create New Module Structure +``` +persistence-inmemory/ +├── pom.xml (parent) +├── core/ +│ ├── pom.xml +│ ├── src/ +│ │ ├── main/ +│ │ │ ├── java/ +│ │ │ │ └── org/apache/unomi/persistence/inmemory/ +│ │ │ │ └── InMemoryPersistenceServiceImpl.java +│ │ │ └── resources/ +│ │ │ └── org.apache.unomi.persistence.inmemory.cfg +│ │ └── test/ +│ │ └── java/ (test utilities if needed) +└── target/ +``` + +#### 1.2 Move Implementation Class +- Move `InMemoryPersistenceServiceImpl.java` from `services/src/test/java/` to `persistence-inmemory/core/src/main/java/org/apache/unomi/persistence/inmemory/` +- Update package name from `org.apache.unomi.services.impl` to `org.apache.unomi.persistence.inmemory` + +#### 1.3 Keep Test Access +- **Option A (Recommended)**: Keep a test helper class in `services/src/test/java` that can instantiate the moved class directly +- **Option B**: Make the constructor public and accessible from tests +- **Option C**: Create a test-specific factory class + +### 2. OSGi Service Registration with Declarative Services (DS) Annotations + +#### 2.1 Implement OSGi DS Annotations +Use OSGi Declarative Services annotations instead of Blueprint XML for type-safe, modern OSGi integration. + +**Required annotations:** +- `@Component` - Declares the component and its service interfaces +- `@Reference` - Declares service dependencies +- `@ReferenceCardinality` - For optional/multiple references +- `@Activate` - Component activation lifecycle +- `@Deactivate` - Component deactivation lifecycle +- `@Modified` - Configuration update handling +- `@Designate` - Links to configuration metadata +- `@ObjectClassDefinition` - Defines configuration properties + +**Implementation:** +```java +package org.apache.unomi.persistence.inmemory; + +import org.apache.unomi.api.services.ExecutionContextManager; +import org.apache.unomi.api.tenants.TenantTransformationListener; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.osgi.framework.BundleContext; +import org.osgi.framework.BundleEvent; +import org.osgi.framework.SynchronousBundleListener; +import org.osgi.service.component.annotations.*; +import org.osgi.service.metatype.annotations.*; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +@Component( + service = {PersistenceService.class, SynchronousBundleListener.class}, + configurationPid = "org.apache.unomi.persistence.inmemory", + immediate = true +) +@Designate(ocd = InMemoryPersistenceServiceImpl.Config.class) +public class InMemoryPersistenceServiceImpl + implements PersistenceService, SynchronousBundleListener { + + @ObjectClassDefinition( + name = "Apache Unomi In-Memory Persistence Configuration", + description = "Configuration for the in-memory persistence service" + ) + public @interface Config { + @AttributeDefinition( + name = "Storage Directory", + description = "Directory where persisted items are stored (if file storage is enabled)" + ) + String storage_dir() default "data/persistence"; + + @AttributeDefinition( + name = "File Storage Enabled", + description = "Whether to persist items to disk" + ) + boolean fileStorage_enabled() default true; + + @AttributeDefinition( + name = "Clear Storage on Init", + description = "Whether to clear storage directory on initialization" + ) + boolean clearStorage_onInit() default false; + + @AttributeDefinition( + name = "Pretty Print JSON", + description = "Whether to pretty print JSON in stored files" + ) + boolean prettyPrintJson() default true; + + @AttributeDefinition( + name = "Simulate Refresh Delay", + description = "Whether to simulate Elasticsearch refresh delay behavior" + ) + boolean simulateRefreshDelay() default true; + + @AttributeDefinition( + name = "Refresh Interval (ms)", + description = "Refresh interval in milliseconds when simulating refresh delay" + ) + int refreshIntervalMs() default 1000; + + @AttributeDefinition( + name = "Default Query Limit", + description = "Default limit for queries" + ) + int defaultQueryLimit() default 10; + + @AttributeDefinition( + name = "Item Type to Refresh Policy", + description = "Map of item types to refresh policies (JSON format)" + ) + String itemTypeToRefreshPolicy() default ""; + } + + private ConditionEvaluatorDispatcher conditionEvaluatorDispatcher; + private volatile ExecutionContextManager executionContextManager; + private BundleContext bundleContext; + + // Configuration properties + private String storageDir; + private boolean fileStorageEnabled; + private boolean clearStorageOnInit; + private boolean prettyPrintJson; + private boolean simulateRefreshDelay; + private int refreshIntervalMs; + private int defaultQueryLimit; + private String itemTypeToRefreshPolicy; + + // Reference to ConditionEvaluatorDispatcher (required) + @Reference + public void setConditionEvaluatorDispatcher(ConditionEvaluatorDispatcher dispatcher) { + this.conditionEvaluatorDispatcher = dispatcher; + } + + public void unsetConditionEvaluatorDispatcher(ConditionEvaluatorDispatcher dispatcher) { + this.conditionEvaluatorDispatcher = null; + } + + // Reference to ExecutionContextManager (optional) + @Reference(cardinality = ReferenceCardinality.OPTIONAL) + public void setExecutionContextManager(ExecutionContextManager manager) { + this.executionContextManager = manager; + } + + public void unsetExecutionContextManager(ExecutionContextManager manager) { + this.executionContextManager = null; + } + + // Reference list for TenantTransformationListeners (optional, multiple) + private final List transformationListeners = + new CopyOnWriteArrayList<>(); + + @Reference( + cardinality = ReferenceCardinality.MULTIPLE, + policy = ReferencePolicy.DYNAMIC + ) + public void bindTransformationListener(TenantTransformationListener listener) { + transformationListeners.add(listener); + addTransformationListener(listener); + } + + public void unbindTransformationListener(TenantTransformationListener listener) { + transformationListeners.remove(listener); + removeTransformationListener(listener); + } + + // Component activation + @Activate + public void activate(BundleContext bundleContext, Config config) { + this.bundleContext = bundleContext; + modified(config); + start(); + } + + // Configuration update + @Modified + public void modified(Config config) { + this.storageDir = config.storage_dir(); + this.fileStorageEnabled = config.fileStorage_enabled(); + this.clearStorageOnInit = config.clearStorage_onInit(); + this.prettyPrintJson = config.prettyPrintJson(); + this.simulateRefreshDelay = config.simulateRefreshDelay(); + this.refreshIntervalMs = config.refreshIntervalMs(); + this.defaultQueryLimit = config.defaultQueryLimit(); + this.itemTypeToRefreshPolicy = config.itemTypeToRefreshPolicy(); + + // Reinitialize if needed + if (isActive()) { + // Handle configuration changes at runtime + } + } + + // Component deactivation + @Deactivate + public void deactivate() { + stop(); + this.bundleContext = null; + } + + // Bundle lifecycle (SynchronousBundleListener) + @Override + public void bundleChanged(BundleEvent event) { + // Handle bundle lifecycle events if needed + } + + // Existing implementation methods... + // (keep all existing PersistenceService implementation methods) + + private void start() { + // Initialize service + if (simulateRefreshDelay) { + startRefreshThread(); + } + if (fileStorageEnabled) { + loadPersistedItems(); + } + } + + private void stop() { + shutdown(); + } + + private boolean isActive() { + return bundleContext != null; + } +} +``` + +#### 2.2 Maven Bundle Plugin Configuration +The Maven Bundle Plugin must be configured to process DS annotations: + +**persistence-inmemory/core/pom.xml:** +```xml + + org.apache.felix + maven-bundle-plugin + + + + org.apache.unomi.persistence.inmemory + + <_dsannotations>* + <_dsannotations-options>inherit + <_metatypeannotations>* + <_metatypeannotations-options>version;nested + + + +``` + +### 3. Configuration Management + +#### 3.1 Configuration File (Optional) +Create `persistence-inmemory/core/src/main/resources/org.apache.unomi.persistence.inmemory.cfg` for default configuration: +``` +storage.dir=data/persistence +fileStorage.enabled=true +clearStorage.onInit=false +prettyPrintJson=true +simulateRefreshDelay=true +refreshIntervalMs=1000 +defaultQueryLimit=10 +itemTypeToRefreshPolicy= +``` + +**Note**: With DS annotations, configuration is primarily managed through the `@ObjectClassDefinition` interface. The `.cfg` file provides default values that can be overridden via Configuration Admin or environment variables. + +#### 3.2 Configuration Update Support +The `@Modified` annotation method handles runtime configuration changes automatically: + +```java +@Modified +public void modified(Config config) { + // Configuration is automatically injected as Config object + this.storageDir = config.storage_dir(); + this.fileStorageEnabled = config.fileStorage_enabled(); + // ... update all properties + + // Handle runtime configuration changes + if (isActive()) { + // Reinitialize components that depend on configuration + if (simulateRefreshDelay && refreshThread == null) { + startRefreshThread(); + } else if (!simulateRefreshDelay && refreshThread != null) { + stopRefreshThread(); + } + } +} +``` + +**Benefits of DS annotations for configuration:** +- Type-safe configuration (no Dictionary casting) +- Compile-time validation +- IDE autocomplete support +- Automatic property name mapping (underscores to dots) +- Real-time updates via `@Modified` without restart + +### 4. OSGi Service Dependencies + +#### 4.1 ExecutionContextManager Integration +With DS annotations, service binding is handled automatically via `@Reference`: + +```java +@Reference(cardinality = ReferenceCardinality.OPTIONAL) +public void setExecutionContextManager(ExecutionContextManager manager) { + this.executionContextManager = manager; +} + +public void unsetExecutionContextManager(ExecutionContextManager manager) { + this.executionContextManager = null; +} +``` + +**Benefits:** +- Automatic service tracking (no manual ServiceTracker needed) +- Thread-safe binding/unbinding +- Optional cardinality allows service to start even if ExecutionContextManager is unavailable + +#### 4.2 Tenant Transformation Listeners +Multiple listeners are handled via `@Reference` with `MULTIPLE` cardinality: + +```java +@Reference( + cardinality = ReferenceCardinality.MULTIPLE, + policy = ReferencePolicy.DYNAMIC +) +public void bindTransformationListener(TenantTransformationListener listener) { + transformationListeners.add(listener); + addTransformationListener(listener); +} + +public void unbindTransformationListener(TenantTransformationListener listener) { + transformationListeners.remove(listener); + removeTransformationListener(listener); +} +``` + +**Benefits:** +- Automatic tracking of multiple services +- Dynamic policy allows services to be added/removed at runtime +- No manual ServiceTracker or ServiceListener needed + +### 5. Lifecycle Management + +#### 5.1 Component Lifecycle with DS Annotations +With DS annotations, lifecycle is managed through `@Activate`, `@Modified`, and `@Deactivate`: + +```java +@Activate +public void activate(BundleContext bundleContext, Config config) { + this.bundleContext = bundleContext; + modified(config); // Apply configuration + start(); // Initialize service +} + +@Modified +public void modified(Config config) { + // Update configuration properties + // Handle runtime configuration changes +} + +@Deactivate +public void deactivate() { + stop(); // Cleanup and shutdown + this.bundleContext = null; +} + +private void start() { + // Initialize service + if (simulateRefreshDelay) { + startRefreshThread(); + } + if (fileStorageEnabled) { + loadPersistedItems(); + } +} + +private void stop() { + // Shutdown refresh thread + shutdown(); + // Optionally persist state +} +``` + +**Lifecycle sequence:** +1. `@Activate` called when component becomes satisfied (all required references available) +2. `@Modified` called when configuration changes (runtime updates) +3. `@Deactivate` called when component becomes unsatisfied or bundle stops + +#### 5.2 Bundle Listener Implementation +The component implements `SynchronousBundleListener` as a service interface: + +```java +@Override +public void bundleChanged(BundleEvent event) { + // Handle bundle lifecycle events if needed + // Typically used for cleanup or re-initialization + // Note: This is registered as a service, not via manual registration +} +``` + +**Note**: With DS annotations, the component is automatically registered as a `SynchronousBundleListener` service (as declared in `@Component`), so no manual bundle context registration is needed. + +### 6. Maven Configuration + +#### 6.1 Parent POM +Create `persistence-inmemory/pom.xml`: +```xml + + + org.apache.unomi + unomi-root + 3.1.0-SNAPSHOT + + unomi-persistence-inmemory + pom + + core + + +``` + +#### 6.2 Core Module POM +Create `persistence-inmemory/core/pom.xml` with DS annotation dependencies: + +```xml + + + org.apache.unomi + unomi-persistence-inmemory + 3.1.0-SNAPSHOT + + unomi-persistence-inmemory-core + bundle + + + + + org.apache.unomi + unomi-api + provided + + + org.apache.unomi + unomi-persistence-spi + provided + + + org.apache.unomi + unomi-common + provided + + + + + org.osgi + org.osgi.service.component + provided + + + org.osgi + org.osgi.service.component.annotations + provided + + + org.osgi + org.osgi.service.metatype.annotations + provided + + + org.osgi + osgi.core + provided + + + + + + + org.apache.felix + maven-bundle-plugin + + + + org.apache.unomi.persistence.inmemory + + + org.apache.unomi.*;resolution:=optional, + org.osgi.*;resolution:=optional, + * + + <_dsannotations>* + <_dsannotations-options>inherit + <_metatypeannotations>* + <_metatypeannotations-options>version;nested + + unomi.persistence;type=inmemory + + + + + + + +``` + +**Key dependencies:** +- `org.osgi.service.component.annotations` - DS annotations (@Component, @Reference, @Activate, etc.) +- `org.osgi.service.metatype.annotations` - Metatype annotations (@ObjectClassDefinition, @AttributeDefinition, etc.) +- `org.osgi.service.component` - DS runtime API (provided by OSGi runtime) + +**Maven Bundle Plugin configuration:** +- `_dsannotations=*` - Process all DS annotations +- `_metatypeannotations=*` - Process all metatype annotations +- These generate the component XML descriptor automatically + +### 7. Constructor Refactoring + +#### 7.1 Support Both Direct Instantiation and OSGi +The class needs to support: +- **Direct instantiation** (for tests): Keep existing constructors +- **OSGi DS injection** (for production): Configuration via `@Activate`/`@Modified`, services via `@Reference` + +**Solution**: Keep constructors for tests, use DS annotations for OSGi: + +```java +// Keep existing constructors for test instantiation +public InMemoryPersistenceServiceImpl( + ExecutionContextManager executionContextManager, + ConditionEvaluatorDispatcher conditionEvaluatorDispatcher) { + this.executionContextManager = executionContextManager; + this.conditionEvaluatorDispatcher = conditionEvaluatorDispatcher; + // Initialize with defaults for testing + this.storageDir = "data/persistence"; + this.fileStorageEnabled = true; + // ... etc +} + +// For OSGi, configuration is injected via @Activate/@Modified methods +// Service dependencies are injected via @Reference methods +// No need for setter methods for configuration properties +``` + +**Key points:** +- **Configuration properties**: Handled via `@Activate`/`@Modified` methods with `Config` parameter (no setters needed) +- **Service dependencies**: Handled via `@Reference` methods (setter/unsetter pattern) +- **Test constructors**: Keep existing constructors for direct instantiation in tests +- **No Blueprint-style setters**: DS annotations eliminate the need for property setters + +### 8. Missing PersistenceService Methods + +Review and implement any missing methods from `PersistenceService` interface: +- `calculateStorageSize(String tenantId)` - Currently throws `UnsupportedOperationException` +- `getApiCallCount(String tenantId)` - Currently throws `UnsupportedOperationException` +- `migrateTenantData(...)` - Currently throws `UnsupportedOperationException` + +**Decision needed**: Implement these methods or document why they're not applicable for in-memory storage. + +### 9. Testing Considerations & Test Dependencies + +#### 9.1 Test Dependencies Analysis + +The test file `InMemoryPersistenceServiceImplTest.java` has several dependencies that need to be handled when moving the implementation to a new module: + +**Direct Dependencies:** +1. **TestHelper** (`org.apache.unomi.services.TestHelper`) + - Location: `services/src/test/java/org/apache/unomi/services/TestHelper.java` + - Used by: Many test files across the services module + - **Critical Issue**: Has hard dependency on `InMemoryPersistenceServiceImpl` (import, constant references, type checking) + +2. **TestConditionEvaluators** (`org.apache.unomi.services.impl.TestConditionEvaluators`) + - Location: `services/src/test/java/org/apache/unomi/services/impl/TestConditionEvaluators.java` + - Used by: Multiple test files (EventServiceImplTest, RulesServiceImplTest, etc.) + +3. **Test Item Classes**: + - `SimpleItem` - `services/src/test/java/org/apache/unomi/services/impl/SimpleItem.java` + - `NestedItem` - `services/src/test/java/org/apache/unomi/services/impl/NestedItem.java` + - `TestMetadataItem` - Inner class in test file itself + +4. **Test Infrastructure**: + - `TestRequestTracer`, `TestTenantService`, `TestActionExecutorDispatcher` + - `ExecutionContextManagerImpl` from `services-common` module + +#### 9.2 Circular Dependency Problem + +**Critical Issue**: `TestHelper` has hard references to `InMemoryPersistenceServiceImpl`: +- Direct import: `import org.apache.unomi.services.impl.InMemoryPersistenceServiceImpl;` +- Constant reference: `InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR` +- Type checking: `instanceof InMemoryPersistenceServiceImpl` + +If we move `InMemoryPersistenceServiceImpl` to `persistence-inmemory`, this creates a circular dependency: +- `services` tests → `persistence-inmemory` (for TestHelper) +- `persistence-inmemory` tests → `services` (for TestHelper) + +**Solution**: Create a shared test utilities module that both can depend on, with refactored `TestHelper` that removes hard dependencies. + +#### 9.3 Recommended Solution: Create Test Utilities Module + +Create a new `test-utilities` module that both `services` and `persistence-inmemory` can depend on. + +**Structure:** +``` +test-utilities/ +├── pom.xml +├── src/ +│ ├── main/ +│ │ └── java/ +│ │ └── org/apache/unomi/test/ +│ │ ├── TestHelper.java (refactored) +│ │ ├── TestConstants.java +│ │ ├── TestConditionEvaluators.java +│ │ ├── TestRequestTracer.java +│ │ ├── TestTenantService.java +│ │ ├── TestActionExecutorDispatcher.java +│ │ └── items/ +│ │ ├── SimpleItem.java +│ │ ├── NestedItem.java +│ │ └── TestMetadataItem.java +│ └── test/ +│ └── java/ (if needed) +``` + +**Key Refactoring Changes:** + +1. **Extract Constants**: + Create `org.apache.unomi.test.TestConstants.java`: + ```java + public class TestConstants { + public static final String DEFAULT_STORAGE_DIR = "data/persistence"; + } + ``` + +2. **Refactor TestHelper**: + - Remove `import org.apache.unomi.services.impl.InMemoryPersistenceServiceImpl;` + - Replace `InMemoryPersistenceServiceImpl.DEFAULT_STORAGE_DIR` with `TestConstants.DEFAULT_STORAGE_DIR` + - Change type checks from `instanceof InMemoryPersistenceServiceImpl` to interface-based checks: + ```java + // Before: + if (persistenceService instanceof InMemoryPersistenceServiceImpl) { + ((InMemoryPersistenceServiceImpl) persistenceService).purge((Date)null); + } + + // After: + if (persistenceService != null) { + persistenceService.purge((Date)null); + } + ``` + +3. **Move Test Item Classes**: + - Move `SimpleItem`, `NestedItem` to `test-utilities` + - Extract `TestMetadataItem` from inner class to separate file + - Update package to `org.apache.unomi.test.items` + +4. **Update Package Names**: + - `org.apache.unomi.services.TestHelper` → `org.apache.unomi.test.TestHelper` + - `org.apache.unomi.services.impl.TestConditionEvaluators` → `org.apache.unomi.test.TestConditionEvaluators` + - Update all imports in test files + +#### 9.4 Maven Configuration for Test Utilities + +**test-utilities/pom.xml:** +```xml + + + org.apache.unomi + unomi-root + 3.1.0-SNAPSHOT + + unomi-test-utilities + jar + + + + + org.apache.unomi + unomi-api + provided + + + org.apache.unomi + unomi-persistence-spi + provided + + + org.apache.unomi + unomi-services-common + provided + + + org.apache.unomi + unomi-tracing-api + provided + + + + + org.junit.jupiter + junit-jupiter + provided + + + org.mockito + mockito-core + provided + + + + + org.apache.commons + commons-lang3 + provided + + + commons-beanutils + commons-beanutils + provided + + + +``` + +**Update Root POM:** +Add to root `pom.xml`: +```xml + + ... + test-utilities + persistence-inmemory + ... + +``` + +**Update Services Module (test scope):** +```xml + + org.apache.unomi + unomi-test-utilities + ${project.version} + test + +``` + +**Update Persistence-InMemory Module (test scope):** +```xml + + org.apache.unomi + unomi-test-utilities + ${project.version} + test + + + org.apache.unomi + unomi-services-common + test + +``` + +#### 9.5 Test File Migration + +**Move Test File:** +- Move `InMemoryPersistenceServiceImplTest.java` from `services/src/test/java/` to `persistence-inmemory/core/src/test/java/org/apache/unomi/persistence/inmemory/` +- Update package to `org.apache.unomi.persistence.inmemory` +- Update imports to use `org.apache.unomi.test.*` instead of `org.apache.unomi.services.*` + +**Update All Test Files:** +Update all test files that use TestHelper: +- `EventServiceImplTest` +- `RulesServiceImplTest` +- `SegmentServiceImplTest` +- `GoalsServiceImplTest` +- `ClusterServiceImplTest` +- `ConditionValidationServiceImplTest` +- `SchedulerServiceImplTest` +- And any others + +#### 9.6 Dependency Graph (No Circular Dependencies) + +``` +services (main) + └─> test-utilities (test) [uses interfaces only] + +persistence-inmemory (main) + └─> test-utilities (test) [uses interfaces only] + └─> services-common (test) [for ExecutionContextManagerImpl] + +test-utilities (main) + └─> api (provided) + └─> persistence-spi (provided) + └─> services-common (provided) + └─> tracing-api (provided) +``` + +**No circular dependencies!** Test-utilities only uses interfaces, not implementations. + +#### 9.7 Alternative: Minimal Refactoring Approach + +If creating a new module is too much work initially: + +1. **Keep test utilities in services** (temporarily) +2. **Use test-jar** to make them available to persistence-inmemory +3. **Refactor TestHelper** to remove hard dependency on InMemoryPersistenceServiceImpl +4. **Move test file** to persistence-inmemory but keep utilities in services + +**Pros:** +- Less initial work +- Can migrate to test-utilities module later + +**Cons:** +- Creates dependency: persistence-inmemory → services (test scope) +- Test utilities conceptually in wrong place +- Harder to reuse in other modules + +### 10. Feature Integration + +#### 10.1 Karaf Feature +Add to `kar/src/main/feature/feature.xml`: +```xml + + unomi-persistence-spi + mvn:org.apache.unomi/unomi-persistence-inmemory-core/${project.version} + +``` + +#### 10.2 Documentation +- Update README with in-memory persistence option +- Document configuration properties +- Document use cases (development, testing, small deployments) + +### 11. Code Quality & Production Readiness + +#### 11.1 Error Handling +- Add proper exception handling for file operations +- Add validation for configuration values +- Add logging for important operations + +#### 11.2 Thread Safety +- Verify all concurrent operations are thread-safe +- Review `ConcurrentHashMap` usage +- Ensure refresh thread synchronization is correct + +#### 11.3 Resource Management +- Ensure proper cleanup in `stop()` method +- Handle file locks properly +- Clean up temporary resources + +### 12. Optional Enhancements + +#### 12.1 Metrics Integration +Consider integrating with `MetricsService` (like ES/OS implementations): +```java +private MetricsService metricsService; + +public void setMetricsService(MetricsService metricsService) { + this.metricsService = metricsService; +} +``` + +#### 12.2 Health Check Integration +Implement health check support for monitoring. + +## Implementation Checklist + +### Test Utilities Module +- [ ] Create `test-utilities` module structure +- [ ] Create `pom.xml` with dependencies +- [ ] Extract `TestConstants` class +- [ ] Refactor `TestHelper` to remove `InMemoryPersistenceServiceImpl` dependency +- [ ] Move `TestConditionEvaluators` to test-utilities +- [ ] Move `TestRequestTracer` to test-utilities +- [ ] Move `TestTenantService` to test-utilities +- [ ] Move `TestActionExecutorDispatcher` to test-utilities +- [ ] Move `SimpleItem` to test-utilities +- [ ] Move `NestedItem` to test-utilities +- [ ] Extract `TestMetadataItem` from inner class +- [ ] Update package names +- [ ] Add to root POM + +### Services Module Updates +- [ ] Add `unomi-test-utilities` dependency (test scope) +- [ ] Update all test file imports +- [ ] Update `TestHelper` usage in tests +- [ ] Remove old test utility files +- [ ] Verify all tests still pass + +### Persistence-InMemory Module +- [ ] Create `persistence-inmemory` module structure +- [ ] Move implementation class to new location +- [ ] Update package name +- [ ] Add OSGi DS annotation dependencies to POM +- [ ] Add `@Component` annotation with service interfaces +- [ ] Add `@ObjectClassDefinition` interface for configuration +- [ ] Add `@Designate` annotation linking to config +- [ ] Add `@Reference` annotations for service dependencies +- [ ] Add `@Activate` method for component initialization +- [ ] Add `@Modified` method for configuration updates +- [ ] Add `@Deactivate` method for component cleanup +- [ ] Implement `SynchronousBundleListener` interface +- [ ] Create configuration file (`.cfg`) - optional defaults +- [ ] Configure Maven Bundle Plugin for DS annotations +- [ ] Create Maven POMs (parent + core) with DS dependencies +- [ ] Update root POM to include new module +- [ ] Add `unomi-test-utilities` dependency (test scope) +- [ ] Add `unomi-services-common` dependency (test scope) +- [ ] Move test file to new location +- [ ] Update package and imports in test file +- [ ] Add to Karaf feature +- [ ] Implement missing PersistenceService methods (or document why not) +- [ ] Add comprehensive error handling +- [ ] Review thread safety +- [ ] Add documentation +- [ ] Test OSGi deployment +- [ ] Test direct instantiation (for unit tests) +- [ ] Verify all tests pass + +## Key Design Decisions + +1. **Dual Mode Support**: The class must support both direct instantiation (tests) and OSGi DS injection (production). This is achieved by keeping constructors for tests and using DS annotations (`@Activate`, `@Modified`, `@Reference`) for OSGi. + +2. **Package Location**: Move from `services.impl` to `persistence.inmemory` to match the pattern of ES/OS implementations. + +3. **OSGi Declarative Services**: Use DS annotations instead of Blueprint XML for modern, type-safe OSGi integration. Benefits include compile-time validation, better IDE support, and reduced boilerplate. + +4. **Test Utilities Module**: Create a shared `test-utilities` module to avoid circular dependencies. This module contains refactored `TestHelper` (without hard dependencies on `InMemoryPersistenceServiceImpl`), test item classes, and other shared test utilities. Both `services` and `persistence-inmemory` modules depend on it at test scope. + +5. **TestHelper Refactoring**: Remove hard dependency on `InMemoryPersistenceServiceImpl` by: + - Extracting constants to `TestConstants` class + - Using `PersistenceService` interface instead of concrete class + - Removing type-specific checks that require concrete class + +6. **Test Access**: Maintain test access through direct instantiation (tests can still instantiate the class directly) and shared test utilities, ensuring tests don't break. + +7. **Configuration**: Use OSGi Configuration Admin with `@ObjectClassDefinition` and `@Modified` for type-safe, runtime configuration updates without restart. + +8. **Service Registration**: Register as OSGi service via `@Component` annotation with proper service interfaces and properties to allow selection between persistence implementations. + +## Estimated Effort + +- **Test Utilities Module**: 4-6 hours + - Create module structure and POM + - Refactor TestHelper to remove hard dependencies + - Move test utility classes + - Update package names +- **Refactoring TestHelper**: 2-3 hours + - Extract constants + - Remove InMemoryPersistenceServiceImpl dependencies + - Update type checks +- **Updating All Test Files**: 3-4 hours + - Update imports across all test files + - Verify tests still pass +- **Persistence-InMemory Module Structure**: 2-4 hours +- **OSGi Integration**: 4-6 hours +- **Configuration Management**: 2-3 hours +- **Testing & Validation**: 4-6 hours +- **Documentation**: 2-3 hours +- **Total**: ~23-35 hours + +## Risks & Considerations + +1. **Breaking Tests**: Moving the class will break existing tests. Mitigation: Create test utilities module and update tests incrementally. + +2. **Circular Dependency Risk**: `TestHelper` has hard dependency on `InMemoryPersistenceServiceImpl`. Mitigation: Refactor `TestHelper` to use interfaces and extract constants to shared `TestConstants` class. + +3. **Test Utilities Migration**: Many test files depend on shared test utilities. Mitigation: Create `test-utilities` module that both `services` and `persistence-inmemory` can depend on, migrate incrementally. + +4. **OSGi DS Annotations**: Using DS annotations is newer than Blueprint (used by ES/OS). Mitigation: Follow coding guidelines and existing DS annotation examples in the codebase. DS annotations provide better type safety and IDE support. + +5. **Configuration Management**: Need to handle configuration updates at runtime. Mitigation: Use `@Modified` annotation method to handle configuration changes. DS annotations provide type-safe configuration via `@ObjectClassDefinition`. + +6. **Performance**: In-memory storage may not scale for production. Mitigation: Document use cases clearly. + +7. **Data Persistence**: File storage option exists but may not be as robust as ES/OS. Mitigation: Document limitations. + +## Conclusion + +Making `InMemoryPersistenceServiceImpl` a production-ready persistence manager is feasible and follows modern OSGi patterns. The main work involves: +1. Moving to proper module structure +2. Adding OSGi DS annotation-based service registration (modern approach) +3. Implementing lifecycle management with `@Activate`/`@Modified`/`@Deactivate` +4. Maintaining test compatibility + +The implementation is already quite complete - it mainly needs OSGi DS integration and proper packaging. Using DS annotations instead of Blueprint provides better type safety, IDE support, and aligns with the project's coding guidelines for new services. + + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 000000000..a2eaff1c7 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,896 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +################################################################################ +# +# Apache Unomi build script for Windows (PowerShell equivalent of build.sh) +# +################################################################################ + +<# +.SYNOPSIS + Builds, tests, deploys, and runs Apache Unomi on Windows. + +.DESCRIPTION + Windows counterpart to ./build.sh. Supports the same build modes: unit/integration + tests, OpenSearch/ElasticSearch IT profiles, Karaf debug, deployment, CI mode, + Javadoc validation, and integration-test tracing. Run with -Help for all options. + + Requires: Java 11+, Maven 3.6+, GraphViz (dot), tar (Windows 10+ or Git for Windows). + Integration tests additionally require Docker Desktop. + +.EXAMPLE + .\build.ps1 -Help + +.EXAMPLE + .\build.ps1 -IntegrationTests -UseOpenSearch + +.EXAMPLE + .\build.ps1 -NoKaraf -SkipTests + +.NOTES + Pair with optional setenv.ps1 in the repo root (same role as setenv.sh). + See manual/src/main/asciidoc/building-and-deploying.adoc (build.sh section). +#> + +param( + [switch]$Help, + [switch]$MavenDebug, + [switch]$MavenQuiet, + [switch]$Offline, + [switch]$SkipTests, + [switch]$SkipUnitTests, + [switch]$IntegrationTests, + [switch]$Deploy, + [switch]$Debug, + [int]$DebugPort = 5005, + [switch]$DebugSuspend, + [switch]$NoMavenCache, + [switch]$PurgeMavenCache, + [string]$KarafHome, + [switch]$UseOpenSearch, + [string]$Distribution, + [string]$SearchHeap, + [string]$KarafHeap, + [switch]$NoKaraf, + [string]$AutoStart, + [string]$SingleTest, + [switch]$ItDebug, + [int]$ItDebugPort = 5006, + [switch]$ItDebugSuspend, + [switch]$SkipMigrationTests, + [switch]$ResolverDebug, + [switch]$KeepContainer, + [switch]$NoMemorySampler, + [int]$MemoryInterval = 30, + [switch]$Javadoc, + [string]$LogFile, + [switch]$LogFileOnly, + [switch]$Ci +) + +$ErrorActionPreference = 'Stop' +$PSDefaultParameterValues['*:ErrorAction'] = 'Stop' + +# Preserve invocation for IT run tracing (archive-it-run.sh reads this on Unix) +$script:BuildScriptInvocation = @($MyInvocation.Line) + +# Defaults aligned with build.sh +$script:UseMavenCache = -not $NoMavenCache +$script:ItMemorySampler = -not $NoMemorySampler +$script:KarafDebugSuspend = if ($DebugSuspend) { 'y' } else { 'n' } + +if ($Ci) { + $NoKaraf = $true + $script:UseMavenCache = $false + $env:BUILD_NON_INTERACTIVE = 'true' + $MavenQuiet = $true + $Javadoc = $true +} + +if ($PurgeMavenCache) { + $script:UseMavenCache = $false +} + +if ($UseOpenSearch -and [string]::IsNullOrWhiteSpace($Distribution)) { + $Distribution = 'unomi-distribution-opensearch' +} +if (-not [string]::IsNullOrWhiteSpace($Distribution) -and $Distribution -like '*opensearch*') { + $UseOpenSearch = $true +} + +if ($LogFileOnly -and [string]::IsNullOrWhiteSpace($LogFile)) { + Write-Error '--log-file-only requires --log-file PATH' + exit 1 +} + +# -LogFileOnly must suppress console output entirely (matching build.sh's +# `exec > "$LOG_FILE" 2>&1`). Start-Transcript alone only tees to console + file, +# so re-invoke this script once with all output streams redirected to the file. +if ($LogFile -and $LogFileOnly -and -not $env:UNOMI_BUILD_PS1_LOG_REDIRECTED) { + $logDir = Split-Path -Parent $LogFile + if ($logDir -and -not (Test-Path $logDir)) { New-Item -ItemType Directory -Force -Path $logDir | Out-Null } + $env:UNOMI_BUILD_PS1_LOG_REDIRECTED = '1' + try { + & $PSCommandPath @PSBoundParameters *> $LogFile + exit $LASTEXITCODE + } finally { + Remove-Item Env:\UNOMI_BUILD_PS1_LOG_REDIRECTED -ErrorAction SilentlyContinue + } +} + +function Test-NonInteractive { + return [bool]($env:CI -or $env:GITHUB_ACTIONS -or $env:BUILD_NON_INTERACTIVE -eq 'true') +} + +function Initialize-Colors { + $script:HasColors = $false + if ($env:NO_COLOR) { return } + if (-not [Console]::IsOutputRedirected) { + $script:HasColors = $Host.UI.SupportsVirtualTerminal + } + + if ($script:HasColors) { + $script:RED = "`e[0;31m" + $script:GREEN = "`e[0;32m" + $script:YELLOW = "`e[1;33m" + $script:BLUE = "`e[0;34m" + $script:MAGENTA = "`e[0;35m" + $script:CYAN = "`e[0;36m" + $script:GRAY = "`e[0;90m" + $script:BOLD = "`e[1m" + $script:NC = "`e[0m" + $script:CHECK_MARK = '✔' + $script:CROSS_MARK = '✘' + $script:ARROW = '➜' + $script:WARNING = '⚠' + $script:INFO = 'ℹ' + } else { + $script:RED = $script:GREEN = $script:YELLOW = $script:BLUE = '' + $script:MAGENTA = $script:CYAN = $script:GRAY = $script:BOLD = $script:NC = '' + $script:CHECK_MARK = '(+)' + $script:CROSS_MARK = '(x)' + $script:ARROW = '(>)' + $script:WARNING = '(!)' + $script:INFO = '(i)' + } +} + +Initialize-Colors + +function Write-Section { + param([string]$Text) + $totalWidth = 80 + $textLength = $Text.Length + $paddingLength = [math]::Floor(($totalWidth - $textLength - 4) / 2) + $leftPadding = ' ' * $paddingLength + $rightPadding = ' ' * ($paddingLength + (($totalWidth - $textLength - 4) % 2)) + Write-Host '' + if ($script:HasColors) { + Write-Host "$($script:BLUE)╔════════════════════════════════════════════════════════════════════════════╗$($script:NC)" + Write-Host "$($script:BLUE)║$($script:NC)$leftPadding$($script:BOLD)$Text$($script:NC)$rightPadding$($script:BLUE)║$($script:NC)" + Write-Host "$($script:BLUE)╚════════════════════════════════════════════════════════════════════════════╝$($script:NC)" + } else { + Write-Host '+------------------------------------------------------------------------------+' + Write-Host "| $leftPadding$Text$rightPadding |" + Write-Host '+------------------------------------------------------------------------------+' + } +} + +function Write-Status { + param([string]$Status, [string]$Message) + $symbol = switch ($Status) { + 'success' { $script:CHECK_MARK } + 'error' { $script:CROSS_MARK } + 'warning' { $script:WARNING } + 'info' { $script:INFO } + default { $script:ARROW } + } + $color = switch ($Status) { + 'success' { $script:GREEN } + 'error' { $script:RED } + 'warning' { $script:YELLOW } + 'info' { $script:CYAN } + default { '' } + } + if ($script:HasColors -and $color) { + Write-Host " $symbol ${color}${Message}$($script:NC)" + } else { + Write-Host " $symbol $Message" + } +} + +function Write-BuildProgress { + param([int]$Current, [int]$Total, [string]$Message) + $percentage = [math]::Floor(($Current * 100) / $Total) + if ($script:HasColors) { + $filled = [math]::Floor($percentage / 2) + $empty = 50 - $filled + $bar = '[' + ('█' * $filled) + ('░' * $empty) + ']' + Write-Host "`r$($script:CYAN)$bar$($script:NC) $percentage% $($script:GRAY)$Message$($script:NC)" -NoNewline + } else { + $filled = [math]::Floor($percentage / 4) + $empty = 25 - $filled + $bar = '[' + ('#' * $filled) + ('-' * $empty) + ']' + Write-Host "`r$bar $percentage% $Message" -NoNewline + } +} + +function Test-CommandExists { + param([string]$Name) + return [bool](Get-Command -Name $Name -ErrorAction SilentlyContinue) +} + +function Invoke-PromptContinue { + param([string]$PromptText = 'Continue?') + if (Test-NonInteractive) { + Write-Status 'info' "Non-interactive mode: continuing ($PromptText)" + return + } + $reply = Read-Host "$PromptText (y/N)" + if ($reply -notmatch '^[Yy]$') { exit 1 } +} + +function Show-Usage { + Write-Section 'Apache Unomi Build Script' + if ($script:HasColors) { Write-Host $script:CYAN } + Write-Host @' + _ _ _____ _ ____ + | | | | ___| | | _ \ + | |__| | |__ | | | |_) | + | __ | __|| | | __/ + | | | | |___| |____| | + |_| |_\_____|______|_| +'@ + if ($script:HasColors) { Write-Host $script:NC } + Write-Host '' + Write-Host 'Usage: .\build.ps1 [options]' + Write-Host '' + Write-Host 'Options:' + Write-Host ' -Help Show this help message' + Write-Host ' -SkipTests Skip all tests' + Write-Host ' -SkipUnitTests Skip unit tests (integration tests can still run)' + Write-Host ' -IntegrationTests Run integration tests' + Write-Host ' -Deploy Deploy after build' + Write-Host ' -MavenDebug Enable Maven debug output' + Write-Host ' -MavenQuiet Disable Maven download progress (quiet mode)' + Write-Host ' -Offline Run Maven in offline mode' + Write-Host ' -Debug Run Karaf in debug mode' + Write-Host ' -DebugPort PORT Set debug port (default: 5005)' + Write-Host ' -DebugSuspend Suspend JVM until debugger connects' + Write-Host ' -NoMavenCache Disable Maven build cache' + Write-Host ' -PurgeMavenCache Purge local Maven cache before building' + Write-Host ' -KarafHome PATH Set Karaf home directory for deployment' + Write-Host ' -UseOpenSearch Use OpenSearch instead of ElasticSearch' + Write-Host ' -Distribution DIST Set Unomi distribution (e.g. unomi-distribution-opensearch)' + Write-Host ' -SearchHeap SIZE Search engine heap for integration tests' + Write-Host ' -KarafHeap SIZE Karaf JVM heap for integration tests' + Write-Host ' -NoKaraf Build without starting Karaf' + Write-Host ' -AutoStart ENGINE Auto-start elasticsearch or opensearch' + Write-Host ' -SingleTest TEST Run a single integration test' + Write-Host ' -ItDebug Enable integration test debug mode' + Write-Host ' -ItDebugPort PORT Set integration test debug port (default: 5006)' + Write-Host ' -ItDebugSuspend Suspend integration test until debugger connects' + Write-Host ' -SkipMigrationTests Skip migration-related tests' + Write-Host ' -ResolverDebug Enable Karaf Resolver debug logging for integration tests' + Write-Host ' -KeepContainer Keep search engine container running after tests' + Write-Host ' -NoMemorySampler Disable JVM/system memory sampling during integration tests' + Write-Host ' -MemoryInterval SEC Memory sample interval in seconds (default: 30)' + Write-Host ' -Javadoc Build and validate Javadoc after install' + Write-Host ' -Ci CI mode: no Karaf, no Maven cache, non-interactive, Javadoc' + Write-Host ' -LogFile PATH Tee all output to PATH (console + file)' + Write-Host ' -LogFileOnly With -LogFile: write to file only, suppress console' + Write-Host '' + Write-Host 'Examples:' + Write-Host ' .\build.ps1 -IntegrationTests -UseOpenSearch' + Write-Host ' .\build.ps1 -SkipUnitTests -IntegrationTests' + Write-Host ' .\build.ps1 -Debug -DebugPort 5006 -DebugSuspend' + Write-Host ' .\build.ps1 -Deploy -KarafHome C:\apache-karaf' + Write-Host ' .\build.ps1 -NoKaraf -AutoStart opensearch' + exit 0 +} + +if ($Help) { Show-Usage } + +if ($AutoStart -and $AutoStart -notin @('elasticsearch', 'opensearch')) { + Write-Error "AutoStart must be either 'elasticsearch' or 'opensearch'" + exit 1 +} + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$SetEnvPath = Join-Path $ScriptDir 'setenv.ps1' +if (Test-Path $SetEnvPath) { . $SetEnvPath } + +function Get-UserHome { + if ($env:USERPROFILE) { return $env:USERPROFILE } + if ($env:HOME) { return $env:HOME } + return (Get-Location).Path +} + +function Initialize-ProjectVersion { + if ($env:UNOMI_VERSION) { return } + $version = (& mvn -B -q '-DforceStdout' help:evaluate '-Dexpression=project.version' '-DinteractiveMode=false' 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($version)) { + Write-Error 'Failed to detect project version from Maven' + exit 1 + } + $env:UNOMI_VERSION = $version + Write-Host "Detected project version=$($env:UNOMI_VERSION)" +} + +Initialize-ProjectVersion + +if ($PurgeMavenCache) { + Write-Host 'Purging Maven cache...' + $m2 = Join-Path (Get-UserHome) '.m2' + foreach ($dir in @('build-cache', 'dependency-cache', 'dependency-cache_v2')) { + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue (Join-Path $m2 $dir) + } + Write-Host 'Maven cache purged.' +} + +function Test-JavaRequirement { + if (-not (Test-CommandExists 'java')) { + Write-Status 'error' 'java not found' + Write-Host 'Please install Java 11 or higher from https://adoptium.net' + return $false + } + $javaVersion = & java -version 2>&1 | Out-String + if ($javaVersion -match 'version "(\d+)') { + $major = [int]$Matches[1] + if ($major -ge 11) { + Write-Status 'success' "Java $major detected" + return $true + } + } + Write-Status 'error' "Java version 11 or higher required: $javaVersion" + return $false +} + +function Test-MavenRequirement { + if (-not (Test-CommandExists 'mvn')) { + Write-Status 'error' 'mvn not found' + return $false + } + if ($Offline) { + Write-Status 'success' 'Maven (offline mode enabled)' + return $true + } + $mvnVersion = (& mvn --version | Select-Object -First 1) + Write-Status 'success' $mvnVersion + return $true +} + +function Test-GraphVizRequirement { + if (-not (Test-CommandExists 'dot')) { + Write-Status 'error' 'GraphViz (dot) not found' + return $false + } + $dotVersion = & dot -V 2>&1 | Out-String + Write-Status 'success' "GraphViz: $($dotVersion.Trim())" + if (-not $env:GRAPHVIZ_DOT) { + $env:GRAPHVIZ_DOT = (Get-Command dot).Source + } + return $true +} + +function Test-PortAvailable { + param([int]$Port) + try { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) + $listener.Start() + $listener.Stop() + return $true + } catch { + return $false + } +} + +function Test-DockerForIntegrationTests { + if (-not (Test-CommandExists 'docker')) { + Write-Status 'error' 'Docker is not installed or not in PATH' + Write-Host 'Integration tests require Docker. Install Docker Desktop for Windows.' + return $false + } + $null = & docker info 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Status 'error' 'Docker is not accessible' + return $false + } + Write-Status 'success' "Docker available: $((& docker --version 2>&1) -join ' ')" + return $true +} + +function Test-IntegrationTestEnvVars { + param([bool]$UseOpenSearch, [string]$AutoStart) + $detected = @() + if ($env:UNOMI_ELASTICSEARCH_CLUSTERNAME -or $env:UNOMI_ELASTICSEARCH_USERNAME -or $env:UNOMI_ELASTICSEARCH_PASSWORD -or + $env:UNOMI_ELASTICSEARCH_SSL_ENABLE -or $env:UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES) { + $detected += 'Elasticsearch' + } + # UNOMI_OPENSEARCH_PASSWORD is required (and checked) by Test-Requirements when + # -UseOpenSearch or -AutoStart opensearch is selected, so it must not be treated as a + # conflicting leftover variable in that case, or the build would always fail. + $openSearchSelected = $UseOpenSearch -or ($AutoStart -eq 'opensearch') + if ($env:UNOMI_OPENSEARCH_CLUSTERNAME -or $env:UNOMI_OPENSEARCH_ADDRESSES -or $env:UNOMI_OPENSEARCH_USERNAME -or + (-not $openSearchSelected -and $env:UNOMI_OPENSEARCH_PASSWORD) -or $env:UNOMI_OPENSEARCH_SSL_ENABLE -or $env:UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES) { + $detected += 'OpenSearch' + } + if ($detected.Count -eq 0) { return } + Write-Status 'error' "Environment variables for $($detected -join ', ') are set and will interfere with integration tests" + Write-Host '' + Write-Host 'Clear them with clear-elasticsearch.sh / clear-opensearch.sh (Git Bash) or unset in PowerShell.' + exit 1 +} + +function Test-Requirements { + Write-Section 'System Requirements Check' + $hasErrors = $false + $hasWarnings = $false + + Write-Status 'info' 'Checking required tools...' + if (-not (Test-JavaRequirement)) { $hasErrors = $true } + if (-not (Test-MavenRequirement)) { $hasErrors = $true } + if (Test-CommandExists 'tar') { Write-Status 'success' 'tar' } + else { Write-Status 'error' 'tar not found (required on Windows 10+ or via Git for Windows)'; $hasErrors = $true } + if (-not (Test-GraphVizRequirement)) { $hasErrors = $true } + + if ($IsWindows) { + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue + if ($os) { + $availableMemoryMB = [math]::Round($os.FreePhysicalMemory / 1024) + if ($availableMemoryMB -lt 2048) { + Write-Status 'warning' "Memory: ${availableMemoryMB}MB available (2048MB recommended)" + $hasWarnings = $true + } else { + Write-Status 'success' "Memory: ${availableMemoryMB}MB available" + } + } + + $driveName = (Get-Location).Drive.Name + if ($driveName) { + $drive = Get-PSDrive -Name $driveName -ErrorAction SilentlyContinue + if ($drive) { + $freeMB = [math]::Round($drive.Free / 1MB) + if ($freeMB -lt 1024) { + Write-Status 'warning' "Disk space: ${freeMB}MB available (1024MB recommended)" + $hasWarnings = $true + } else { + Write-Status 'success' "Disk space: ${freeMB}MB available" + } + } + } + } + + $settings = Join-Path (Get-UserHome) '.m2/settings.xml' + if (-not (Test-Path $settings)) { + Write-Status 'warning' 'Maven settings.xml not found' + $hasWarnings = $true + } else { + Write-Status 'success' 'Maven settings.xml found' + } + + if ($Debug) { + if ($DebugPort -lt 1024 -or $DebugPort -gt 65535) { + Write-Status 'error' "Debug port: $DebugPort (invalid)" + $hasErrors = $true + } elseif (-not (Test-PortAvailable $DebugPort)) { + Write-Status 'error' "Debug port: $DebugPort (already in use)" + $hasErrors = $true + } else { + Write-Status 'success' "Debug port: $DebugPort available" + } + } + + if ($Deploy) { + if ([string]::IsNullOrWhiteSpace($KarafHome)) { + Write-Status 'error' 'Karaf home directory not set for deployment' + $hasErrors = $true + } elseif (-not (Test-Path $KarafHome)) { + Write-Status 'error' "Karaf home directory does not exist: $KarafHome" + $hasErrors = $true + } elseif (-not (Test-Path (Join-Path $KarafHome 'deploy'))) { + Write-Status 'error' "Karaf deploy directory not found: $KarafHome\deploy" + $hasErrors = $true + } else { + Write-Status 'success' "Karaf home directory validated: $KarafHome" + } + } + + if ($SkipTests -and $IntegrationTests) { + Write-Status 'error' 'Cannot use -SkipTests and -IntegrationTests together' + $hasErrors = $true + } + if ($SkipTests -and $SkipUnitTests) { + Write-Status 'error' 'Cannot use -SkipTests and -SkipUnitTests together' + $hasErrors = $true + } + + if ($IntegrationTests -and -not (Test-DockerForIntegrationTests)) { + $hasErrors = $true + } + + if (($UseOpenSearch -or $AutoStart -eq 'opensearch') -and [string]::IsNullOrWhiteSpace($env:UNOMI_OPENSEARCH_PASSWORD)) { + Write-Status 'error' 'UNOMI_OPENSEARCH_PASSWORD is not set for OpenSearch' + $hasErrors = $true + } + + if (-not [string]::IsNullOrWhiteSpace($SingleTest) -and -not $IntegrationTests) { + Write-Status 'error' 'SingleTest specified but integration tests are not enabled. Use -IntegrationTests.' + $hasErrors = $true + } + if ($ItDebug -and -not $IntegrationTests) { + Write-Status 'error' 'ItDebug enabled but integration tests are not enabled. Use -IntegrationTests.' + $hasErrors = $true + } + if ($ItDebug) { + if ($ItDebugPort -lt 1024 -or $ItDebugPort -gt 65535) { + Write-Status 'error' "Integration test debug port: $ItDebugPort (invalid)" + $hasErrors = $true + } elseif (-not (Test-PortAvailable $ItDebugPort)) { + Write-Status 'error' "Integration test debug port: $ItDebugPort (already in use)" + $hasErrors = $true + } else { + Write-Status 'success' "Integration test debug port: $ItDebugPort available" + } + } + + if ($Offline -and $PurgeMavenCache) { + Write-Status 'error' 'Cannot use -PurgeMavenCache in offline mode' + $hasErrors = $true + } + + Write-Host '' + if ($hasErrors) { + Write-Status 'error' 'Critical requirements not met. Please fix the errors above.' + exit 1 + } + if ($hasWarnings) { + Write-Status 'warning' 'Some non-critical requirements not met' + Invoke-PromptContinue 'Continue despite warnings?' + } else { + Write-Status 'success' 'All requirements checked successfully' + } +} + +Test-Requirements + +function Get-MavenArgumentList { + $args = @() + if ($MavenDebug) { $args += '-X' } + if ($Offline) { $args += '-o' } + if (-not $script:UseMavenCache) { $args += '-Dmaven.build.cache.enabled=false' } + if ($MavenQuiet) { $args += '-ntp' } + if ($env:MAVEN_EXTRA_OPTS) { + $args += $env:MAVEN_EXTRA_OPTS.Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries) + } + if (-not [string]::IsNullOrWhiteSpace($Distribution)) { + $args += "-Dunomi.distribution=$Distribution" + Write-Host "Using Unomi distribution: $Distribution" + } + if ($env:GRAPHVIZ_DOT) { + $args += "-Dgraphviz.dot.path=$($env:GRAPHVIZ_DOT)" + } + + $profiles = @() + if ($IntegrationTests) { + Test-IntegrationTestEnvVars -UseOpenSearch:$UseOpenSearch -AutoStart $AutoStart + if ($UseOpenSearch) { + $args += '-Duse.opensearch=true' + $args += '-Popensearch' + Write-Host 'Running integration tests with OpenSearch' + } else { + Write-Host 'Running integration tests with ElasticSearch' + } + $args += '-Pintegration-tests' + if ($SearchHeap) { + if ($UseOpenSearch) { $args += "-Dopensearch.heap=$SearchHeap" } + else { $args += "-Delasticsearch.heap=$SearchHeap" } + } + if ($KarafHeap) { $args += "-Dit.karaf.heap=$KarafHeap" } + if ($SingleTest) { $args += "-Dit.test=$SingleTest"; Write-Host "Running single integration test: $SingleTest" } + if ($ItDebug) { + $debugOpts = "port=$ItDebugPort" + if ($ItDebugSuspend) { $debugOpts += ',hold:true' } else { $debugOpts += ',hold:false' } + $args += "-Dit.karaf.debug=$debugOpts" + } + if ($SkipMigrationTests) { $args += '-Dit.test.exclude.pattern=**/migration/**/*IT.java' } + if ($KeepContainer) { $args += '-Dit.keepContainer=true' } + if ($ResolverDebug) { $args += '-Dit.unomi.resolver.debug=true' } + if ($SkipUnitTests) { $args += '-Pskip-unit-tests' } + } else { + if ($SkipTests) { + $profiles += '!integration-tests', '!run-tests' + $args += '-DskipTests' + } elseif ($SkipUnitTests) { + $args += '-Pskip-unit-tests' + } + if (-not [string]::IsNullOrWhiteSpace($SingleTest)) { + Write-Status 'warning' 'SingleTest specified but integration tests are not enabled. Use -IntegrationTests.' + } + } + + if ($profiles.Count -gt 0) { + $args += "-P$($profiles -join ',')" + } + return $args +} + +function Invoke-MavenPhase { + param( + [string]$Phase, + [string[]]$ExtraArgs, + [switch]$AllowFailure + ) + $mvnArgs = @($Phase) + $ExtraArgs + Write-Host "Running: mvn $($mvnArgs -join ' ')" + & mvn @mvnArgs + $code = $LASTEXITCODE + if ($code -ne 0) { + Write-Status 'error' "Maven $Phase failed" + if ($AllowFailure) { return $code } + exit $code + } + return 0 +} + +function Write-ItRunTraceStart { + param([string[]]$MvnArgs) + $traceDir = Join-Path $ScriptDir 'itests\target' + New-Item -ItemType Directory -Force -Path $traceDir | Out-Null + $traceFile = Join-Path $traceDir 'it-run-trace.properties' + $engine = if ($UseOpenSearch) { 'opensearch' } else { 'elasticsearch' } + @( + '# IT run trace (written by build.ps1 after clean, before install)' + 'trace.phase=started' + "trace.started=$((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))" + "build.invocation=$($script:BuildScriptInvocation -join ' ')" + "maven.clean.command=mvn clean $($MvnArgs -join ' ')" + "maven.install.command=mvn install $($MvnArgs -join ' ')" + "use.opensearch=$UseOpenSearch" + "search.engine=$engine" + "search.heap=$SearchHeap" + "karaf.heap=$KarafHeap" + "single.test=$SingleTest" + "it.debug=$ItDebug" + "it.debug.port=$ItDebugPort" + "it.debug.suspend=$ItDebugSuspend" + "skip.migration.tests=$SkipMigrationTests" + "it.keep.container=$KeepContainer" + "it.memory.sampler=$($script:ItMemorySampler)" + "it.memory.interval=$MemoryInterval" + "maven.debug=$MavenDebug" + "maven.offline=$Offline" + "maven.quiet=$MavenQuiet" + "host=$env:COMPUTERNAME" + ) | Set-Content -Path $traceFile -Encoding UTF8 +} + +function Start-ItMemorySampler { + $sampler = Join-Path $ScriptDir 'itests\sample-it-memory.sh' + if (-not $script:ItMemorySampler -or -not (Test-Path $sampler)) { return } + if (-not (Test-CommandExists 'bash')) { + Write-Status 'warning' 'bash not found; skipping IT memory sampler' + return + } + & bash $sampler start --target-dir (Join-Path $ScriptDir 'itests\target') --interval $MemoryInterval + if ($LASTEXITCODE -ne 0) { Write-Status 'warning' 'Could not start IT memory sampler' } +} + +function Stop-ItMemorySampler { + $sampler = Join-Path $ScriptDir 'itests\sample-it-memory.sh' + if (-not (Test-Path $sampler) -or -not (Test-CommandExists 'bash')) { return } + & bash $sampler stop --target-dir (Join-Path $ScriptDir 'itests\target') 2>$null +} + +function Complete-ItRunTrace { + param([int]$ExitCode) + $traceFile = Join-Path $ScriptDir 'itests\target\it-run-trace.properties' + if (-not (Test-Path $traceFile)) { return } + Add-Content -Path $traceFile -Value "trace.phase=completed" + Add-Content -Path $traceFile -Value "trace.completed=$((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))" + Add-Content -Path $traceFile -Value "maven.exit.code=$ExitCode" +} + +function Write-ItRunOperatorNote { + $sampler = Join-Path $ScriptDir 'itests\sample-it-memory.sh' + if (-not (Test-Path $sampler) -or -not (Test-CommandExists 'bash')) { return } + & bash $sampler operator-note --target-dir (Join-Path $ScriptDir 'itests\target') 2>$null +} + +$script:StartTime = $null +function Start-BuildTimer { $script:StartTime = Get-Date } +function Get-BuildElapsed { + if (-not $script:StartTime) { return '00:00' } + $elapsed = [math]::Floor(((Get-Date) - $script:StartTime).TotalSeconds) + return '{0:d2}:{1:d2}' -f [math]::Floor($elapsed / 60), ($elapsed % 60) +} + +if ($LogFile -and -not $LogFileOnly) { + $logDir = Split-Path -Parent $LogFile + if ($logDir -and -not (Test-Path $logDir)) { New-Item -ItemType Directory -Force -Path $logDir | Out-Null } + Start-Transcript -Path $LogFile -Append | Out-Null +} + +Write-Section 'Apache Unomi Build Script' +$mvnArgs = Get-MavenArgumentList + +Write-Host @' + + ____ _ _ _____ _ ____ + | _ \| | | |_ _| | | _ \ + | |_) | | | | | | | | | | | | + | _ <| | | | | | | | | | | | + | |_) | |__| |_| |_| |____| |_| | + |____/ \____/|_____|______|____/ + +'@ +Write-Host 'Building...' +Write-Host 'Estimated time: 3-5 minutes for build, 50-60 minutes with integration tests' +Start-BuildTimer + +$totalSteps = if ($Javadoc) { 3 } else { 2 } +$currentStep = 0 + +Write-BuildProgress (++$currentStep) $totalSteps 'Cleaning previous build...' +Write-Host '' +Invoke-MavenPhase -Phase 'clean' -ExtraArgs $mvnArgs | Out-Null + +if ($IntegrationTests) { + Write-ItRunTraceStart -MvnArgs $mvnArgs + Start-ItMemorySampler +} + +Write-BuildProgress (++$currentStep) $totalSteps 'Compiling and installing artifacts...' +Write-Host '' +$installExit = Invoke-MavenPhase -Phase 'install' -ExtraArgs $mvnArgs -AllowFailure +if ($IntegrationTests) { + Stop-ItMemorySampler + Complete-ItRunTrace -ExitCode $installExit + Write-ItRunOperatorNote +} +if ($installExit -ne 0) { exit $installExit } + +Write-Status 'success' "Build completed in $(Get-BuildElapsed)" + +if ($Javadoc) { + Write-Section 'Javadoc Validation' + Write-BuildProgress (++$currentStep) $totalSteps 'Generating and validating Javadoc...' + Write-Host '' + $javadocArgs = @('javadoc:javadoc', '-DskipTests') + $mvnArgs + Write-Host "Running: mvn $($javadocArgs -join ' ')" + & mvn @javadocArgs + if ($LASTEXITCODE -ne 0) { + Write-Status 'error' 'Javadoc validation failed — fix doclint errors above before pushing' + exit $LASTEXITCODE + } + Write-Status 'success' 'Javadoc validated successfully' +} + +if ($Deploy) { + Write-Section 'Deploying to Apache Karaf' + $karFile = Join-Path $ScriptDir "kar\target\unomi-kar-$($env:UNOMI_VERSION).kar" + if (-not (Test-Path $karFile)) { + Write-Status 'error' "KAR file not found: $karFile" + exit 1 + } + Copy-Item -Force $karFile (Join-Path $KarafHome 'deploy\') + Write-Status 'success' 'KAR package copied successfully' + + $repo = Join-Path $KarafHome 'data\maven\repository' + if (Test-Path $repo) { + Get-ChildItem $repo | Remove-Item -Recurse -Force + Write-Status 'success' 'Karaf Maven repository purged' + } + $tmp = Join-Path $KarafHome 'data\tmp' + if (Test-Path $tmp) { + Get-ChildItem $tmp | Remove-Item -Recurse -Force + Write-Status 'success' 'Karaf temporary files purged' + } + Write-Status 'success' 'Deployment completed' +} + +if (-not $NoKaraf) { + $packageTarget = Join-Path $ScriptDir 'package\target' + if (-not (Test-Path $packageTarget)) { + Write-Status 'error' 'Build directory not found. Did the build complete successfully?' + exit 1 + } + + Push-Location $packageTarget + try { + $archive = Join-Path $packageTarget "unomi-$($env:UNOMI_VERSION).tar.gz" + if (-not (Test-Path $archive)) { + Write-Status 'error' "Unomi package not found: $archive" + exit 1 + } + & tar -xzf $archive + if ($LASTEXITCODE -ne 0) { + Write-Status 'error' "Failed to extract $archive" + exit $LASTEXITCODE + } + $unomiHome = Join-Path $packageTarget "unomi-$($env:UNOMI_VERSION)" + + foreach ($pair in @( + @{ Src = Join-Path $ScriptDir 'GeoLite2-City.mmdb'; Dest = Join-Path $unomiHome 'etc\GeoLite2-City.mmdb' } + @{ Src = Join-Path $ScriptDir 'allCountries.zip'; Dest = Join-Path $unomiHome 'etc\allCountries.zip' } + )) { + if (Test-Path $pair.Src) { + Copy-Item -Force $pair.Src $pair.Dest + } + } + + $karafOptsParts = @() + if ($AutoStart) { + $karafOptsParts += "-Dunomi.autoStart=$AutoStart" + Write-Status 'info' "Configuring auto-start for $AutoStart" + } + if (-not [string]::IsNullOrWhiteSpace($Distribution)) { + $karafOptsParts += "-Dunomi.distribution=$Distribution" + Write-Status 'info' "Using Unomi distribution: $Distribution" + } + if ($karafOptsParts.Count -gt 0) { + $env:KARAF_OPTS = $karafOptsParts -join ' ' + } + + if ($Debug) { + if (-not (Test-PortAvailable $DebugPort)) { + Write-Status 'error' "Port $DebugPort is already in use" + exit 1 + } + $env:KARAF_DEBUG = 'true' + $env:JAVA_DEBUG_OPTS = "-agentlib:jdwp=transport=dt_socket,server=y,suspend=$($script:KarafDebugSuspend),address=$DebugPort" + Write-Status 'info' "Debug mode enabled (port: $DebugPort, suspend: $($script:KarafDebugSuspend))" + } + + $karafBat = Join-Path $unomiHome 'bin\karaf.bat' + if (-not (Test-Path $karafBat)) { + Write-Status 'error' 'Karaf executable not found: karaf.bat' + exit 1 + } + Push-Location (Join-Path $unomiHome 'bin') + try { + & $karafBat + if ($LASTEXITCODE -ne 0) { + Write-Status 'error' 'Karaf failed to start' + exit $LASTEXITCODE + } + } finally { + Pop-Location + } + } finally { + Pop-Location + } +} else { + Write-Status 'info' 'Skipping Karaf startup (-NoKaraf specified)' + if ($AutoStart) { + Write-Status 'info' "Note: auto-start ($AutoStart) will be applied when Karaf is started manually" + } +} + +Write-Host @' + + ____ ___ _ _ _____ _ + | _ \ / _ \| \ | | ____| | + | | | | | | | \| | _| | | + | |_| | |_| | |\ | |___|_| + |____/ \___/|_| \_|_____(_) + +'@ +Write-Host 'Operation completed successfully.' + +if ($LogFile -and -not $LogFileOnly) { + Stop-Transcript | Out-Null +} diff --git a/build.sh b/build.sh index c6eaa7837..9a8e9e2f7 100755 --- a/build.sh +++ b/build.sh @@ -251,6 +251,7 @@ print_section "Apache Unomi Build Script" # Default values SKIP_TESTS=false +SKIP_UNIT_TESTS=false RUN_INTEGRATION_TESTS=false DEPLOY=false DEBUG=false @@ -266,11 +267,16 @@ KARAF_HEAP="" MAVEN_QUIET=false NO_KARAF=false AUTO_START="" +# Only initialize UNOMI_DISTRIBUTION if not already set (e.g., by setup-opensearch.sh or setup-elasticsearch.sh) +if [ -z "${UNOMI_DISTRIBUTION+x}" ]; then + UNOMI_DISTRIBUTION="" +fi SINGLE_TEST="" IT_DEBUG=false IT_DEBUG_PORT=5006 IT_DEBUG_SUSPEND=false SKIP_MIGRATION_TESTS=false +RESOLVER_DEBUG=false KEEP_CONTAINER=false IT_MEMORY_SAMPLER=true IT_MEMORY_INTERVAL=30 @@ -297,6 +303,7 @@ EOF echo -e "${BOLD}Options:${NC}" echo -e " ${CYAN}-h, --help${NC} Show this help message" echo -e " ${CYAN}-s, --skip-tests${NC} Skip all tests" + echo -e " ${CYAN}--skip-unit-tests${NC} Skip unit tests (integration tests can still run)" echo -e " ${CYAN}-i, --integration-tests${NC} Run integration tests" echo -e " ${CYAN}-d, --deploy${NC} Deploy after build" echo -e " ${CYAN}-X, --maven-debug${NC} Enable Maven debug output" @@ -309,6 +316,7 @@ EOF echo -e " ${CYAN}--purge-maven-cache${NC} Purge local Maven cache before building" echo -e " ${CYAN}--karaf-home PATH${NC} Set Karaf home directory for deployment" echo -e " ${CYAN}--use-opensearch${NC} Use OpenSearch instead of ElasticSearch" + echo -e " ${CYAN}--distribution DIST${NC} Set Unomi distribution (e.g., unomi-distribution-opensearch)" echo -e " ${CYAN}--no-karaf${NC} Build without starting Karaf" echo -e " ${CYAN}--auto-start ENGINE${NC} Auto-start with specified engine" echo -e " ${CYAN}--single-test TEST${NC} Run a single integration test" @@ -316,6 +324,7 @@ EOF echo -e " ${CYAN}--it-debug-port PORT${NC} Set integration test debug port" echo -e " ${CYAN}--it-debug-suspend${NC} Suspend integration test until debugger connects" echo -e " ${CYAN}--skip-migration-tests${NC} Skip migration-related tests" + echo -e " ${CYAN}--resolver-debug${NC} Enable Karaf Resolver debug logging for integration tests" echo -e " ${CYAN}--keep-container${NC} Keep search engine container running after tests (for post-failure inspection)" echo -e " ${CYAN}--no-memory-sampler${NC} Disable JVM/system memory sampling during integration tests" echo -e " ${CYAN}--memory-interval SEC${NC} Memory sample interval in seconds (default: 30)" @@ -338,6 +347,7 @@ EOF echo "Options:" echo " -h, --help Show this help message" echo " -s, --skip-tests Skip all tests" + echo " --skip-unit-tests Skip unit tests (integration tests can still run)" echo " -i, --integration-tests Run integration tests" echo " -d, --deploy Deploy after build" echo " -X, --maven-debug Enable Maven debug output" @@ -350,6 +360,7 @@ EOF echo " --purge-maven-cache Purge local Maven cache before building" echo " --karaf-home PATH Set Karaf home directory for deployment" echo " --use-opensearch Use OpenSearch instead of ElasticSearch" + echo " --distribution DIST Set Unomi distribution (e.g., unomi-distribution-opensearch)" echo " --no-karaf Build without starting Karaf" echo " --auto-start ENGINE Auto-start with specified engine" echo " --single-test TEST Run a single integration test" @@ -357,6 +368,7 @@ EOF echo " --it-debug-port PORT Set integration test debug port" echo " --it-debug-suspend Suspend integration test until debugger connects" echo " --skip-migration-tests Skip migration-related tests" + echo " --resolver-debug Enable Karaf Resolver debug logging for integration tests" echo " --keep-container Keep search engine container running after tests (for post-failure inspection)" echo " --no-memory-sampler Disable JVM/system memory sampling during integration tests" echo " --memory-interval SEC Memory sample interval in seconds (default: 30)" @@ -374,6 +386,9 @@ EOF echo " # Build with integration tests using OpenSearch" echo " $0 --integration-tests --use-opensearch" echo + echo " # Build skipping unit tests but running integration tests" + echo " $0 --skip-unit-tests --integration-tests" + echo echo " # Build in debug mode" echo " $0 --debug --debug-port 5006 --debug-suspend" echo @@ -398,6 +413,9 @@ EOF echo " # Build with integration tests using OpenSearch" echo " $0 --integration-tests --use-opensearch" echo + echo " # Build skipping unit tests but running integration tests" + echo " $0 --skip-unit-tests --integration-tests" + echo echo " # Build in debug mode" echo " $0 --debug --debug-port 5006 --debug-suspend" echo @@ -443,6 +461,9 @@ while [ "$1" != "" ]; do -s | --skip-tests) SKIP_TESTS=true ;; + --skip-unit-tests) + SKIP_UNIT_TESTS=true + ;; -i | --integration-tests) RUN_INTEGRATION_TESTS=true ;; @@ -472,6 +493,10 @@ while [ "$1" != "" ]; do --use-opensearch) USE_OPENSEARCH=true ;; + --distribution) + shift + UNOMI_DISTRIBUTION="$1" + ;; --search-heap) shift SEARCH_HEAP=$1 @@ -508,6 +533,9 @@ while [ "$1" != "" ]; do --skip-migration-tests) SKIP_MIGRATION_TESTS=true ;; + --resolver-debug) + RESOLVER_DEBUG=true + ;; --keep-container) KEEP_CONTAINER=true ;; @@ -543,6 +571,15 @@ while [ "$1" != "" ]; do shift done +# Wire distribution and use-opensearch parameters +if [ "$USE_OPENSEARCH" = true ] && [ -z "$UNOMI_DISTRIBUTION" ]; then + UNOMI_DISTRIBUTION="unomi-distribution-opensearch" +fi + +if [ ! -z "$UNOMI_DISTRIBUTION" ] && [[ "$UNOMI_DISTRIBUTION" == *opensearch* ]]; then + USE_OPENSEARCH=true +fi + # Wire up log file output if requested if [ "$LOG_FILE_ONLY" = true ] && [ -z "$LOG_FILE" ]; then echo "Error: --log-file-only requires --log-file PATH" @@ -796,6 +833,76 @@ check_requirements() { has_errors=true fi + if [ "$SKIP_TESTS" = true ] && [ "$SKIP_UNIT_TESTS" = true ]; then + print_status "error" "Cannot use --skip-tests and --skip-unit-tests together" + has_errors=true + fi + + # Docker check for integration tests + if [ "$RUN_INTEGRATION_TESTS" = true ]; then + print_status "info" "Checking Docker availability for integration tests..." + if ! command_exists docker; then + print_status "error" "Docker is not installed or not in PATH" + echo "Integration tests require Docker to run Elasticsearch/OpenSearch containers." + echo "Please install Docker:" + if [[ "$(uname)" == "Darwin" ]]; then + echo " - macOS: Download Docker Desktop from https://www.docker.com/products/docker-desktop" + echo " - Or install via Homebrew: brew install --cask docker" + else + echo " - Ubuntu/Debian: sudo apt install docker.io" + echo " - CentOS/RHEL/Fedora: sudo yum install docker (or sudo dnf install docker)" + echo " - Or follow: https://docs.docker.com/get-docker/" + fi + has_errors=true + else + docker_info_output=$(docker info 2>&1) + docker_info_exit_code=$? + if [ $docker_info_exit_code -ne 0 ]; then + if echo "$docker_info_output" | grep -q "permission denied\|Got permission denied"; then + print_status "error" "Docker permission denied" + echo "Docker is installed but you don't have permission to access it." + if [[ "$(uname)" == "Darwin" ]]; then + echo "On macOS, ensure Docker Desktop is running and you're logged in." + else + echo "On Linux, add your user to the docker group:" + echo " sudo usermod -aG docker $USER" + echo " Then log out and log back in, or run: newgrp docker" + fi + elif echo "$docker_info_output" | grep -q "Cannot connect to the Docker daemon\|Is the docker daemon running"; then + print_status "error" "Docker daemon is not running" + echo "Please start Docker daemon:" + if [[ "$(uname)" == "Darwin" ]]; then + echo " - macOS: Start Docker Desktop application from Applications" + echo " - Or from command line: open -a Docker" + else + echo " - Linux: sudo systemctl start docker" + echo " - Or for older systems: sudo service docker start" + fi + else + print_status "error" "Docker is not accessible" + echo "Docker command failed with:" + echo "$docker_info_output" + fi + has_errors=true + else + docker_version=$(docker --version 2>&1) + print_status "success" "✓ Docker available: ${docker_version}" + fi + fi + fi + + # OpenSearch password check + if [ "$USE_OPENSEARCH" = true ] || [ "$AUTO_START" = "opensearch" ]; then + if [ -z "$UNOMI_OPENSEARCH_PASSWORD" ]; then + print_status "error" "UNOMI_OPENSEARCH_PASSWORD is not set for OpenSearch" + echo "When using OpenSearch, you must export UNOMI_OPENSEARCH_PASSWORD before running the build/start." + echo "Examples:" + echo " export UNOMI_OPENSEARCH_PASSWORD=yourStrongPassword" + echo " UNOMI_OPENSEARCH_PASSWORD=yourStrongPassword $0 --integration-tests --use-opensearch" + has_errors=true + fi + fi + if [ ! -z "$SINGLE_TEST" ] && [ "$RUN_INTEGRATION_TESTS" = false ]; then print_status "error" "Single test specified (--single-test) but integration tests are not enabled. Use --integration-tests to run the test." has_errors=true @@ -920,9 +1027,62 @@ if [ ! -f ~/.m2/settings.xml ]; then fi fi +# Function to check for conflicting environment variables before integration tests +check_integration_test_env_vars() { + local detected_vars=() + local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + if [ -n "${UNOMI_ELASTICSEARCH_CLUSTERNAME+x}" ] || \ + [ -n "${UNOMI_ELASTICSEARCH_USERNAME+x}" ] || \ + [ -n "${UNOMI_ELASTICSEARCH_PASSWORD+x}" ] || \ + [ -n "${UNOMI_ELASTICSEARCH_SSL_ENABLE+x}" ] || \ + [ -n "${UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES+x}" ]; then + detected_vars+=("Elasticsearch") + fi + + # UNOMI_OPENSEARCH_PASSWORD is required (and checked) by check_requirements when + # --use-opensearch is selected, so it must not be treated as a conflicting leftover + # variable in that case, or --integration-tests --use-opensearch would always fail. + if [ -n "${UNOMI_OPENSEARCH_CLUSTERNAME+x}" ] || \ + [ -n "${UNOMI_OPENSEARCH_ADDRESSES+x}" ] || \ + [ -n "${UNOMI_OPENSEARCH_USERNAME+x}" ] || \ + ([ "$USE_OPENSEARCH" != true ] && [ -n "${UNOMI_OPENSEARCH_PASSWORD+x}" ]) || \ + [ -n "${UNOMI_OPENSEARCH_SSL_ENABLE+x}" ] || \ + [ -n "${UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES+x}" ]; then + detected_vars+=("OpenSearch") + fi + + if [ ${#detected_vars[@]} -gt 0 ]; then + print_status "error" "Environment variables for ${detected_vars[*]} are set and will interfere with integration tests" + echo "" + echo "Integration tests manage their own search engine configuration and should not" + echo "be run with these environment variables set." + echo "" + echo "To clear the environment variables, run one of the following:" + echo "" + for var_type in "${detected_vars[@]}"; do + if [ "$var_type" = "Elasticsearch" ]; then + echo " source ${script_dir}/clear-elasticsearch.sh" + elif [ "$var_type" = "OpenSearch" ]; then + echo " source ${script_dir}/clear-opensearch.sh" + fi + done + echo "" + echo "After clearing the variables, you can run the integration tests again." + exit 1 + fi +} + +# Add unomi.distribution system property if set +if [ ! -z "$UNOMI_DISTRIBUTION" ]; then + MVN_OPTS="$MVN_OPTS -Dunomi.distribution=$UNOMI_DISTRIBUTION" + echo "Using Unomi distribution: $UNOMI_DISTRIBUTION" +fi + # Add profile options PROFILES="" if [ "$RUN_INTEGRATION_TESTS" = true ]; then + check_integration_test_env_vars if [ "$USE_OPENSEARCH" = true ]; then MVN_OPTS="$MVN_OPTS -Duse.opensearch=true -P opensearch" echo "Running integration tests with OpenSearch" @@ -976,10 +1136,23 @@ if [ "$RUN_INTEGRATION_TESTS" = true ]; then MVN_OPTS="$MVN_OPTS -Dit.keepContainer=true" echo "Search engine container will be kept running after tests" fi + + if [ "$RESOLVER_DEBUG" = true ]; then + MVN_OPTS="$MVN_OPTS -Dit.unomi.resolver.debug=true" + echo "Enabling Karaf Resolver debug logging for integration tests" + fi + + if [ "$SKIP_UNIT_TESTS" = true ]; then + MVN_OPTS="$MVN_OPTS -P skip-unit-tests" + echo "Skipping unit tests (integration tests will still run)" + fi else if [ "$SKIP_TESTS" = true ]; then PROFILES="$PROFILES,!integration-tests,!run-tests" MVN_OPTS="$MVN_OPTS -DskipTests" + elif [ "$SKIP_UNIT_TESTS" = true ]; then + MVN_OPTS="$MVN_OPTS -P skip-unit-tests" + echo "Skipping unit tests" fi # Warn if single test was specified but integration tests are not enabled @@ -1326,11 +1499,24 @@ EOF if [ ! -z "$AUTO_START" ]; then print_status "info" "Configuring auto-start for $AUTO_START" - export KARAF_OPTS="-Dunomi.autoStart=$AUTO_START" + KARAF_OPTS_ARGS="-Dunomi.autoStart=$AUTO_START" else print_status "info" "Use [unomi:start] to start Unomi after Karaf initialization" fi + if [ ! -z "$UNOMI_DISTRIBUTION" ]; then + if [ ! -z "$KARAF_OPTS_ARGS" ]; then + KARAF_OPTS_ARGS="$KARAF_OPTS_ARGS -Dunomi.distribution=$UNOMI_DISTRIBUTION" + else + KARAF_OPTS_ARGS="-Dunomi.distribution=$UNOMI_DISTRIBUTION" + fi + print_status "info" "Using Unomi distribution: $UNOMI_DISTRIBUTION" + fi + + if [ ! -z "$KARAF_OPTS_ARGS" ]; then + export KARAF_OPTS="$KARAF_OPTS_ARGS" + fi + ./karaf || { print_status "error" "Karaf failed to start" exit 1 diff --git a/clear-elasticsearch.sh b/clear-elasticsearch.sh new file mode 100755 index 000000000..3e3c23ea4 --- /dev/null +++ b/clear-elasticsearch.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +################################################################################ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +################################################################################ + +# Detect if script is being sourced or executed directly +# Both setup and clear scripts must be sourced to modify the current shell's environment +_IS_SOURCED=false +if [ -n "${ZSH_VERSION}" ]; then + # In zsh, use funcstack - it has entries when sourced, empty when executed + # This works even when nested (sourced from another sourced script) + if [ ${#funcstack[@]} -gt 0 ]; then + _IS_SOURCED=true + fi +else + # In bash, check BASH_SOURCE + if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "${0}" ]; then + _IS_SOURCED=true + fi +fi + +if [ "${_IS_SOURCED}" = false ]; then + echo "WARNING: This script must be sourced to clear environment variables in your shell." >&2 + echo "" >&2 + echo "Usage:" >&2 + echo " source ${0}" >&2 + echo " # or" >&2 + echo " . ${0}" >&2 + echo "" >&2 + echo "Note: Both setup and clear scripts must be sourced because they modify" >&2 + echo "environment variables (export/unset) which only work in the current shell." >&2 + # Only exit if executed directly, not if sourced + exit 1 +fi + +# Clear Elasticsearch environment variables +unset UNOMI_ELASTICSEARCH_CLUSTERNAME +unset UNOMI_ELASTICSEARCH_ADDRESSES +unset UNOMI_ELASTICSEARCH_USERNAME +unset UNOMI_ELASTICSEARCH_PASSWORD +unset UNOMI_ELASTICSEARCH_SSL_ENABLE +unset UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES + +echo "Elasticsearch environment variables cleared." + diff --git a/clear-opensearch.sh b/clear-opensearch.sh new file mode 100755 index 000000000..e6789bff4 --- /dev/null +++ b/clear-opensearch.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +################################################################################ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +################################################################################ + +# Detect if script is being sourced or executed directly +# Both setup and clear scripts must be sourced to modify the current shell's environment +_IS_SOURCED=false +if [ -n "${ZSH_VERSION}" ]; then + # In zsh, use funcstack - it has entries when sourced, empty when executed + # This works even when nested (sourced from another sourced script) + if [ ${#funcstack[@]} -gt 0 ]; then + _IS_SOURCED=true + fi +else + # In bash, check BASH_SOURCE + if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "${0}" ]; then + _IS_SOURCED=true + fi +fi + +if [ "${_IS_SOURCED}" = false ]; then + echo "WARNING: This script must be sourced to clear environment variables in your shell." >&2 + echo "" >&2 + echo "Usage:" >&2 + echo " source ${0}" >&2 + echo " # or" >&2 + echo " . ${0}" >&2 + echo "" >&2 + echo "Note: Both setup and clear scripts must be sourced because they modify" >&2 + echo "environment variables (export/unset) which only work in the current shell." >&2 + # Only exit if executed directly, not if sourced + exit 1 +fi + +# Clear OpenSearch environment variables +unset UNOMI_OPENSEARCH_CLUSTERNAME +unset UNOMI_OPENSEARCH_ADDRESSES +unset UNOMI_OPENSEARCH_USERNAME +unset UNOMI_OPENSEARCH_PASSWORD +unset UNOMI_OPENSEARCH_SSL_ENABLE +unset UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES + +echo "OpenSearch environment variables cleared." + diff --git a/docker/src/main/docker/docker-compose-build-es.yml b/docker/src/main/docker/docker-compose-build-es.yml index 454e10364..0aa221209 100644 --- a/docker/src/main/docker/docker-compose-build-es.yml +++ b/docker/src/main/docker/docker-compose-build-es.yml @@ -34,6 +34,7 @@ services: node-1: build: . image: apache/unomi:${project.version} + container_name: unomi environment: - UNOMI_AUTO_START=true - UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch @@ -46,6 +47,7 @@ services: - 8181:8181 - 9443:9443 - 8102:8102 + - "${DEBUG_PORT:-5005}:5005" networks: - unomi-3 links: diff --git a/docker/src/main/docker/docker-compose-es.yml b/docker/src/main/docker/docker-compose-es.yml index 43f3bbba5..db97b6d64 100644 --- a/docker/src/main/docker/docker-compose-es.yml +++ b/docker/src/main/docker/docker-compose-es.yml @@ -40,6 +40,7 @@ services: node-1: image: apache/unomi:${project.version} + container_name: unomi environment: - UNOMI_AUTO_START=true - UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch @@ -52,6 +53,7 @@ services: - 8181:8181 - 9443:9443 - 8102:8102 + - "${DEBUG_PORT:-5005}:5005" networks: - unomi-3 links: diff --git a/etc/org.apache.unomi.tenant.cfg b/etc/org.apache.unomi.tenant.cfg new file mode 100644 index 000000000..ee8e7dcf3 --- /dev/null +++ b/etc/org.apache.unomi.tenant.cfg @@ -0,0 +1,25 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +# Tenant Configuration +tenant.default.id=default +tenant.apikey.validity.period=30 +tenant.apikey.validity.unit=DAYS +tenant.apikey.rotation.warning.days=7 + +# Security Settings +tenant.security.provider=${unomi.search.engine} +tenant.security.roles.prefix=unomi_tenant_ diff --git a/itests/docker-compose-snapshot-analysis.yml b/itests/docker-compose-snapshot-analysis.yml new file mode 100644 index 000000000..7e9f955e8 --- /dev/null +++ b/itests/docker-compose-snapshot-analysis.yml @@ -0,0 +1,69 @@ +################################################################################ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +################################################################################ +services: + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:9.1.3 + container_name: es-snapshot-analysis + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms2g -Xmx2g" + - bootstrap.memory_lock=true + - cluster.name=snapshot-analysis + # Configure snapshot repository path (can contain multiple paths separated by commas) + - path.repo=/usr/share/elasticsearch/snapshots_repository + ulimits: + memlock: + soft: -1 + hard: -1 + volumes: + - ./snapshots_repository:/usr/share/elasticsearch/snapshots_repository + - es-data:/usr/share/elasticsearch/data + ports: + - "9200:9200" + - "9300:9300" + networks: + - es-kibana-net + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] + interval: 10s + timeout: 10s + retries: 30 + + kibana: + image: docker.elastic.co/kibana/kibana:9.1.3 + container_name: kibana-snapshot-analysis + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + - xpack.security.enabled=false + - xpack.monitoring.ui.container.elasticsearch.enabled=true + ports: + - "5601:5601" + networks: + - es-kibana-net + depends_on: + elasticsearch: + condition: service_healthy + +volumes: + es-data: + driver: local + +networks: + es-kibana-net: + driver: bridge + diff --git a/itests/setup-snapshot-analysis.sh b/itests/setup-snapshot-analysis.sh new file mode 100755 index 000000000..5699908b7 --- /dev/null +++ b/itests/setup-snapshot-analysis.sh @@ -0,0 +1,147 @@ +#!/bin/bash +################################################################################ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +################################################################################ +# Quick setup script to restore Elasticsearch snapshot and analyze with Kibana + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SNAPSHOT_ZIP="$SCRIPT_DIR/src/test/resources/migration/snapshots_repository.zip" +EXTRACT_DIR="$SCRIPT_DIR/snapshots_repository" +COMPOSE_FILE="$SCRIPT_DIR/docker-compose-snapshot-analysis.yml" + +echo "==========================================" +echo "Elasticsearch Snapshot Analysis Setup" +echo "==========================================" + +# Step 1: Extract snapshot repository (matching Maven build process from itests/pom.xml's unzip task) +if [ ! -d "$EXTRACT_DIR" ]; then + echo "Extracting snapshot repository..." + echo " (Matching Maven build: unzip to \${project.build.directory}/snapshots_repository)" + unzip -q "$SNAPSHOT_ZIP" -d "$SCRIPT_DIR" + echo "✓ Extracted to $EXTRACT_DIR" +else + echo "✓ Snapshot repository already extracted" +fi + +# Step 2: Start Elasticsearch and Kibana +echo "" +echo "Starting Elasticsearch and Kibana..." +cd "$SCRIPT_DIR" +docker-compose -f "$COMPOSE_FILE" up -d + +# Step 3: Wait for Elasticsearch to be ready +echo "" +echo "Waiting for Elasticsearch to be ready..." +max_attempts=60 +attempt=0 +while [ $attempt -lt $max_attempts ]; do + if curl -s http://localhost:9200/_cluster/health > /dev/null 2>&1; then + echo "✓ Elasticsearch is ready!" + break + fi + attempt=$((attempt + 1)) + echo " Attempt $attempt/$max_attempts..." + sleep 2 +done + +if [ $attempt -eq $max_attempts ]; then + echo "✗ Elasticsearch failed to start" + exit 1 +fi + +# Step 4: Register snapshot repository (matching test configuration) +echo "" +echo "Registering snapshot repository..." +REPO_RESPONSE=$(curl -s -X PUT "http://localhost:9200/_snapshot/snapshots_repository" \ + -H 'Content-Type: application/json' \ + -d '{ + "type": "fs", + "settings": { + "location": "snapshots" + } + }') + +if echo "$REPO_RESPONSE" | grep -q '"acknowledged":true'; then + echo "✓ Snapshot repository registered" +else + echo "✗ Failed to register repository: $REPO_RESPONSE" + exit 1 +fi + +# Step 5: Verify snapshot exists (matching test configuration) +echo "" +echo "Verifying snapshot exists..." +SNAPSHOT_NAME="snapshot_3" # From Migrate16xToCurrentVersionIT.java's ES_SNAPSHOT_3 constant +SNAPSHOT_CHECK=$(curl -s "http://localhost:9200/_snapshot/snapshots_repository/$SNAPSHOT_NAME") +if [ -z "$SNAPSHOT_CHECK" ] || echo "$SNAPSHOT_CHECK" | grep -q '"error"'; then + echo "✗ Snapshot $SNAPSHOT_NAME not found" + echo "Response: $SNAPSHOT_CHECK" + echo "" + echo "Available snapshots:" + curl -s "http://localhost:9200/_snapshot/snapshots_repository/_all" | python3 -m json.tool 2>/dev/null || curl -s "http://localhost:9200/_snapshot/snapshots_repository/_all" + exit 1 +fi +echo "✓ Snapshot $SNAPSHOT_NAME found" + +# Step 6: Restore snapshot (matching test configuration) +echo "" +echo "Restoring snapshot: $SNAPSHOT_NAME" +RESTORE_RESPONSE=$(curl -s -X POST "http://localhost:9200/_snapshot/snapshots_repository/$SNAPSHOT_NAME/_restore?wait_for_completion=true" \ + -H 'Content-Type: application/json' \ + -d '{}') + +if echo "$RESTORE_RESPONSE" | grep -q '"snapshot"' && ! echo "$RESTORE_RESPONSE" | grep -q '"error"'; then + echo "✓ Snapshot restored successfully!" +else + echo "✗ Failed to restore snapshot: $RESTORE_RESPONSE" + echo "Check status with:" + echo " curl http://localhost:9200/_snapshot/snapshots_repository/$SNAPSHOT_NAME/_status" + exit 1 +fi + +# Step 7: List restored indices +echo "" +echo "Restored indices:" +curl -s "http://localhost:9200/_cat/indices?v" + +echo "" +echo "==========================================" +echo "Setup Complete!" +echo "==========================================" +echo "" +echo "Access Kibana at: http://localhost:5601" +echo "" +echo "Useful commands:" +echo " - List indices: curl http://localhost:9200/_cat/indices?v" +echo " - Search an index: curl http://localhost:9200/INDEX_NAME/_search?pretty" +echo " - Stop services: docker-compose -f $COMPOSE_FILE down" +echo " - View logs: docker-compose -f $COMPOSE_FILE logs -f" +echo "" +echo "Configuration details:" +echo " - Repository name: snapshots_repository (from Migrate16xToCurrentVersionIT.java's getEsSnapshotRepo())" +echo " - Snapshot name: snapshot_3 (from Migrate16xToCurrentVersionIT.java's ES_SNAPSHOT_3 constant)" +echo " - Repository location: snapshots (relative to path.repo, from create_snapshots_repository.json)" +echo " - path.repo: /usr/share/elasticsearch/snapshots_repository (this standalone tool's own setting;" +echo " the Maven IT itself uses /tmp/snapshots_repository, see itests/pom.xml)" +echo " - Elasticsearch version: 9.1.3 (this standalone tool's own pin in docker-compose-snapshot-analysis.yml;" +echo " the Maven IT uses elasticsearch.test.version from the root pom.xml)" +echo " - Snapshot extraction: matches itests/pom.xml's unzip task (unzip to target/snapshots_repository)" +echo "" + diff --git a/pom.xml b/pom.xml index 0f98cbbfd..f0e709ae9 100644 --- a/pom.xml +++ b/pom.xml @@ -172,6 +172,7 @@ v1.22.19 -Papache-release,integration-tests + false @@ -448,6 +449,25 @@ + + unit-tests + + true + + + false + + + + skip-unit-tests + + false + + + true + + + integration-tests @@ -997,6 +1017,13 @@ apache-rat-plugin ${apache-rat.plugin.version} + + org.apache.maven.plugins + maven-surefire-plugin + + ${unomi.skip.unit.tests} + + external.atlassian.jgitflow jgitflow-maven-plugin diff --git a/rest/postman-readme.md b/rest/postman-readme.md new file mode 100644 index 000000000..35391d932 --- /dev/null +++ b/rest/postman-readme.md @@ -0,0 +1,277 @@ + +# Apache Unomi Postman Collection + +This Postman collection provides comprehensive examples for getting started with Apache Unomi REST API. + +## Setup Instructions + +### 1. Import the Collection + +1. Open Postman +2. Click **Import** button +3. Select the `unomi-postman-collection.json` file +4. The collection will appear in your Postman workspace + +### 2. Configure Environment Variables + +The collection uses several variables that you need to configure: + +#### Required Variables + +1. **baseUrl**: Your Unomi server URL + - Default: `http://localhost:8181` + - Update if your server is running on a different host/port + +2. **scope**: The scope identifier for your tenant + - Default: `example` + - This should match your tenant's scope + +3. **publicApiKey**: Your tenant's public API key + - Get this from your tenant after creating it + - Used for public API endpoints (`/context.json`, `/eventcollector`) + - Sent via `X-Unomi-Api-Key` header + +4. **privateApiKey**: Your tenant's private API key + - Get this from your tenant after creating it + - Used for private API endpoints (segments, rules, profiles, scopes, etc.) + - Used with `tenantId` for Basic Auth (tenantId:privateApiKey) + +5. **tenantId**: Your tenant ID + - Default: `mytenant` + - Used with `privateApiKey` for Basic Auth on private endpoints + - Update this to match your actual tenant ID + +6. **adminUsername**: Admin username for administrative endpoints + - Default: `karaf` + - Used only for tenant management endpoints (`/cxs/tenants`) + +7. **adminPassword**: Admin password for administrative endpoints + - Default: `karaf` + - Used only for tenant management endpoints (`/cxs/tenants`) + +8. **healthUsername**: Health check username + - Default: `health` + - Used for health check endpoint + +9. **healthPassword**: Health check password + - Default: `health` + - Used for health check endpoint + +#### Optional Variables (with defaults) + +- **sessionId**: `session-123` (used for context requests) +- **profileId**: `profile-123` (used for profile operations) +- **segmentId**: `premium-users` (used for segment operations) +- **ruleId**: `set-premium-on-purchase` (used for rule operations) +- **scopeId**: `example` (used for scope management operations) + +### 3. Setting Up Variables in Postman + +#### Option 1: Collection Variables (Recommended) +1. Right-click the collection → **Edit** +2. Go to **Variables** tab +3. Update the values for your environment +4. **No base64 encoding needed!** Postman automatically handles Basic Auth encoding + +#### Option 2: Environment Variables +1. Create a new Environment in Postman +2. Add all the variables listed above +3. Select the environment before making requests + +## Collection Structure + +The collection is organized in a logical demo flow order with 12 main folders: + +### 1. Health Check +- Basic health check endpoint to verify server status +- **Authentication**: `healthUsername:healthPassword` (Basic Auth) + +### 2. Tenant Management +- Create, list, update, delete tenants +- Generate API keys (PUBLIC and PRIVATE) +- **Authentication**: `adminUsername:adminPassword` (Basic Auth) +- **Important**: Save both PUBLIC and PRIVATE API keys from the create tenant response! + +### 3. Scope Management +- **List All Scopes**: Retrieve all scopes in the system +- **Get Scope by ID**: Retrieve a specific scope by its identifier +- **Create Scope**: Create a new scope with basic metadata +- **Create Scope with Tags**: Create a scope with tags and additional metadata +- **Update Scope**: Update an existing scope (use POST with same itemId) +- **Delete Scope**: Delete a scope by ID +- **Authentication**: `tenantId:privateApiKey` (Basic Auth) + +### 4. Event Schema Creation +- Create JSON schemas for custom events +- Validate events against schemas +- List and manage schemas +- **Authentication**: `tenantId:privateApiKey` (Basic Auth) + +### 5. Basic Context Requests +- Minimal context requests +- Context with source information +- Context with all properties +- Context with specific properties +- **Authentication**: `X-Unomi-Api-Key` header with `publicApiKey` + +### 6. Segment Creation +- Simple segments (single condition) +- Complex segments (multiple conditions) +- Segments based on past events +- Nested segment conditions +- Segment management operations +- **Authentication**: `tenantId:privateApiKey` (Basic Auth) + +### 7. Context Requests with Events +- Context requests with view events +- Context requests with custom events +- Event collector for multiple events +- **Authentication**: `X-Unomi-Api-Key` header with `publicApiKey` + +### 8. Personalization +- Simple personalization (matching-first strategy) +- Score-based personalization (score-sorted strategy) +- **Authentication**: `X-Unomi-Api-Key` header with `publicApiKey` + +### 9. Rule Management +- Create rules with conditions and actions +- List and get rules +- View rule statistics +- Delete rules +- **Authentication**: `tenantId:privateApiKey` (Basic Auth) + +### 11. Profile Management +- Get, create, update, delete profiles +- Search profiles +- Get profile segments and sessions +- **Authentication**: `tenantId:privateApiKey` (Basic Auth) + +### 10. Explain Context Request +- Context requests with explain mode enabled +- Returns detailed tracing information +- **Authentication**: `X-Unomi-Api-Key` header with `publicApiKey` + +### 12. Additional Useful Requests +- Event type management +- Event search +- Condition and action definitions +- Property types +- **Authentication**: `tenantId:privateApiKey` (Basic Auth) + +## Quick Start Guide + +### Step 1: Check Server Health +1. Go to **1. Health Check** → **Health Check** +2. Update `healthAuth` variable if needed +3. Send the request +4. Verify all components are LIVE + +### Step 2: Create a Tenant +1. Go to **2. Tenant Management** → **Create Tenant** +2. Update the request body with your tenant details (or use the default `{{tenantId}}`) +3. Send the request +4. **IMPORTANT**: Save both API keys from the response: + - Find the API key with `"type": "PUBLIC"` → Update `publicApiKey` variable + - Find the API key with `"type": "PRIVATE"` → Update `privateApiKey` variable +5. Update `tenantId` variable to match your actual tenant ID + +### Step 3: Create a Scope (if needed) +1. Go to **3. Scope Management** → **Create Scope** +2. Update the request body with your scope ID and metadata +3. Send the request + +### Step 4: Create a Simple Segment +1. Go to **6. Segment Creation** → **Simple Segment - Profile Property** +2. Update the `scope` variable if needed +3. Send the request (uses `tenantId:privateApiKey` authentication) + +### Step 5: Make a Basic Context Request +1. Go to **5. Basic Context Requests** → **Get Context (Minimal)** +2. Update `sessionId` if needed +3. Send the request (uses `X-Unomi-Api-Key` header with `publicApiKey`) +4. Note the `profileId` and `sessionId` in the response + +### Step 6: Send an Event +1. Go to **7. Context Requests with Events** → **Context with View Event** +2. Update `sessionId` and `scope` variables +3. Send the request (uses `X-Unomi-Api-Key` header with `publicApiKey`) + +## Common Use Cases + +### Creating a Custom Event Type + +1. **Create Event Schema** (4. Event Schema Creation → Create Event Schema) +2. **Validate Event** (4. Event Schema Creation → Validate Event) +3. **Send Custom Event** (7. Context Requests with Events → Context with Custom Event) + +### Setting Up Personalization + +1. **Create Segments** (6. Segment Creation) +2. **Create Rules** (9. Rule Management) - Optional, to automatically add users to segments +3. **Request Personalization** (8. Personalization) + +### Debugging with Explain Mode + +1. **Enable Explain** (10. Explain Context Request → Context Request with Explain) +2. Review the `requestTracing` object in the response +3. Check rule execution, segment evaluation, and personalization decisions + +## Tips + +1. **Start Simple**: Begin with basic context requests before moving to complex segments and rules +2. **Use Explain Mode**: When debugging, use explain mode to understand why rules/segments match or don't match +3. **Save Responses**: Save important responses (like tenant creation) to retrieve API keys +4. **Update Variables**: Keep your variables up to date, especially `sessionId` and `profileId` as you work +5. **Check Health**: Regularly check the health endpoint to ensure all components are running + +## Troubleshooting + +### 401 Unauthorized +- **Public endpoints** (`/context.json`, `/eventcollector`): Check that `publicApiKey` is set correctly +- **Private endpoints** (segments, rules, profiles, scopes): Check that both `tenantId` and `privateApiKey` are set correctly +- **Admin endpoints** (`/cxs/tenants`): Verify `adminUsername` and `adminPassword` are correct +- **Health endpoint**: Verify `healthUsername` and `healthPassword` are correct + +### 404 Not Found +- Verify the endpoint URL is correct +- Check that the resource (tenant, segment, rule) exists +- Ensure you're using the correct scope + +### 400 Bad Request +- Validate your JSON payload structure +- Check that required fields are present +- For custom events, ensure the schema exists and matches the event type + +### Schema Validation Errors +- Ensure event schema name matches the `eventType` in your events +- Check that all required properties are present +- Verify property types match the schema definition + +## Additional Resources + +- [Apache Unomi Documentation](https://unomi.apache.org/) +- [Unomi API Reference](https://unomi.apache.org/unomi-api/) +- [Unomi Manual](https://unomi.apache.org/manual/) + +## Notes + +- All timestamps in Unomi are in ISO 8601 format +- Session IDs and Profile IDs are case-sensitive +- Scopes are used to isolate data between different tenants/applications +- Rules are executed automatically when matching events occur +- Segments are evaluated dynamically based on current profile state diff --git a/rest/unomi-postman-collection.json b/rest/unomi-postman-collection.json new file mode 100644 index 000000000..979e6bdcd --- /dev/null +++ b/rest/unomi-postman-collection.json @@ -0,0 +1,2638 @@ +{ + "info": { + "_postman_id": "unomi-api-collection", + "name": "Apache Unomi API Examples", + "description": "Comprehensive collection of Apache Unomi REST API examples for getting started", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "1. Health Check", + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/health/check", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "health", + "check" + ] + }, + "description": "Check the health status of Unomi server and its components (Karaf, ElasticSearch/OpenSearch, Unomi bundles, Persistence, Cluster)", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{healthUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{healthPassword}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{healthUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{healthPassword}}", + "type": "string" + } + ] + } + }, + { + "name": "2. Tenant Management", + "item": [ + { + "name": "List All Tenants", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/tenants", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "tenants" + ] + }, + "description": "Retrieve all tenants in the system. Requires administrator authentication.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Tenant by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/tenants/{{tenantId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "tenants", + "{{tenantId}}" + ] + }, + "description": "Retrieve a specific tenant by ID", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Create Tenant", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"requestedId\": \"{{tenantId}}\",\n \"properties\": {\n \"name\": \"My Company\",\n \"description\": \"My Company tenant\",\n \"address\": \"123 Main St\",\n \"country\": \"USA\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/tenants", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "tenants" + ] + }, + "description": "Create a new tenant. API keys (PUBLIC and PRIVATE) are automatically generated.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Tenant", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"itemId\": \"{{tenantId}}\",\n \"name\": \"My Company Updated\",\n \"description\": \"Updated description\",\n \"properties\": {\n \"address\": \"456 New St\",\n \"country\": \"USA\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/tenants/{{tenantId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "tenants", + "{{tenantId}}" + ] + }, + "description": "Update an existing tenant", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Generate API Key", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/tenants/{{tenantId}}/apikeys?type=PUBLIC&validityDays=365", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "tenants", + "{{tenantId}}", + "apikeys" + ], + "query": [ + { + "key": "type", + "value": "PUBLIC", + "description": "PUBLIC or PRIVATE" + }, + { + "key": "validityDays", + "value": "365", + "description": "Validity period in days (0 or null for no expiration)" + } + ] + }, + "description": "Generate a new API key for a tenant. Type can be PUBLIC or PRIVATE.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Validate API Key", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/tenants/{{tenantId}}/apikeys/validate?key={{apiKey}}&type=PUBLIC", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "tenants", + "{{tenantId}}", + "apikeys", + "validate" + ], + "query": [ + { + "key": "key", + "value": "{{apiKey}}", + "description": "The API key to validate" + }, + { + "key": "type", + "value": "PUBLIC", + "description": "PUBLIC or PRIVATE" + } + ] + }, + "description": "Validate an API key for a tenant. Returns 200 OK if valid, 401 Unauthorized if invalid.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Tenant", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/tenants/{{tenantId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "tenants", + "{{tenantId}}" + ] + }, + "description": "Delete a tenant", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + { + "name": "3. Scope Management", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + }, + "item": [ + { + "name": "List All Scopes", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/scopes", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "scopes" + ] + }, + "description": "Retrieve all scopes in the system", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Scope by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/scopes/{{scopeId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "scopes", + "{{scopeId}}" + ] + }, + "description": "Retrieve a specific scope by ID", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Create Scope", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"itemId\": \"example\",\n \"itemType\": \"scope\",\n \"metadata\": {\n \"id\": \"example\",\n \"name\": \"Example Scope\",\n \"description\": \"An example scope for testing\",\n \"scope\": \"systemscope\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/scopes", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "scopes" + ] + }, + "description": "Create a new scope. The itemId and metadata.id should match.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Create Scope with Tags", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"itemId\": \"example-tagged\",\n \"itemType\": \"scope\",\n \"metadata\": {\n \"id\": \"example-tagged\",\n \"name\": \"Tagged Example Scope\",\n \"description\": \"A scope with tags for organization\",\n \"scope\": \"systemscope\",\n \"tags\": [\"production\", \"ecommerce\"],\n \"enabled\": true\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/scopes", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "scopes" + ] + }, + "description": "Create a scope with tags and additional metadata", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Scope", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"itemId\": \"{{scopeId}}\",\n \"itemType\": \"scope\",\n \"metadata\": {\n \"id\": \"{{scopeId}}\",\n \"name\": \"Updated Scope Name\",\n \"description\": \"Updated description for the scope\",\n \"scope\": \"systemscope\",\n \"enabled\": true\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/scopes", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "scopes" + ] + }, + "description": "Update an existing scope. Use POST with the same itemId to update.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Scope", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/scopes/{{scopeId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "scopes", + "{{scopeId}}" + ] + }, + "description": "Delete a scope by ID", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "4. Event Schema Creation", + "item": [ + { + "name": "Create Event Schema", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"$id\": \"https://unomi.apache.org/schemas/json/events/contactInfoSubmitted/1-0-0\",\n \"$schema\": \"https://json-schema.org/draft/2019-09/schema\",\n \"self\": {\n \"vendor\": \"org.apache.unomi\",\n \"name\": \"contactInfoSubmitted\",\n \"format\": \"jsonschema\",\n \"target\": \"events\",\n \"version\": \"1-0-0\"\n },\n \"title\": \"contactInfoSubmittedEvent\",\n \"type\": \"object\",\n \"allOf\": [{ \"$ref\": \"https://unomi.apache.org/schemas/json/event/1-0-0\" }],\n \"properties\": {\n \"source\": {\n \"$ref\": \"https://unomi.apache.org/schemas/json/item/1-0-0\"\n },\n \"target\": {\n \"$ref\": \"https://unomi.apache.org/schemas/json/item/1-0-0\"\n },\n \"properties\": {\n \"type\": \"object\",\n \"properties\": {\n \"firstName\": {\n \"type\": [\"null\", \"string\"]\n },\n \"lastName\": {\n \"type\": [\"null\", \"string\"]\n },\n \"email\": {\n \"type\": [\"null\", \"string\"]\n }\n }\n }\n },\n \"unevaluatedProperties\": false\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/jsonSchema", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "jsonSchema" + ] + }, + "description": "Create a JSON schema for validating custom events. The schema name must match the eventType in events.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "List Installed Schemas", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/jsonSchema", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "jsonSchema" + ] + }, + "description": "Get list of all installed JSON schema IDs", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Schema by ID", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "\"https://unomi.apache.org/schemas/json/events/contactInfoSubmitted/1-0-0\"" + }, + "url": { + "raw": "{{baseUrl}}/cxs/jsonSchema/query", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "jsonSchema", + "query" + ] + }, + "description": "Retrieve a specific schema by its ID", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Validate Event", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"eventType\": \"contactInfoSubmitted\",\n \"scope\": \"{{scope}}\",\n \"source\": {\n \"itemId\": \"form\",\n \"itemType\": \"form\",\n \"scope\": \"{{scope}}\"\n },\n \"properties\": {\n \"firstName\": \"John\",\n \"lastName\": \"Doe\",\n \"email\": \"john.doe@example.com\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/jsonSchema/validateEvent", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "jsonSchema", + "validateEvent" + ] + }, + "description": "Validate an event against its schema. Returns validation errors if any.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Schema", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "\"https://unomi.apache.org/schemas/json/events/contactInfoSubmitted/1-0-0\"" + }, + "url": { + "raw": "{{baseUrl}}/cxs/jsonSchema/delete", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "jsonSchema", + "delete" + ] + }, + "description": "Delete a JSON schema by ID", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + { + "name": "5. Basic Context Requests", + "item": [ + { + "name": "Get Context (Minimal)", + "request": { + "method": "GET", + "header": [ + { + "key": "X-Unomi-Api-Key", + "value": "{{publicApiKey}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseUrl}}/cxs/context.json?sessionId={{sessionId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "context.json" + ], + "query": [ + { + "key": "sessionId", + "value": "{{sessionId}}" + } + ] + }, + "description": "Get basic context with minimal information. Creates profile and session if they don't exist." + }, + "response": [] + }, + { + "name": "Get Context with Source", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Unomi-Api-Key", + "value": "{{publicApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"source\": {\n \"itemId\": \"homepage\",\n \"itemType\": \"page\",\n \"scope\": \"{{scope}}\"\n },\n \"sessionId\": \"{{sessionId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/context.json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "context.json" + ] + }, + "description": "Get context with source information about the page/site" + }, + "response": [] + }, + { + "name": "Get Context with All Properties", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Unomi-Api-Key", + "value": "{{publicApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"source\": {\n \"itemId\": \"homepage\",\n \"itemType\": \"page\",\n \"scope\": \"{{scope}}\"\n },\n \"sessionId\": \"{{sessionId}}\",\n \"requiredProfileProperties\": [\"*\"],\n \"requiredSessionProperties\": [\"*\"],\n \"requireSegments\": true,\n \"requireScores\": true\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/context.json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "context.json" + ] + }, + "description": "Get context with all profile properties, session properties, segments, and scores" + }, + "response": [] + }, + { + "name": "Get Context with Specific Properties", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Unomi-Api-Key", + "value": "{{publicApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"source\": {\n \"itemId\": \"homepage\",\n \"itemType\": \"page\",\n \"scope\": \"{{scope}}\"\n },\n \"sessionId\": \"{{sessionId}}\",\n \"requiredProfileProperties\": [\"properties.firstName\", \"properties.lastName\", \"properties.email\"],\n \"requiredSessionProperties\": [\"properties.pageViews\", \"properties.lastVisit\"],\n \"requireSegments\": true\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/context.json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "context.json" + ] + }, + "description": "Get context with only specific profile and session properties" + }, + "response": [] + } + ] + }, + { + "name": "6. Segment Creation", + "item": [ + { + "name": "Simple Segment - Profile Property", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"metadata\": {\n \"id\": \"premium-users\",\n \"name\": \"Premium Users\",\n \"scope\": \"{{scope}}\",\n \"description\": \"Users with premium subscription\"\n },\n \"condition\": {\n \"type\": \"profilePropertyCondition\",\n \"parameterValues\": {\n \"propertyName\": \"properties.subscriptionType\",\n \"comparisonOperator\": \"equals\",\n \"propertyValue\": \"premium\"\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/segments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments" + ] + }, + "description": "Create a simple segment based on a single profile property condition", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Segment - Multiple Conditions (AND)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"metadata\": {\n \"id\": \"active-premium-users\",\n \"name\": \"Active Premium Users\",\n \"scope\": \"{{scope}}\",\n \"description\": \"Premium users who are active\"\n },\n \"condition\": {\n \"type\": \"booleanCondition\",\n \"parameterValues\": {\n \"operator\": \"and\",\n \"subConditions\": [\n {\n \"type\": \"profilePropertyCondition\",\n \"parameterValues\": {\n \"propertyName\": \"properties.subscriptionType\",\n \"comparisonOperator\": \"equals\",\n \"propertyValue\": \"premium\"\n }\n },\n {\n \"type\": \"profilePropertyCondition\",\n \"parameterValues\": {\n \"propertyName\": \"properties.isActive\",\n \"comparisonOperator\": \"equals\",\n \"propertyValueBoolean\": true\n }\n }\n ]\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/segments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments" + ] + }, + "description": "Create a segment with multiple conditions using AND operator", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Segment - Age Range", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"metadata\": {\n \"id\": \"age-20-30\",\n \"name\": \"Age 20-30\",\n \"scope\": \"{{scope}}\",\n \"description\": \"Users between 20 and 30 years old\"\n },\n \"condition\": {\n \"type\": \"booleanCondition\",\n \"parameterValues\": {\n \"operator\": \"and\",\n \"subConditions\": [\n {\n \"type\": \"profilePropertyCondition\",\n \"parameterValues\": {\n \"propertyName\": \"properties.age\",\n \"comparisonOperator\": \"greaterThanOrEqualTo\",\n \"propertyValueInteger\": 20\n }\n },\n {\n \"type\": \"profilePropertyCondition\",\n \"parameterValues\": {\n \"propertyName\": \"properties.age\",\n \"comparisonOperator\": \"lessThan\",\n \"propertyValueInteger\": 30\n }\n }\n ]\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/segments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments" + ] + }, + "description": "Create a segment for users in a specific age range", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Segment - Past Events", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"metadata\": {\n \"id\": \"active-users\",\n \"name\": \"Active Users\",\n \"scope\": \"{{scope}}\",\n \"description\": \"Users who viewed pages in the last 30 days\"\n },\n \"condition\": {\n \"type\": \"pastEventCondition\",\n \"parameterValues\": {\n \"eventType\": \"view\",\n \"numberOfDays\": 30,\n \"minimumEventCount\": 1\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/segments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments" + ] + }, + "description": "Create a segment based on past events (users who performed an action in the last N days)", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Segment - Complex (Multiple Past Events)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"metadata\": {\n \"id\": \"engaged-customers\",\n \"name\": \"Engaged Customers\",\n \"scope\": \"{{scope}}\",\n \"description\": \"Users who viewed pages recently and made purchases\"\n },\n \"condition\": {\n \"type\": \"booleanCondition\",\n \"parameterValues\": {\n \"operator\": \"and\",\n \"subConditions\": [\n {\n \"type\": \"pastEventCondition\",\n \"parameterValues\": {\n \"eventType\": \"view\",\n \"numberOfDays\": 30,\n \"minimumEventCount\": 10\n }\n },\n {\n \"type\": \"pastEventCondition\",\n \"parameterValues\": {\n \"eventType\": \"purchase\",\n \"numberOfDays\": 90,\n \"minimumEventCount\": 1\n }\n }\n ]\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/segments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments" + ] + }, + "description": "Create a complex segment combining multiple past event conditions", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Segment - Nested Segment Condition", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"metadata\": {\n \"id\": \"vip-premium-users\",\n \"name\": \"VIP Premium Users\",\n \"scope\": \"{{scope}}\",\n \"description\": \"Users who are in premium segment and have VIP status\"\n },\n \"condition\": {\n \"type\": \"booleanCondition\",\n \"parameterValues\": {\n \"operator\": \"and\",\n \"subConditions\": [\n {\n \"type\": \"profileSegmentCondition\",\n \"parameterValues\": {\n \"segments\": [\"premium-users\"]\n }\n },\n {\n \"type\": \"profilePropertyCondition\",\n \"parameterValues\": {\n \"propertyName\": \"properties.vipStatus\",\n \"comparisonOperator\": \"equals\",\n \"propertyValue\": \"active\"\n }\n }\n ]\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/segments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments" + ] + }, + "description": "Create a segment that references another segment (nested segment condition)", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "List Segments", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/segments?offset=0&size=50", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments" + ], + "query": [ + { + "key": "offset", + "value": "0" + }, + { + "key": "size", + "value": "50" + } + ] + }, + "description": "List all segments with pagination", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Segment", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/segments/{{segmentId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments", + "{{segmentId}}" + ] + }, + "description": "Get a specific segment by ID", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Segment Match Count", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/segments/{{segmentId}}/count", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments", + "{{segmentId}}", + "count" + ] + }, + "description": "Get the count of profiles matching a segment", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Matching Profiles", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/segments/{{segmentId}}/match?offset=0&size=10", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments", + "{{segmentId}}", + "match" + ], + "query": [ + { + "key": "offset", + "value": "0" + }, + { + "key": "size", + "value": "10" + } + ] + }, + "description": "Get profiles matching a segment", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Segment", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/segments/{{segmentId}}?validate=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "segments", + "{{segmentId}}" + ], + "query": [ + { + "key": "validate", + "value": "true", + "description": "Check for dependencies before deleting" + } + ] + }, + "description": "Delete a segment. Set validate=true to check for dependencies.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + { + "name": "7. Context Requests with Events", + "item": [ + { + "name": "Context with View Event", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Unomi-Api-Key", + "value": "{{publicApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"source\": {\n \"itemId\": \"homepage\",\n \"itemType\": \"page\",\n \"scope\": \"{{scope}}\"\n },\n \"sessionId\": \"{{sessionId}}\",\n \"events\": [\n {\n \"eventType\": \"view\",\n \"scope\": \"{{scope}}\",\n \"source\": {\n \"itemType\": \"site\",\n \"scope\": \"{{scope}}\",\n \"itemId\": \"mysite\"\n },\n \"target\": {\n \"itemType\": \"page\",\n \"scope\": \"{{scope}}\",\n \"itemId\": \"homepage\",\n \"properties\": {\n \"pageInfo\": {\n \"referringURL\": \"https://www.google.com\",\n \"pagePath\": \"/homepage\",\n \"pageTitle\": \"Homepage\"\n }\n }\n }\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/context.json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "context.json" + ] + }, + "description": "Send a context request with a view event to track page views" + }, + "response": [] + }, + { + "name": "Context with Custom Event", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Unomi-Api-Key", + "value": "{{publicApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"source\": {\n \"itemId\": \"contact-form\",\n \"itemType\": \"form\",\n \"scope\": \"{{scope}}\"\n },\n \"sessionId\": \"{{sessionId}}\",\n \"events\": [\n {\n \"eventType\": \"contactInfoSubmitted\",\n \"scope\": \"{{scope}}\",\n \"source\": {\n \"itemType\": \"form\",\n \"scope\": \"{{scope}}\",\n \"itemId\": \"contact-form\"\n },\n \"properties\": {\n \"firstName\": \"John\",\n \"lastName\": \"Doe\",\n \"email\": \"john.doe@example.com\"\n }\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/context.json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "context.json" + ] + }, + "description": "Send a context request with a custom event (requires schema to be created first)" + }, + "response": [] + }, + { + "name": "Event Collector - Multiple Events", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Unomi-Api-Key", + "value": "{{publicApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sessionId\": \"{{sessionId}}\",\n \"events\": [\n {\n \"eventType\": \"view\",\n \"scope\": \"{{scope}}\",\n \"source\": {\n \"itemType\": \"site\",\n \"scope\": \"{{scope}}\",\n \"itemId\": \"mysite\"\n },\n \"target\": {\n \"itemType\": \"page\",\n \"scope\": \"{{scope}}\",\n \"itemId\": \"product-page\",\n \"properties\": {\n \"pageInfo\": {\n \"pagePath\": \"/products\",\n \"pageTitle\": \"Products\"\n }\n }\n }\n },\n {\n \"eventType\": \"click\",\n \"scope\": \"{{scope}}\",\n \"source\": {\n \"itemType\": \"button\",\n \"scope\": \"{{scope}}\",\n \"itemId\": \"add-to-cart\"\n },\n \"target\": {\n \"itemType\": \"product\",\n \"scope\": \"{{scope}}\",\n \"itemId\": \"product-123\"\n }\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/eventcollector", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "eventcollector" + ] + }, + "description": "Send multiple events using the event collector endpoint. This executes rules but doesn't return context." + }, + "response": [] + } + ] + }, + { + "name": "8. Personalization", + "item": [ + { + "name": "Context with Simple Personalization", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Unomi-Api-Key", + "value": "{{publicApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"source\": {\n \"itemId\": \"homepage\",\n \"itemType\": \"page\",\n \"scope\": \"{{scope}}\"\n },\n \"sessionId\": \"{{sessionId}}\",\n \"personalizations\": [\n {\n \"id\": \"homepage-hero\",\n \"strategy\": \"matching-first\",\n \"strategyOptions\": {\n \"fallback\": \"default-content\"\n },\n \"contents\": [\n {\n \"id\": \"premium-user-content\",\n \"filters\": [\n {\n \"condition\": {\n \"type\": \"profileSegmentCondition\",\n \"parameterValues\": {\n \"segments\": [\"premium-users\"]\n }\n }\n }\n ]\n },\n {\n \"id\": \"new-visitor-content\",\n \"filters\": [\n {\n \"condition\": {\n \"type\": \"sessionPropertyCondition\",\n \"parameterValues\": {\n \"propertyName\": \"duration\",\n \"comparisonOperator\": \"lessThan\",\n \"propertyValueInteger\": 300\n }\n }\n }\n ]\n },\n {\n \"id\": \"default-content\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/context.json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "context.json" + ] + }, + "description": "Request personalization with matching-first strategy. Returns the first matching content or fallback." + }, + "response": [] + }, + { + "name": "Context with Score-Based Personalization", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Unomi-Api-Key", + "value": "{{publicApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"source\": {\n \"itemId\": \"recommendations\",\n \"itemType\": \"page\",\n \"scope\": \"{{scope}}\"\n },\n \"sessionId\": \"{{sessionId}}\",\n \"personalizations\": [\n {\n \"id\": \"recommendations-by-interest\",\n \"strategy\": \"score-sorted\",\n \"strategyOptions\": {\n \"threshold\": -1\n },\n \"contents\": [\n {\n \"id\": \"content-1\",\n \"filters\": [\n {\n \"condition\": {\n \"type\": \"pastEventCondition\",\n \"parameterValues\": {\n \"minimumEventCount\": 1,\n \"eventCondition\": {\n \"type\": \"eventTypeCondition\",\n \"parameterValues\": {\n \"eventTypeId\": \"view\"\n }\n },\n \"numberOfDays\": 30\n }\n },\n \"properties\": {\n \"score\": 100\n }\n }\n ],\n \"properties\": {\n \"interests\": \"health food\"\n }\n },\n {\n \"id\": \"content-2\",\n \"filters\": [\n {\n \"condition\": {\n \"type\": \"pastEventCondition\",\n \"parameterValues\": {\n \"minimumEventCount\": 1,\n \"eventCondition\": {\n \"type\": \"eventTypeCondition\",\n \"parameterValues\": {\n \"eventTypeId\": \"view\"\n }\n },\n \"numberOfDays\": 30\n }\n },\n \"properties\": {\n \"score\": 50\n }\n }\n ]\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/context.json", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "context.json" + ] + }, + "description": "Request personalization with score-sorted strategy. Contents are sorted by their filter scores." + }, + "response": [] + } + ] + }, + { + "name": "9. Rule Management", + "item": [ + { + "name": "List All Rules", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/rules", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "rules" + ] + }, + "description": "Get metadata for all rules", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Rule", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/rules/{{ruleId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "rules", + "{{ruleId}}" + ] + }, + "description": "Get a specific rule by ID", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Create Rule - Add Profile Property", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"metadata\": {\n \"id\": \"set-premium-on-purchase\",\n \"name\": \"Set Premium on Purchase\",\n \"description\": \"Set premium flag when user makes a purchase\",\n \"scope\": \"{{scope}}\",\n \"enabled\": true\n },\n \"condition\": {\n \"type\": \"eventTypeCondition\",\n \"parameterValues\": {\n \"eventTypeId\": \"purchase\"\n }\n },\n \"actions\": [\n {\n \"type\": \"setPropertyAction\",\n \"parameterValues\": {\n \"setPropertyName\": \"properties.isPremium\",\n \"setPropertyValue\": true\n }\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/rules", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "rules" + ] + }, + "description": "Create a rule that sets a profile property when an event occurs", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Create Rule - Add to Segment", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"metadata\": {\n \"id\": \"add-to-premium-segment\",\n \"name\": \"Add to Premium Segment\",\n \"description\": \"Add profile to premium segment on purchase\",\n \"scope\": \"{{scope}}\",\n \"enabled\": true\n },\n \"condition\": {\n \"type\": \"booleanCondition\",\n \"parameterValues\": {\n \"operator\": \"and\",\n \"subConditions\": [\n {\n \"type\": \"eventTypeCondition\",\n \"parameterValues\": {\n \"eventTypeId\": \"purchase\"\n }\n },\n {\n \"type\": \"eventPropertyCondition\",\n \"parameterValues\": {\n \"propertyName\": \"properties.totalAmount\",\n \"comparisonOperator\": \"greaterThan\",\n \"propertyValueInteger\": 100\n }\n }\n ]\n }\n },\n \"actions\": [\n {\n \"type\": \"addToSegmentAction\",\n \"parameterValues\": {\n \"segmentId\": \"premium-users\"\n }\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/rules", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "rules" + ] + }, + "description": "Create a rule that adds a profile to a segment based on event conditions", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Rule Statistics", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/rules/statistics", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "rules", + "statistics" + ] + }, + "description": "Get statistics for all rules", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Rule Statistics by ID", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/rules/{{ruleId}}/statistics", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "rules", + "{{ruleId}}", + "statistics" + ] + }, + "description": "Get statistics for a specific rule", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Rule", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/rules/{{ruleId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "rules", + "{{ruleId}}" + ] + }, + "description": "Delete a rule", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + { + "name": "11. Profile Management", + "item": [ + { + "name": "Get Profile", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/profiles/{{profileId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "profiles", + "{{profileId}}" + ] + }, + "description": "Get a profile by ID", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Create/Update Profile", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"itemId\": \"{{profileId}}\",\n \"itemType\": \"profile\",\n \"properties\": {\n \"firstName\": \"John\",\n \"lastName\": \"Doe\",\n \"email\": \"john.doe@example.com\",\n \"age\": 28,\n \"subscriptionType\": \"premium\",\n \"isActive\": true\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/profiles", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "profiles" + ] + }, + "description": "Create or update a profile. Sends a profileUpdated event.", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Search Profiles", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"condition\": {\n \"type\": \"profilePropertyCondition\",\n \"parameterValues\": {\n \"propertyName\": \"properties.subscriptionType\",\n \"comparisonOperator\": \"equals\",\n \"propertyValue\": \"premium\"\n }\n },\n \"offset\": 0,\n \"limit\": 50,\n \"sortBy\": \"properties.lastName:asc\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/profiles/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "profiles", + "search" + ] + }, + "description": "Search profiles using a condition query", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Profile Count", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/profiles/count", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "profiles", + "count" + ] + }, + "description": "Get total count of profiles", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Profile Segments", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/profiles/{{profileId}}/segments", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "profiles", + "{{profileId}}", + "segments" + ] + }, + "description": "Get all segments that a profile belongs to", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Profile Sessions", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/profiles/{{profileId}}/sessions?offset=0&size=10", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "profiles", + "{{profileId}}", + "sessions" + ], + "query": [ + { + "key": "offset", + "value": "0" + }, + { + "key": "size", + "value": "10" + } + ] + }, + "description": "Get all sessions for a profile", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Profile", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/profiles/{{profileId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "profiles", + "{{profileId}}" + ] + }, + "description": "Delete a profile", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + }, + { + "name": "10. Explain Context Request", + "item": [ + { + "name": "Context Request with Explain", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "X-Unomi-Api-Key", + "value": "{{publicApiKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"source\": {\n \"itemId\": \"homepage\",\n \"itemType\": \"page\",\n \"scope\": \"{{scope}}\"\n },\n \"sessionId\": \"{{sessionId}}\",\n \"events\": [\n {\n \"eventType\": \"view\",\n \"scope\": \"{{scope}}\",\n \"source\": {\n \"itemType\": \"site\",\n \"scope\": \"{{scope}}\",\n \"itemId\": \"mysite\"\n },\n \"target\": {\n \"itemType\": \"page\",\n \"scope\": \"{{scope}}\",\n \"itemId\": \"homepage\"\n }\n }\n ],\n \"personalizations\": [\n {\n \"id\": \"homepage-hero\",\n \"strategy\": \"matching-first\",\n \"contents\": [\n {\n \"id\": \"premium-content\",\n \"filters\": [\n {\n \"condition\": {\n \"type\": \"profileSegmentCondition\",\n \"parameterValues\": {\n \"segments\": [\"premium-users\"]\n }\n }\n }\n ]\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/context.json?explain=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "context.json" + ], + "query": [ + { + "key": "explain", + "value": "true", + "description": "Enable detailed tracing of request processing" + } + ] + }, + "description": "Get context with explain mode enabled. Returns detailed tracing information about rule execution, segment evaluation, and personalization. Requires administrator authentication." + }, + "response": [] + } + ] + }, + { + "name": "12. Additional Useful Requests", + "item": [ + { + "name": "List Event Types", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/events/types", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "events", + "types" + ] + }, + "description": "Get list of all event type identifiers that the server has processed", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Search Events", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"condition\": {\n \"type\": \"eventTypeCondition\",\n \"parameterValues\": {\n \"eventTypeId\": \"view\"\n }\n },\n \"offset\": 0,\n \"limit\": 50,\n \"sortBy\": \"timeStamp:desc\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/cxs/events/search", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "events", + "search" + ] + }, + "description": "Search for events using a query condition", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Session Events", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/profiles/sessions/{{sessionId}}/events?eventTypes=view&eventTypes=click&offset=0&size=50", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "profiles", + "sessions", + "{{sessionId}}", + "events" + ], + "query": [ + { + "key": "eventTypes", + "value": "view" + }, + { + "key": "eventTypes", + "value": "click" + }, + { + "key": "offset", + "value": "0" + }, + { + "key": "size", + "value": "50" + } + ] + }, + "description": "Get events for a specific session, optionally filtered by event types", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Condition Definitions", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/definitions/conditions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "definitions", + "conditions" + ] + }, + "description": "Get all available condition type definitions", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Action Definitions", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/definitions/actions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "definitions", + "actions" + ] + }, + "description": "Get all available action type definitions", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Property Types", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/cxs/profiles/properties", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cxs", + "profiles", + "properties" + ] + }, + "description": "Get all known property types organized by target", + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{tenantId}}", + "type": "string" + }, + { + "key": "password", + "value": "{{privateApiKey}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{adminUsername}}", + "type": "string" + }, + { + "key": "password", + "value": "{{adminPassword}}", + "type": "string" + } + ] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8181", + "type": "string" + }, + { + "key": "scope", + "value": "example", + "type": "string" + }, + { + "key": "scopeId", + "value": "example", + "type": "string", + "description": "Scope ID for scope management operations" + }, + { + "key": "sessionId", + "value": "session-123", + "type": "string" + }, + { + "key": "profileId", + "value": "profile-123", + "type": "string" + }, + { + "key": "tenantId", + "value": "mytenant", + "type": "string" + }, + { + "key": "segmentId", + "value": "premium-users", + "type": "string" + }, + { + "key": "ruleId", + "value": "set-premium-on-purchase", + "type": "string" + }, + { + "key": "apiKey", + "value": "YOUR_API_KEY", + "type": "string", + "description": "API key to validate (used in Validate API Key request)" + }, + { + "key": "publicApiKey", + "value": "YOUR_PUBLIC_API_KEY", + "type": "string", + "description": "Public API key from tenant" + }, + { + "key": "privateApiKey", + "value": "YOUR_PRIVATE_API_KEY", + "type": "string", + "description": "Private API key from tenant (used with tenantId for Basic Auth)" + }, + { + "key": "adminUsername", + "value": "karaf", + "type": "string", + "description": "Admin username for administrative endpoints" + }, + { + "key": "adminPassword", + "value": "karaf", + "type": "string", + "description": "Admin password for administrative endpoints" + }, + { + "key": "healthUsername", + "value": "health", + "type": "string", + "description": "Health check username" + }, + { + "key": "healthPassword", + "value": "health", + "type": "string", + "description": "Health check password" + } + ] +} \ No newline at end of file diff --git a/setup-elasticsearch.sh b/setup-elasticsearch.sh new file mode 100755 index 000000000..8c103668f --- /dev/null +++ b/setup-elasticsearch.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +################################################################################ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +################################################################################ + +# Main function - wraps all logic so we can use 'return' when sourced +_setup_elasticsearch() { + # Get the directory where this script is located + # Support both bash and zsh + if [ -n "${ZSH_VERSION}" ]; then + SCRIPT_DIR="$(cd "$(dirname "${(%):-%x}")" && pwd)" + else + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + fi + + # Source shared utilities + if [ -f "${SCRIPT_DIR}/setup-utils.sh" ]; then + source "${SCRIPT_DIR}/setup-utils.sh" || { echo "ERROR: Failed to source setup-utils.sh" >&2; return 1; } + else + echo "ERROR: setup-utils.sh not found in ${SCRIPT_DIR}" >&2 + return 1 + fi + + # Check if script is being sourced + # Support both bash and zsh + if [ -n "${ZSH_VERSION}" ]; then + if ! check_sourced "${(%):-%x}"; then + return 1 + fi + else + if ! check_sourced "${BASH_SOURCE[0]}"; then + return 1 + fi + fi + + # Clear OpenSearch environment variables first. This must succeed: leaving both + # engines' variables set is exactly the conflicting state build.sh's + # check_integration_test_env_vars is designed to detect and reject. + if ! clear_opposite "${SCRIPT_DIR}" "opensearch"; then + echo "ERROR: Failed to clear OpenSearch variables" >&2 + return 1 + fi + + # Load only the Elasticsearch password from .env.local if it exists + # This ensures we don't load the OpenSearch password + if ! load_password_from_env_local "${SCRIPT_DIR}" "UNOMI_ELASTICSEARCH_PASSWORD"; then + # If not found in .env.local, check if it's already set in environment + if [ -z "${UNOMI_ELASTICSEARCH_PASSWORD}" ]; then + echo "Note: UNOMI_ELASTICSEARCH_PASSWORD not found in .env.local or environment" + fi + fi + + # Check if password is set + if ! check_password "UNOMI_ELASTICSEARCH_PASSWORD"; then + return 1 + fi + + # Set Elasticsearch 9 environment variables + # Note: Unomi defaults are not appropriate for ES 9, so we override multiple settings + export UNOMI_ELASTICSEARCH_CLUSTERNAME=docker-cluster + export UNOMI_ELASTICSEARCH_USERNAME=elastic + export UNOMI_ELASTICSEARCH_SSL_ENABLE=true + export UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES=true + # Password is already set from .env.local or environment + + # Set the distribution to use Elasticsearch (default, but explicit for clarity) + export UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch + + echo "Elasticsearch 9 environment variables configured." + echo " Distribution: ${UNOMI_DISTRIBUTION}" + echo " Cluster: ${UNOMI_ELASTICSEARCH_CLUSTERNAME} (overridden from default: contextElasticSearch)" + echo " Username: ${UNOMI_ELASTICSEARCH_USERNAME} (overridden from default: empty)" + echo " SSL Enabled: ${UNOMI_ELASTICSEARCH_SSL_ENABLE} (overridden from default: false)" + echo " Trust All Certificates: ${UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES} (overridden from default: false)" + + return 0 +} + +# Determine if script is being sourced +# This MUST be done at the top level before any function calls +_IS_SOURCED=false +if [ -n "${ZSH_VERSION}" ]; then + # In zsh, use funcstack - it has entries when sourced, empty when executed + # This is the most reliable method and works even when nested + if [ ${#funcstack[@]} -gt 0 ]; then + _IS_SOURCED=true + fi +else + # In bash, check BASH_SOURCE + # When sourced: BASH_SOURCE[0] != $0 + # When executed: BASH_SOURCE[0] == $0 + if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "${0}" ]; then + _IS_SOURCED=true + fi +fi + +# Call the main function +# CRITICAL: NEVER use exit when sourced - it will close the shell +if [ "${_IS_SOURCED}" = "true" ]; then + # Script is being sourced - call function and handle errors gracefully + # Do NOT use || exit here - it will close the shell! + # Just call the function - if it fails, it will return 1 but won't exit + _setup_elasticsearch + _SETUP_EXIT_CODE=$? + # Clean up temporary variables + unset _IS_SOURCED _SETUP_EXIT_CODE + # Don't do anything with the exit code - just let it be + # The function already printed error messages if it failed +else + # Script is being executed directly - safe to use exit + _setup_elasticsearch || exit 1 +fi diff --git a/setup-opensearch.sh b/setup-opensearch.sh new file mode 100755 index 000000000..565dcc4e9 --- /dev/null +++ b/setup-opensearch.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +################################################################################ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +################################################################################ + +# Main function - wraps all logic so we can use 'return' when sourced +_setup_opensearch() { + # Get the directory where this script is located + # Support both bash and zsh + if [ -n "${ZSH_VERSION}" ]; then + SCRIPT_DIR="$(cd "$(dirname "${(%):-%x}")" && pwd)" + else + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + fi + + # Source shared utilities + if [ -f "${SCRIPT_DIR}/setup-utils.sh" ]; then + source "${SCRIPT_DIR}/setup-utils.sh" || { echo "ERROR: Failed to source setup-utils.sh" >&2; return 1; } + else + echo "ERROR: setup-utils.sh not found in ${SCRIPT_DIR}" >&2 + return 1 + fi + + # Check if script is being sourced + # Support both bash and zsh + if [ -n "${ZSH_VERSION}" ]; then + if ! check_sourced "${(%):-%x}"; then + return 1 + fi + else + if ! check_sourced "${BASH_SOURCE[0]}"; then + return 1 + fi + fi + + # Clear Elasticsearch environment variables first. This must succeed: leaving both + # engines' variables set is exactly the conflicting state build.sh's + # check_integration_test_env_vars is designed to detect and reject. + if ! clear_opposite "${SCRIPT_DIR}" "elasticsearch"; then + echo "ERROR: Failed to clear Elasticsearch variables" >&2 + return 1 + fi + + # Load only the OpenSearch password from .env.local if it exists + # This ensures we don't load the Elasticsearch password + if ! load_password_from_env_local "${SCRIPT_DIR}" "UNOMI_OPENSEARCH_PASSWORD"; then + # If not found in .env.local, check if it's already set in environment + if [ -z "${UNOMI_OPENSEARCH_PASSWORD}" ]; then + echo "Note: UNOMI_OPENSEARCH_PASSWORD not found in .env.local or environment" + fi + fi + + # Check if password is set + if ! check_password "UNOMI_OPENSEARCH_PASSWORD"; then + return 1 + fi + + # Set OpenSearch 3 password (only override needed - defaults are appropriate for OpenSearch 3) + # Password is already set from .env.local or environment, just ensure it's exported + export UNOMI_OPENSEARCH_PASSWORD + + # Set the distribution to use OpenSearch + export UNOMI_DISTRIBUTION=unomi-distribution-opensearch + + echo "OpenSearch 3 environment variables configured." + echo " Distribution: ${UNOMI_DISTRIBUTION}" + echo " Password: (set from .env.local or environment)" + echo " Note: Using Unomi defaults for other OpenSearch settings (cluster, addresses, username, SSL)" + + return 0 +} + +# Determine if script is being sourced +# This MUST be done at the top level before any function calls +_IS_SOURCED=false +if [ -n "${ZSH_VERSION}" ]; then + # In zsh, use funcstack - it has entries when sourced, empty when executed + # This is the most reliable method and works even when nested + if [ ${#funcstack[@]} -gt 0 ]; then + _IS_SOURCED=true + fi +else + # In bash, check BASH_SOURCE + # When sourced: BASH_SOURCE[0] != $0 + # When executed: BASH_SOURCE[0] == $0 + if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "${0}" ]; then + _IS_SOURCED=true + fi +fi + +# Call the main function +# CRITICAL: NEVER use exit when sourced - it will close the shell +if [ "${_IS_SOURCED}" = "true" ]; then + # Script is being sourced - call function and handle errors gracefully + # Do NOT use || exit here - it will close the shell! + # Just call the function - if it fails, it will return 1 but won't exit + _setup_opensearch + _SETUP_EXIT_CODE=$? + # Clean up temporary variables + unset _IS_SOURCED _SETUP_EXIT_CODE + # Don't do anything with the exit code - just let it be + # The function already printed error messages if it failed +else + # Script is being executed directly - safe to use exit + _setup_opensearch || exit 1 +fi + diff --git a/setup-utils.sh b/setup-utils.sh new file mode 100755 index 000000000..6d8f04338 --- /dev/null +++ b/setup-utils.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +################################################################################ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# +################################################################################ + +# Check if script is being sourced or executed directly +# Both setup and clear scripts must be sourced to modify the current shell's environment +# Usage: check_sourced CALLING_SCRIPT_PATH +# Returns: 0 if sourced, 1 if executed directly (and shows warning) +# Note: When called from a function in a sourced script, use 'return' not 'exit' to avoid closing the shell +check_sourced() { + local calling_script="$1" + local is_sourced=false + + # Support both bash and zsh + if [ -n "${ZSH_VERSION}" ]; then + # In zsh, use funcstack - it has entries when sourced, empty when executed + # This is the most reliable method and works even when nested or inside functions + if [ ${#funcstack[@]} -gt 0 ]; then + is_sourced=true + fi + else + # BASH_SOURCE[1] is the script that sourced this utils script (the calling script) + # When the calling script is sourced: $0 is the parent shell (starts with "-" like "-zsh" or "-bash") + # When the calling script is executed: $0 is the script path itself + # So if BASH_SOURCE[1] != $0, the calling script was sourced + if [ -n "${BASH_SOURCE[1]}" ] && [ "${BASH_SOURCE[1]}" != "${0}" ]; then + is_sourced=true + fi + fi + + # If script was executed directly, show warning and return error (NEVER exit here) + if [ "${is_sourced}" = false ]; then + echo "WARNING: This script must be sourced to set environment variables in your shell." >&2 + echo "" >&2 + echo "Usage:" >&2 + echo " source ${calling_script}" >&2 + echo " # or" >&2 + echo " . ${calling_script}" >&2 + echo "" >&2 + echo "Note: Both setup and clear scripts must be sourced because they modify" >&2 + echo "environment variables (export/unset) which only work in the current shell." >&2 + return 1 + fi + return 0 +} + +# Load .env.local file if it exists +# Usage: load_env_local SCRIPT_DIR +load_env_local() { + local script_dir="$1" + local env_local="${script_dir}/.env.local" + + if [ -f "${env_local}" ]; then + echo "Loading passwords from .env.local" + source "${env_local}" + fi +} + +# Load a specific password variable from .env.local file if it exists +# This function only loads the specified variable, not the entire file +# Usage: load_password_from_env_local SCRIPT_DIR PASSWORD_VAR_NAME +load_password_from_env_local() { + local script_dir="$1" + local password_var="$2" + local env_local="${script_dir}/.env.local" + + if [ -f "${env_local}" ]; then + # Use grep to find the line with the password variable + # Match lines that start with optional whitespace, 'export', one or more whitespace, variable name, '=' + local matching_line + matching_line=$(grep -E "^[[:space:]]*export[[:space:]]+${password_var}=" "${env_local}" 2>/dev/null | head -n 1) + + if [ -n "${matching_line}" ]; then + # Extract just the variable assignment part (VAR=value) + # This handles cases where the line might have comments or extra whitespace + local var_assignment + # Remove 'export' keyword and trim whitespace + var_assignment="${matching_line#export}" + var_assignment="${var_assignment#"${var_assignment%%[![:space:]]*}"}" + # Remove any trailing comments (everything after #) + var_assignment="${var_assignment%%#*}" + # Trim trailing whitespace + var_assignment="${var_assignment%"${var_assignment##*[![:space:]]}"}" + + # Export the variable by evaluating the assignment + # This is safe because we've already validated the variable name matches + if ! eval "export ${var_assignment}"; then + echo "ERROR: Failed to parse ${password_var} assignment in .env.local" >&2 + return 1 + fi + echo "Loaded ${password_var} from .env.local" + return 0 + fi + fi + return 1 +} + +# Check if password environment variable is set +# Usage: check_password PASSWORD_VAR_NAME +# Returns: 0 if password is set, 1 if not set +# Note: When called from a sourced script, use 'return' not 'exit' to avoid closing the shell +check_password() { + local password_var="$1" + # Support both bash and zsh indirect variable expansion + if [ -n "${ZSH_VERSION}" ]; then + local password_value="${(P)password_var}" + else + local password_value="${!password_var}" + fi + + if [ -z "${password_value}" ]; then + echo "ERROR: ${password_var} is not set." + echo "" + echo "To set the password, create a .env.local file in the project root with:" + echo " export ${password_var}=your_password_here" + echo "" + echo "Example:" + echo " echo 'export ${password_var}=your_password' > .env.local" + echo "" + echo "Note: .env.local is gitignored and should not be committed." + return 1 + fi + return 0 +} + +# Clear the opposite search engine's environment variables +# Usage: clear_opposite SCRIPT_DIR OPPOSITE_TYPE +# OPPOSITE_TYPE should be "opensearch" or "elasticsearch" +clear_opposite() { + local script_dir="$1" + local opposite_type="$2" + local clear_script="${script_dir}/clear-${opposite_type}.sh" + + if [ -f "${clear_script}" ]; then + source "${clear_script}" + else + echo "ERROR: clear-${opposite_type}.sh not found, cannot clear ${opposite_type} variables" >&2 + return 1 + fi +} + diff --git a/tools/shell-commands/src/main/java/org/apache/unomi/shell/actions/Setup.java b/tools/shell-commands/src/main/java/org/apache/unomi/shell/actions/Setup.java index 9d4c3acd8..19af2a6fb 100644 --- a/tools/shell-commands/src/main/java/org/apache/unomi/shell/actions/Setup.java +++ b/tools/shell-commands/src/main/java/org/apache/unomi/shell/actions/Setup.java @@ -18,9 +18,11 @@ import org.apache.karaf.shell.api.action.Action; import org.apache.karaf.shell.api.action.Command; +import org.apache.karaf.shell.api.action.Completion; import org.apache.karaf.shell.api.action.Option; import org.apache.karaf.shell.api.action.lifecycle.Reference; import org.apache.karaf.shell.api.action.lifecycle.Service; +import org.apache.unomi.shell.completers.DistributionCompleter; import org.apache.unomi.shell.services.UnomiManagementService; @Command(scope = "unomi", name = "setup", description = "This will setup some Apache Unomi runtime options") @@ -31,12 +33,26 @@ public class Setup implements Action { UnomiManagementService unomiManagementService; @Option(name = "-d", aliases = "--distribution", description = "Unomi Distribution feature to configure", required = true, multiValued = false) + @Completion(DistributionCompleter.class) String distribution = "unomi-distribution-elasticsearch"; @Option(name = "-f", aliases = "--force", description = "Force setting up distribution feature name even if already exists (use with caution)", required = false, multiValued = false) boolean force = false; + @Option(name = "-s", aliases = "--show", description = "Show the currently configured distribution", required = false, multiValued = false) + boolean show = false; + public Object execute() throws Exception { + if (show) { + String currentDistribution = unomiManagementService.getCurrentDistribution(); + if (currentDistribution != null) { + System.out.println("Currently configured distribution: " + currentDistribution); + } else { + System.out.println("No distribution is currently configured."); + } + return null; + } + System.out.println("Setting up Apache Unomi distribution: " + distribution); unomiManagementService.setupUnomiDistribution(distribution, force); return null; diff --git a/tools/shell-commands/src/main/java/org/apache/unomi/shell/completers/DistributionCompleter.java b/tools/shell-commands/src/main/java/org/apache/unomi/shell/completers/DistributionCompleter.java new file mode 100644 index 000000000..71a862450 --- /dev/null +++ b/tools/shell-commands/src/main/java/org/apache/unomi/shell/completers/DistributionCompleter.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.unomi.shell.completers; + +import org.apache.karaf.features.Feature; +import org.apache.karaf.features.FeaturesService; +import org.apache.karaf.features.Repository; +import org.apache.karaf.shell.api.action.lifecycle.Reference; +import org.apache.karaf.shell.api.action.lifecycle.Service; +import org.apache.karaf.shell.api.console.CommandLine; +import org.apache.karaf.shell.api.console.Completer; +import org.apache.karaf.shell.api.console.Session; +import org.apache.karaf.shell.support.completers.StringsCompleter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.List; + +@Service +public class DistributionCompleter implements Completer { + + private static final Logger LOGGER = LoggerFactory.getLogger(DistributionCompleter.class); + + @Reference + private FeaturesService featuresService; + + @Override + public int complete(Session session, CommandLine commandLine, List candidates) { + StringsCompleter delegate = new StringsCompleter(); + + try { + // Get all repositories and their features + Repository[] repositories = featuresService.listRepositories(); + for (Repository repository : repositories) { + try { + Feature[] features = repository.getFeatures(); + for (Feature feature : features) { + String featureName = feature.getName(); + // Filter features starting with unomi-distribution-* + if (featureName != null && featureName.startsWith("unomi-distribution-")) { + delegate.getStrings().add(featureName); + } + } + } catch (Exception e) { + // Skip repositories that can't be accessed; this is normal for some repositories + LOGGER.debug("Could not read features from repository {}", repository.getName(), e); + } + } + + // If no features found from repositories, try to get known distributions by name + if (delegate.getStrings().isEmpty()) { + String[] knownDistributions = { + "unomi-distribution-elasticsearch", + "unomi-distribution-opensearch" + }; + for (String dist : knownDistributions) { + try { + Feature feature = featuresService.getFeature(dist); + if (feature != null) { + delegate.getStrings().add(dist); + } + } catch (Exception e) { + // Feature doesn't exist, skip it + LOGGER.debug("Could not look up known distribution feature {}", dist, e); + } + } + } + } catch (Exception e) { + // If we can't list repositories, fall back to common distribution names + LOGGER.warn("Could not list feature repositories, falling back to default distribution names", e); + delegate.getStrings().add("unomi-distribution-elasticsearch"); + delegate.getStrings().add("unomi-distribution-opensearch"); + } + + return delegate.complete(session, commandLine, candidates); + } +}