You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by sh...@apache.org on 2026/05/14 05:49:02 UTC
(unomi) 01/01: UNOMI-139 UNOMI-880 UNOMI-878 UNOMI-884 UNOMI-904: multi-tenant core, unified cache, scheduler, migrations, V2 compat
This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch UNOMI-139-UNOMI-880-multitenancy
in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 57d71edaf7708ad3f4ee34825b482143e9e362e8
Author: Serge Huber <sh...@apache.org>
AuthorDate: Thu May 14 07:48:46 2026 +0200
UNOMI-139 UNOMI-880 UNOMI-878 UNOMI-884 UNOMI-904: multi-tenant core, unified cache, scheduler, migrations, V2 compat
Why one PR
These changes share the same execution model (tenant context, unified
cache, scheduler, and persistence) and are wired through the same
services, blueprints, and integration tests. Splitting them into separate
commits would produce artificial intermediate states that neither compile
nor pass ITs, and would force heavy merge or rebase work across the same
files repeatedly. They are therefore delivered together as one coherent
“3.1 platform slice” for review, even though the work maps to several JIRA
tickets.
This change set backports and wires the Unomi 3.1 multi-tenant platform work
into the integration branch: tenant-aware execution, centralized caching,
the new scheduler service, V3 migration assets, and optional V2 REST
compatibility. Tracing and full type-validation services from unomi-3-dev
are not included; call sites use ParserHelper / defensive handling where
those stacks were removed for a stacked follow-up branch.
UNOMI-139 Multi-tenancy
- Introduce tenant model and APIs (Tenant, TenantService, API keys,
security/audit interfaces, quotas, lifecycle hooks) and propagate
tenantId on Item and related types.
- Add ExecutionContextManager and thread-local tenant context so services,
persistence, and REST layers resolve the active tenant consistently.
- Adjust persistence (Elasticsearch/OpenSearch) and services to scope
reads/writes and inheritance (e.g. system tenant vs tenant overrides).
UNOMI-880 Unified multi-tenant caching
- Add MultiTypeCacheService / CacheableTypeConfig and
AbstractMultiTypeCachingService for shared cache lifecycle, bundle
predefined items, periodic refresh, and statistics.
- Migrate DefinitionsServiceImpl and SegmentServiceImpl (and related
paths) onto the unified cache; remove duplicated ad-hoc cache listeners
where superseded.
- Add Karaf shell cache commands (unomi:cache) and align tests with empty
stats / CSV output edge cases.
UNOMI-878 Cluster-aware task scheduling
- Replace legacy scheduler usage with the new SchedulerService API
(scheduled tasks, task executors, persistence-backed vs in-memory tasks).
- Add shell commands for task inspection and management; integrate periodic
work (e.g. cache refresh, rules refresh, purges) with the new scheduler
where backported from unomi-3-dev.
- Update dependents (e.g. Groovy actions service) to schedule work through
the new API instead of removed executor accessors.
UNOMI-884 Migration to V3
- Add migration Groovy scripts and request bodies for 3.1.0 (tenant
document IDs, system item id fixes, tenant initialization, legacy
queryBuilder updates, profile visit fixes as applicable).
- Extend migration configuration and tests (e.g. MigrationUtilsTest) to
cover new steps aligned with multi-tenancy and index updates.
UNOMI-904 V2 API compatibility mode
- Add REST authentication configuration (Metatype) for toggling V2
compatibility and choosing the default tenant when the mode is on.
- Implement AuthenticationFilter handling so legacy V2-style access
patterns can be enabled during migration without forcing every client
to API keys immediately.
Reliability and tests
- All integration tests (AllITs) pass in a green run; they remain flaky
locally—roughly one in four full-suite runs still fails with unrelated
timing or environment symptoms (shared Elasticsearch, indexing lag,
suite order). A follow-up should harden CI or add targeted waits where
needed; this commit does not claim zero flake rate.
- Align condition/action resolution with HEAD + selective unomi-3-dev
fixes (PropertyHelper for past-event numeric parameters, null
conditionType in evaluation, optional action type resolution in the
dispatcher, segment recalculation paths).
- Stabilize integration tests: wait for segment visibility after GraphQL
create, relax cache stats assertions when global stats are empty,
adjust invalid-segment test when numeric strings are coerced without
validation service.
Docs: https://issues.apache.org/jira/browse/UNOMI-139
https://issues.apache.org/jira/browse/UNOMI-880
https://issues.apache.org/jira/browse/UNOMI-878
https://issues.apache.org/jira/browse/UNOMI-884
https://issues.apache.org/jira/browse/UNOMI-904
---
.gitignore | 3 +
.vscode/settings.json | 3 +-
api/pom.xml | 10 +
.../java/org/apache/unomi/api/ContextRequest.java | 21 +
.../java/org/apache/unomi/api/ContextResponse.java | 2 -
api/src/main/java/org/apache/unomi/api/Event.java | 7 +-
.../apache/unomi/api/EventsCollectorRequest.java | 21 +
.../org/apache/unomi/api/ExecutionContext.java | 98 +
api/src/main/java/org/apache/unomi/api/Item.java | 80 +-
.../main/java/org/apache/unomi/api/Parameter.java | 1 -
.../main/java/org/apache/unomi/api/Profile.java | 1 +
.../unomi/api/PropertyMergeStrategyType.java | 3 +-
.../main/java/org/apache/unomi/api/ValueType.java | 3 +-
.../org/apache/unomi/api/actions/ActionType.java | 16 +-
.../apache/unomi/api/conditions/ConditionType.java | 16 +-
.../unomi/api/security/EncryptionService.java | 33 +-
.../apache/unomi/api/security/SecurityService.java | 234 +++
.../api/security/SecurityServiceConfiguration.java | 120 ++
.../apache/unomi/api/security/TenantPrincipal.java | 74 +
.../org/apache/unomi/api/security/UnomiRoles.java | 113 ++
.../unomi/api/services/DefinitionsService.java | 37 +-
.../apache/unomi/api/services/EventService.java | 23 +-
.../api/services/ExecutionContextManager.java | 78 +
.../apache/unomi/api/services/ProfileService.java | 15 +-
.../unomi/api/services/SchedulerService.java | 385 +++-
.../apache/unomi/api/services/SegmentService.java | 16 +
.../api/services/TenantLifecycleListener.java | 13 +-
.../org/apache/unomi/api/services/TriFunction.java | 35 +-
.../api/services/cache/CacheableTypeConfig.java | 620 ++++++
.../api/services/cache/MultiTypeCacheService.java | 170 ++
.../org/apache/unomi/api/tasks/ScheduledTask.java | 873 +++++++++
.../org/apache/unomi/api/tasks/TaskExecutor.java | 139 ++
.../java/org/apache/unomi/api/tenants/ApiKey.java | 204 ++
.../org/apache/unomi/api/tenants/ApiKeyConfig.java | 164 ++
.../org/apache/unomi/api/tenants/AuditService.java | 92 +
.../apache/unomi/api/tenants/ItemAuditService.java | 98 +
.../apache/unomi/api/tenants/ResourceQuota.java | 258 +++
.../java/org/apache/unomi/api/tenants/Tenant.java | 367 ++++
.../unomi/api/tenants/TenantAuditService.java | 15 +-
.../unomi/api/tenants/TenantBackupMetadata.java | 34 +-
.../apache/unomi/api/tenants/TenantService.java | 145 ++
.../org/apache/unomi/api/tenants/TenantStatus.java | 39 +-
.../api/tenants/TenantTransformationListener.java | 72 +
.../api/tenants/security/SecurityAuditReport.java | 191 ++
.../api/tenants/security/SecuritySettings.java | 171 ++
.../tenants/security/SecurityValidationResult.java | 96 +
.../tenants/security/TenantSecurityService.java | 56 +
.../apache/unomi/api/utils/ConditionBuilder.java | 584 +++++-
.../org/apache/unomi/api/tenants/TenantTest.java | 526 ++++++
bom/artifacts/pom.xml | 21 +
bom/pom.xml | 20 +
build.sh | 18 +-
extensions/geonames/services/pom.xml | 6 +-
.../geonames/services/GeonamesServiceImpl.java | 272 ++-
.../META-INF/cxs/mappings/geonameEntry.json | 11 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 20 +-
.../karaf-kar/src/main/feature/feature.xml | 1 +
extensions/groovy-actions/services/pom.xml | 50 +
.../actions/listener/GroovyActionListener.java | 145 --
.../services/impl/GroovyActionsServiceImpl.java | 607 +++---
.../unomi/healthcheck/HealthCheckService.java | 6 +-
extensions/json-schema/pom.xml | 1 +
extensions/json-schema/services/pom.xml | 72 +-
.../apache/unomi/schema/api/JsonSchemaWrapper.java | 16 +-
.../org/apache/unomi/schema/api/SchemaService.java | 15 -
.../unomi/schema/impl/SchemaServiceImpl.java | 428 +++--
.../unomi/schema/listener/JsonSchemaListener.java | 122 --
.../resources/OSGI-INF/blueprint/blueprint.xml | 36 +-
.../shell-commands}/pom.xml | 77 +-
.../shell/commands/schema/SchemaCrudCommand.java | 177 ++
.../resources/META-INF/cxs/mappings/userList.json | 11 +-
.../extensions/log4j/InMemoryLogAppender.java | 290 +++
.../resources/OSGI-INF/blueprint/blueprint.xml | 3 +-
.../apache/unomi/router/api/RouterConstants.java | 1 +
.../services/ImportExportConfigurationService.java | 9 +-
.../unomi/router/core/bean/CollectProfileBean.java | 32 +-
.../router/core/context/RouterCamelContext.java | 180 +-
.../processor/ImportConfigByFileNameProcessor.java | 187 +-
.../core/processor/UnomiStorageProcessor.java | 71 +-
.../route/ProfileExportCollectRouteBuilder.java | 16 +-
.../route/ProfileImportFromSourceRouteBuilder.java | 21 +-
.../route/ProfileImportOneShotRouteBuilder.java | 2 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 36 +-
.../src/main/resources/org.apache.unomi.router.cfg | 5 +-
.../services/ExportConfigurationServiceImpl.java | 40 +-
.../services/ImportConfigurationServiceImpl.java | 40 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 3 +
.../karaf-kar/src/main/feature/feature.xml | 3 +-
.../META-INF/cxs/mappings/sfdcConfiguration.json | 13 +-
.../karaf-kar/src/main/feature/feature.xml | 9 +-
.../commands/CreateOrUpdateSourceCommand.java | 8 +-
.../condition/factories/ConditionFactory.java | 41 +-
.../factories/ProfileConditionFactory.java | 8 +-
.../SegmentProfileEventsConditionParser.java | 14 +-
.../SegmentProfilePropertiesConditionParser.java | 10 +-
.../converters/UnomiToGraphQLConverter.java | 4 +
.../graphql/schema/GraphQLSchemaProvider.java | 81 +
.../unomi/graphql/schema/GraphQLSchemaUpdater.java | 151 +-
.../graphql/schema/TenantSchemaInvalidator.java | 83 +
.../unomi/graphql/servlet/GraphQLServlet.java | 138 +-
.../auth/GraphQLServletSecurityValidator.java | 100 +-
.../types/output/CDPConsentUpdateEvent.java | 6 +-
.../org/apache/unomi/graphql/utils/DateUtils.java | 24 +
.../cxs/conditions/userListPropertyCondition.json | 2 +-
graphql/karaf-feature/src/main/feature/feature.xml | 33 +-
.../unomi/graphql/security/SecurityDirective.java | 63 +
.../unomi/graphql/security/TenantDirective.java | 60 +
itests/README.md | 39 +
itests/pom.xml | 115 +-
.../test/java/org/apache/unomi/itests/AllITs.java | 12 +
.../test/java/org/apache/unomi/itests/BaseIT.java | 1009 +++++++++-
.../test/java/org/apache/unomi/itests/BasicIT.java | 15 +-
.../apache/unomi/itests/ConditionEvaluatorIT.java | 12 +-
.../org/apache/unomi/itests/ContextServletIT.java | 221 ++-
.../unomi/itests/CopyPropertiesActionIT.java | 19 +-
.../org/apache/unomi/itests/EventServiceIT.java | 43 +-
.../org/apache/unomi/itests/EventsCollectorIT.java | 139 ++
.../unomi/itests/GroovyActionsServiceIT.java | 8 +-
.../org/apache/unomi/itests/HealthCheckIT.java | 2 +-
.../org/apache/unomi/itests/InputValidationIT.java | 29 +-
.../java/org/apache/unomi/itests/JSONSchemaIT.java | 63 +-
.../test/java/org/apache/unomi/itests/PatchIT.java | 30 +-
.../java/org/apache/unomi/itests/PersonaIT.java | 154 ++
.../apache/unomi/itests/ProfileImportActorsIT.java | 29 +-
.../apache/unomi/itests/ProfileImportBasicIT.java | 6 +-
.../unomi/itests/ProfileImportRankingIT.java | 15 +-
.../unomi/itests/ProfileImportSurfersIT.java | 75 +-
.../org/apache/unomi/itests/ProfileMergeIT.java | 60 +-
.../itests/ProfileServiceWithoutOverwriteIT.java | 3 +-
.../org/apache/unomi/itests/ProgressListener.java | 201 +-
.../org/apache/unomi/itests/ProgressSuite.java | 28 +-
.../org/apache/unomi/itests/RuleServiceIT.java | 139 +-
.../java/org/apache/unomi/itests/SchedulerIT.java | 167 ++
.../test/java/org/apache/unomi/itests/ScopeIT.java | 2 +-
.../java/org/apache/unomi/itests/SecurityIT.java | 105 ++
.../java/org/apache/unomi/itests/SegmentIT.java | 128 +-
.../org/apache/unomi/itests/SendEventActionIT.java | 2 +-
.../java/org/apache/unomi/itests/TenantIT.java | 592 ++++++
.../java/org/apache/unomi/itests/TestUtils.java | 317 +++-
.../apache/unomi/itests/V2CompatibilityModeIT.java | 435 +++++
.../apache/unomi/itests/graphql/BaseGraphQLIT.java | 118 +-
.../unomi/itests/graphql/GraphQLEventIT.java | 59 +-
.../unomi/itests/graphql/GraphQLSegmentIT.java | 11 +-
.../itests/graphql/GraphQLServletSecurityIT.java | 4 +-
.../unomi/itests/graphql/GraphQLSourceIT.java | 6 +-
.../migration/Migrate16xToCurrentVersionIT.java | 694 ++++++-
.../apache/unomi/itests/shell/CacheCommandsIT.java | 147 ++
.../apache/unomi/itests/shell/CrudCommandsIT.java | 747 ++++++++
.../apache/unomi/itests/shell/OtherCommandsIT.java | 36 +-
.../itests/shell/RuleStatisticsCommandsIT.java | 133 ++
.../unomi/itests/shell/SchedulerCommandsIT.java | 150 ++
.../unomi/itests/shell/ShellCommandsBaseIT.java | 466 +++++
.../apache/unomi/itests/shell/TailCommandsIT.java | 45 +
.../unomi/itests/shell/TenantCommandsIT.java | 93 +
.../org/apache/unomi/itests/tools/LogChecker.java | 1221 ++++++++++++
.../apache/unomi/itests/tools/LogCheckerTest.java | 396 ++++
.../httpclient/HttpClientThatWaitsForUnomi.java | 44 +
itests/src/test/resources/etc/users.properties | 2 +-
itests/src/test/resources/events/viewEvent.json | 37 +
.../persona/persona-with-sessions-payload.json | 36 +
kar/pom.xml | 14 +-
kar/src/main/feature/feature.xml | 30 +-
.../apache/unomi/lifecycle/BundleWatcherImpl.java | 25 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 4 +-
manual/src/main/asciidoc/5-min-quickstart.adoc | 50 +-
manual/src/main/asciidoc/configuration.adoc | 457 ++++-
manual/src/main/asciidoc/consent-api.adoc | 3 +
manual/src/main/asciidoc/datamodel.adoc | 10 +-
manual/src/main/asciidoc/getting-started.adoc | 54 +-
manual/src/main/asciidoc/graphql.adoc | 89 +
manual/src/main/asciidoc/index.adoc | 33 +-
.../main/asciidoc/jsonSchema/json-schema-api.adoc | 16 +-
.../asciidoc/jsonSchema/json-schema-develop.adoc | 10 +-
manual/src/main/asciidoc/migrate-es7-to-es9.adoc | 17 +-
.../asciidoc/migrations/migrate-2.x-to-3.0.adoc | 16 +-
.../asciidoc/migrations/migrate-3.0-to-3.1.adoc | 319 ++++
.../src/main/asciidoc/migrations/migrations.adoc | 12 +-
.../asciidoc/migrations/v2-compatibility-mode.adoc | 351 ++++
.../asciidoc/migrations/v2-v3-compatibility.adoc | 230 +++
manual/src/main/asciidoc/multitenancy.adoc | 848 +++++++++
manual/src/main/asciidoc/privacy.adoc | 7 +-
.../main/asciidoc/queries-and-aggregations.adoc | 12 +-
manual/src/main/asciidoc/recipes.adoc | 52 +-
manual/src/main/asciidoc/request-examples.adoc | 1691 ++++++++++++++++-
.../src/main/asciidoc/samples/twitter-sample.adoc | 3 +
manual/src/main/asciidoc/tutorial.adoc | 10 +-
manual/src/main/asciidoc/updating-events.adoc | 12 +-
manual/src/main/asciidoc/whats-new.adoc | 10 +-
manual/src/main/asciidoc/writing-plugins.adoc | 26 +-
.../apache/unomi/metrics/commands/ViewCommand.java | 5 +-
package/pom.xml | 47 +-
.../main/resources/etc/custom.system.properties | 42 +-
.../main/resources/etc/org.ops4j.pax.logging.cfg | 5 +
package/src/main/resources/etc/users.properties | 2 +-
.../advanced/IdsConditionESQueryBuilder.java | 20 +-
.../advanced/PastEventConditionESQueryBuilder.java | 15 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 2 +
.../ElasticSearchPersistenceServiceImpl.java | 605 +++++-
.../core/PropertyConditionESQueryBuilder.java | 18 +-
.../resources/META-INF/cxs/mappings/event.json | 9 +
.../META-INF/cxs/mappings/personaSession.json | 11 +-
.../resources/META-INF/cxs/mappings/profile.json | 9 +
.../META-INF/cxs/mappings/profileAlias.json | 9 +
.../META-INF/cxs/mappings/scheduledTask.json | 85 +
.../resources/META-INF/cxs/mappings/session.json | 9 +
.../META-INF/cxs/mappings/systemItems.json | 15 +-
.../resources/META-INF/cxs/mappings/tenant.json | 24 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 34 +-
.../org.apache.unomi.persistence.elasticsearch.cfg | 4 +-
persistence-opensearch/conditions/pom.xml | 60 +-
.../advanced/IdsConditionOSQueryBuilder.java | 18 +-
.../advanced/PastEventConditionOSQueryBuilder.java | 15 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 2 +
persistence-opensearch/core/pom.xml | 47 +-
.../OpenSearchPersistenceServiceImpl.java | 528 +++++-
.../core/PropertyConditionOSQueryBuilder.java | 11 +-
.../resources/META-INF/cxs/mappings/event.json | 9 +
.../META-INF/cxs/mappings/personaSession.json | 11 +-
.../resources/META-INF/cxs/mappings/profile.json | 9 +
.../META-INF/cxs/mappings/profileAlias.json | 9 +
.../META-INF/cxs/mappings/scheduledTask.json | 88 +
.../resources/META-INF/cxs/mappings/session.json | 9 +
.../META-INF/cxs/mappings/systemItems.json | 15 +-
.../mappings/{personaSession.json => tenant.json} | 25 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 59 +-
.../org.apache.unomi.persistence.opensearch.cfg | 16 +-
persistence-spi/pom.xml | 35 +-
.../unomi/persistence/spi/CustomObjectMapper.java | 20 +
.../unomi/persistence/spi/PersistenceService.java | 26 +
.../unomi/persistence/spi/PropertyHelper.java | 14 +-
.../impl/ConditionEvaluatorDispatcherImpl.java | 10 +
.../spi/conditions/geo/DistanceUnit.java | 2 +-
.../spi/conditions/geo/GeoDistance.java | 2 +-
.../actions/MergeProfilesOnPropertyAction.java | 267 ++-
.../conditions/PastEventConditionEvaluator.java | 9 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 29 +-
plugins/past-event/pom.xml | 6 +-
pom.xml | 106 +-
rest/pom.xml | 50 +-
.../rest/authentication/AuthenticationFilter.java | 281 ++-
.../authentication/RestAuthenticationConfig.java | 19 +
.../SecurityContextCleanupFilter.java | 58 +
.../authentication/V2ThirdPartyConfigService.java | 259 +++
.../impl/DefaultRestAuthenticationConfig.java | 74 +-
.../unomi/rest/endpoints/ContextJsonEndpoint.java | 112 +-
.../rest/endpoints/EventsCollectorEndpoint.java | 81 +-
.../rest/endpoints/ProfileServiceEndPoint.java | 2 +-
.../apache/unomi/rest/scheduler/TaskEndpoint.java | 165 ++
.../apache/unomi/rest/security/RequiresRole.java | 13 +-
.../apache/unomi/rest/security/RequiresTenant.java | 12 +-
.../apache/unomi/rest/security/SecurityFilter.java | 98 +
.../org/apache/unomi/rest/server/RestServer.java | 259 ++-
.../unomi/rest/service/RestServiceUtils.java | 4 +-
.../rest/service/impl/RestServiceUtilsImpl.java | 131 +-
.../apache/unomi/rest/tenants/TenantEndpoint.java | 192 ++
.../apache/unomi/rest/tenants/TenantRequest.java | 25 +-
.../org.apache.unomi.rest.authentication.cfg | 31 +
.../src/main/webapp/javascript/login-example.js | 2 +-
{persistence-spi => services-common}/pom.xml | 90 +-
.../cache/AbstractMultiTypeCachingService.java | 832 ++++++++
.../services/common/security/AuditServiceImpl.java | 139 ++
.../security/ExecutionContextManagerImpl.java | 191 ++
.../common/security/IPValidationUtils.java | 99 +
.../common/security/KarafSecurityService.java | 333 ++++
.../service/AbstractContextAwareService.java | 174 ++
.../resources/OSGI-INF/blueprint/blueprint.xml | 85 +
.../cache/AbstractMultiTypeCachingServiceTest.java | 380 ++++
.../common/cache/CacheableTypeConfigTest.java | 99 +
.../common/security/IPValidationUtilsTest.java | 144 ++
.../common/security/KarafSecurityServiceTest.java | 366 ++++
services/pom.xml | 61 +-
.../actions/impl/ActionExecutorDispatcherImpl.java | 17 +-
.../impl/cache/MultiTypeCacheServiceImpl.java | 258 +++
.../services/impl/cluster/ClusterServiceImpl.java | 198 +-
.../impl/definitions/DefinitionsServiceImpl.java | 840 +++++----
.../services/impl/events/EventServiceImpl.java | 297 ++-
.../services/impl/goals/GoalsServiceImpl.java | 225 +--
.../services/impl/lists/UserListServiceImpl.java | 27 +-
.../services/impl/patches/PatchServiceImpl.java | 106 +-
.../services/impl/profiles/ProfileServiceImpl.java | 448 ++---
.../services/impl/rules/RulesServiceImpl.java | 1086 ++++++++---
.../scheduler/PersistenceSchedulerProvider.java | 399 ++++
.../impl/scheduler/SchedulerConstants.java | 49 +
.../services/impl/scheduler/SchedulerProvider.java | 97 +
.../impl/scheduler/SchedulerServiceImpl.java | 1992 +++++++++++++++++++-
.../impl/scheduler/TaskExecutionManager.java | 523 +++++
.../impl/scheduler/TaskExecutorRegistry.java | 149 ++
.../impl/scheduler/TaskHistoryManager.java | 167 ++
.../services/impl/scheduler/TaskLockManager.java | 352 ++++
.../impl/scheduler/TaskMetricsManager.java | 93 +
.../impl/scheduler/TaskRecoveryManager.java | 336 ++++
.../services/impl/scheduler/TaskStateManager.java | 311 +++
.../impl/scheduler/TaskValidationManager.java | 198 ++
.../services/impl/scope/ScopeServiceImpl.java | 82 +-
.../services/impl/segments/SegmentServiceImpl.java | 805 ++++++--
.../unomi/services/impl/tenants/TenantMetrics.java | 59 +
.../impl/tenants/TenantMigrationService.java | 68 +
.../impl/tenants/TenantMonitoringService.java | 187 ++
.../services/impl/tenants/TenantQuotaService.java | 166 ++
.../impl/tenants/TenantSecurityService.java | 58 +
.../services/impl/tenants/TenantServiceImpl.java | 240 +++
.../unomi/services/impl/tenants/TenantUsage.java | 59 +
.../painless/evaluateScoringPlanElement.painless | 12 +-
.../cxs/painless/resetScoringPlan.painless | 6 +-
.../resources/OSGI-INF/blueprint/blueprint.xml | 473 +++--
.../main/resources/org.apache.unomi.cluster.cfg | 2 +-
.../main/resources/org.apache.unomi.services.cfg | 18 +
.../unomi/services/impl/EventServiceImplTest.java | 197 --
tools/shell-commands/pom.xml | 13 +-
.../shell/migration/service/MigrationConfig.java | 2 +
.../shell/migration/service/MigrationContext.java | 5 +
.../shell/migration/service/MigrationScript.java | 10 +
.../migration/service/MigrationServiceImpl.java | 40 +-
.../unomi/shell/migration/utils/HttpUtils.java | 7 +-
.../shell/migration/utils/MigrationUtils.java | 706 ++++++-
.../shell/services/UnomiManagementService.java | 7 +
.../internal/UnomiManagementServiceImpl.java | 6 +
.../migrate-2.0.0-15-eventsReindex.groovy | 1 -
.../migrate-3.1.0-00-tenantDocumentIds.groovy | 179 ++
.../migrate-3.1.0-05-fixSystemItemIds.groovy | 85 +
.../migrate-3.1.0-10-tenantInitialization.groovy | 88 +
...igrate-3.1.0-15-updateLegacyQueryBuilder.groovy | 129 ++
.../main/resources/org.apache.unomi.migration.cfg | 5 +-
.../requestBody/2.0.0/mappings/campaign.json | 11 +-
.../requestBody/2.0.0/mappings/conditionType.json | 11 +-
.../resources/requestBody/2.0.0/mappings/goal.json | 11 +-
.../requestBody/2.0.0/mappings/patch.json | 9 +
.../resources/requestBody/2.0.0/mappings/rule.json | 11 +-
.../requestBody/2.0.0/mappings/scope.json | 9 +
.../requestBody/2.0.0/mappings/scoring.json | 9 +
.../requestBody/2.0.0/mappings/segment.json | 9 +
.../requestBody/2.2.0/suffix_ids.painless | 7 +-
.../3.1.0/base_update_by_query_request.json | 12 +
.../requestBody/3.1.0/fix_system_item_ids.painless | 71 +
.../3.1.0/fix_system_item_ids_update_request.json | 12 +
.../requestBody/3.1.0/get_item_types_query.json | 11 +
.../initialize_tenant_and_audit_fields.painless | 100 +
.../3.1.0/update_legacy_querybuilder.painless | 33 +
.../shell/migration/utils/MigrationUtilsTest.java | 202 ++
tools/shell-dev-commands/pom.xml | 1 -
.../unomi/shell/dev/commands/CacheCommands.java | 368 ++++
.../shell/dev/commands/ListCommandSupport.java | 7 +
.../shell/dev/commands/TenantContextHelper.java | 74 +
.../dev/commands/apikeys/ApiKeyCrudCommand.java | 208 ++
.../commands/scheduler/BaseSchedulerCommand.java | 65 +
.../commands/scheduler/CancelTaskCommand.java} | 22 +-
.../dev/commands/scheduler/ListTasksCommand.java | 135 ++
.../dev/commands/scheduler/PurgeTasksCommand.java | 83 +
.../commands/scheduler/RetryTaskCommand.java} | 33 +-
.../scheduler/SetExecutorNodeCommand.java} | 38 +-
.../dev/commands/scheduler/ShowTaskCommand.java | 87 +
.../dev/commands/tenants/TenantCrudCommand.java | 278 +++
.../commands/tenants/TenantGetCurrentCommand.java} | 27 +-
.../commands/tenants/TenantSetCurrentCommand.java | 71 +
.../shell/dev/completers/TenantCompleter.java | 51 +
.../unomi/shell/dev/services/BaseCrudCommand.java | 44 +-
356 files changed, 40122 insertions(+), 4533 deletions(-)
diff --git a/.gitignore b/.gitignore
index 4de759d76..bd6258bdc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,6 @@ rest/.miredot-offline.json
itests/src/main
dependency_tree.txt
.mvn/.develocity/develocity-workspace-id
+/.cursor/
+itests/snapshots_repository/
+.env.local
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7b016a89f..d53ecaf3d 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,3 +1,4 @@
{
- "java.compile.nullAnalysis.mode": "automatic"
+ "java.compile.nullAnalysis.mode": "automatic",
+ "java.configuration.updateBuildConfiguration": "automatic"
}
\ No newline at end of file
diff --git a/api/pom.xml b/api/pom.xml
index 4d9ed7fbd..b1933fbed 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -73,6 +73,16 @@
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>javax.servlet</groupId>
+ <artifactId>javax.servlet-api</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.osgi</groupId>
+ <artifactId>osgi.core</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
diff --git a/api/src/main/java/org/apache/unomi/api/ContextRequest.java b/api/src/main/java/org/apache/unomi/api/ContextRequest.java
index 3f7a10d79..f050be672 100644
--- a/api/src/main/java/org/apache/unomi/api/ContextRequest.java
+++ b/api/src/main/java/org/apache/unomi/api/ContextRequest.java
@@ -71,6 +71,11 @@ public class ContextRequest {
private String clientId;
+ /**
+ * The public API key for tenant authentication.
+ */
+ private String publicApiKey;
+
/**
* Retrieves the source of the context request.
*
@@ -294,4 +299,20 @@ public class ContextRequest {
public void setClientId(String clientId) {
this.clientId = clientId;
}
+
+ /**
+ * Gets the public API key used for tenant authentication.
+ * @return the public API key
+ */
+ public String getPublicApiKey() {
+ return publicApiKey;
+ }
+
+ /**
+ * Sets the public API key used for tenant authentication.
+ * @param publicApiKey the public API key to set
+ */
+ public void setPublicApiKey(String publicApiKey) {
+ this.publicApiKey = publicApiKey;
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/ContextResponse.java b/api/src/main/java/org/apache/unomi/api/ContextResponse.java
index 6da1f3875..8c158ec76 100644
--- a/api/src/main/java/org/apache/unomi/api/ContextResponse.java
+++ b/api/src/main/java/org/apache/unomi/api/ContextResponse.java
@@ -20,10 +20,8 @@ package org.apache.unomi.api;
import org.apache.unomi.api.conditions.Condition;
import org.apache.unomi.api.services.RulesService;
-import javax.xml.bind.annotation.XmlTransient;
import java.io.Serializable;
import java.util.*;
-import java.util.stream.Collectors;
/**
* A context server response resulting from the evaluation of a client's context request. Note that all returned values result of the evaluation of the data provided in the
diff --git a/api/src/main/java/org/apache/unomi/api/Event.java b/api/src/main/java/org/apache/unomi/api/Event.java
index b8ce833c4..e6a87285c 100644
--- a/api/src/main/java/org/apache/unomi/api/Event.java
+++ b/api/src/main/java/org/apache/unomi/api/Event.java
@@ -152,7 +152,9 @@ public class Event extends Item implements TimestampedItem {
this.eventType = eventType;
this.profile = profile;
this.session = session;
- this.profileId = profile.getItemId();
+ if (profile != null) {
+ this.profileId = profile.getItemId();
+ }
this.scope = scope;
this.source = source;
this.target = target;
@@ -319,6 +321,9 @@ public class Event extends Item implements TimestampedItem {
* @param value the value of the property
*/
public void setProperty(String name, Object value) {
+ if (properties == null) {
+ properties = new LinkedHashMap<>();
+ }
properties.put(name, value);
}
diff --git a/api/src/main/java/org/apache/unomi/api/EventsCollectorRequest.java b/api/src/main/java/org/apache/unomi/api/EventsCollectorRequest.java
index bdf012de6..759a71ca8 100644
--- a/api/src/main/java/org/apache/unomi/api/EventsCollectorRequest.java
+++ b/api/src/main/java/org/apache/unomi/api/EventsCollectorRequest.java
@@ -30,6 +30,11 @@ public class EventsCollectorRequest {
private String profileId;
+ /**
+ * The public API key for tenant authentication.
+ */
+ private String publicApiKey;
+
/**
* Retrieves the events to be processed.
*
@@ -81,4 +86,20 @@ public class EventsCollectorRequest {
public void setProfileId(String profileId) {
this.profileId = profileId;
}
+
+ /**
+ * Gets the public API key used for tenant authentication.
+ * @return the public API key
+ */
+ public String getPublicApiKey() {
+ return publicApiKey;
+ }
+
+ /**
+ * Sets the public API key used for tenant authentication.
+ * @param publicApiKey the public API key to set
+ */
+ public void setPublicApiKey(String publicApiKey) {
+ this.publicApiKey = publicApiKey;
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/ExecutionContext.java b/api/src/main/java/org/apache/unomi/api/ExecutionContext.java
new file mode 100644
index 000000000..1fcf5a7ba
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/ExecutionContext.java
@@ -0,0 +1,98 @@
+/*
+ * 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.api;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.Stack;
+
+/**
+ * Represents the execution context for operations in Unomi, including security and tenant information.
+ */
+public class ExecutionContext {
+ public static final String SYSTEM_TENANT = "system";
+
+ private String tenantId;
+ private Set<String> roles = new HashSet<>();
+ private Set<String> permissions = new HashSet<>();
+ private Stack<String> tenantStack = new Stack<>();
+ private boolean isSystem = false;
+
+ public ExecutionContext(String tenantId, Set<String> roles, Set<String> permissions) {
+ this.tenantId = tenantId;
+ if (tenantId != null && tenantId.equals(SYSTEM_TENANT)) {
+ this.isSystem = true;
+ }
+ if (roles != null) {
+ this.roles.addAll(roles);
+ }
+ if (permissions != null) {
+ this.permissions.addAll(permissions);
+ }
+ }
+
+ public static ExecutionContext systemContext() {
+ ExecutionContext context = new ExecutionContext(SYSTEM_TENANT, null, null);
+ context.isSystem = true;
+ return context;
+ }
+
+ public String getTenantId() {
+ return tenantId;
+ }
+
+ public Set<String> getRoles() {
+ return new HashSet<>(roles);
+ }
+
+ public Set<String> getPermissions() {
+ return new HashSet<>(permissions);
+ }
+
+ public boolean isSystem() {
+ return isSystem;
+ }
+
+ public void setTenant(String tenantId) {
+ tenantStack.push(this.tenantId);
+ this.tenantId = tenantId;
+ }
+
+ public void restorePreviousTenant() {
+ if (!tenantStack.isEmpty()) {
+ this.tenantId = tenantStack.pop();
+ }
+ }
+
+ public void validateAccess(String operation) {
+ if (isSystem) {
+ return;
+ }
+
+ if (!hasPermission(operation)) {
+ throw new SecurityException("Access denied: Missing permission for operation " + operation + " for tenant " + tenantId + " and roles " + roles);
+ }
+ }
+
+ public boolean hasPermission(String permission) {
+ return isSystem || permissions.contains(permission);
+ }
+
+ public boolean hasRole(String role) {
+ return isSystem || roles.contains(role);
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/Item.java b/api/src/main/java/org/apache/unomi/api/Item.java
index 9836cf40d..44da45f5c 100644
--- a/api/src/main/java/org/apache/unomi/api/Item.java
+++ b/api/src/main/java/org/apache/unomi/api/Item.java
@@ -69,12 +69,22 @@ public abstract class Item implements Serializable, YamlConvertible {
protected String scope;
protected Long version;
protected Map<String, Object> systemMetadata = new HashMap<>();
+ private String tenantId;
+
+ // Audit metadata fields
+ private String createdBy;
+ private String lastModifiedBy;
+ private Date creationDate;
+ private Date lastModificationDate;
+ private String sourceInstanceId;
+ private Date lastSyncDate;
public Item() {
this.itemType = getItemType(this.getClass());
if (itemType == null) {
LOGGER.error("Item implementations must provide a public String constant named ITEM_TYPE to uniquely identify this Item for the persistence service.");
}
+ initializeAuditMetadata();
}
public Item(String itemId) {
@@ -82,6 +92,11 @@ public abstract class Item implements Serializable, YamlConvertible {
this.itemId = itemId;
}
+ private void initializeAuditMetadata() {
+ this.creationDate = new Date();
+ this.lastModificationDate = this.creationDate;
+ this.version = 0L;
+ }
/**
* Retrieves the Item's identifier used to uniquely identify this Item when persisted or when referred to. An Item's identifier must be unique among Items with the same type.
@@ -131,7 +146,6 @@ public abstract class Item implements Serializable, YamlConvertible {
Item item = (Item) o;
return !(itemId != null ? !itemId.equals(item.itemId) : item.itemId != null);
-
}
@Override
@@ -155,6 +169,63 @@ public abstract class Item implements Serializable, YamlConvertible {
systemMetadata.put(key, value);
}
+ public String getTenantId() {
+ return tenantId;
+ }
+
+ public void setTenantId(String tenantId) {
+ this.tenantId = tenantId;
+ }
+
+ // Audit metadata getters and setters
+ public String getCreatedBy() {
+ return createdBy;
+ }
+
+ public void setCreatedBy(String createdBy) {
+ this.createdBy = createdBy;
+ }
+
+ public String getLastModifiedBy() {
+ return lastModifiedBy;
+ }
+
+ public void setLastModifiedBy(String lastModifiedBy) {
+ this.lastModifiedBy = lastModifiedBy;
+ }
+
+ public Date getCreationDate() {
+ return creationDate;
+ }
+
+ public void setCreationDate(Date creationDate) {
+ this.creationDate = creationDate;
+ }
+
+ public Date getLastModificationDate() {
+ return lastModificationDate;
+ }
+
+ public void setLastModificationDate(Date lastModificationDate) {
+ this.lastModificationDate = lastModificationDate;
+ }
+
+ public String getSourceInstanceId() {
+ return sourceInstanceId;
+ }
+
+ public void setSourceInstanceId(String sourceInstanceId) {
+ this.sourceInstanceId = sourceInstanceId;
+ }
+
+ public Date getLastSyncDate() {
+ return lastSyncDate;
+ }
+
+ public void setLastSyncDate(Date lastSyncDate) {
+ this.lastSyncDate = lastSyncDate;
+ }
+
/**
* Converts this item to a Map structure for YAML output.
* Implements YamlConvertible interface with circular reference detection.
@@ -190,6 +261,13 @@ public abstract class Item implements Serializable, YamlConvertible {
.putIfNotNull("scope", scope)
.putIfNotNull("version", version)
.putIfNotNull("systemMetadata", systemMetadata != null && !systemMetadata.isEmpty() ? toYamlValue(systemMetadata, visitedSet, maxDepth - 1) : null)
+ .putIfNotNull("tenantId", tenantId)
+ .putIfNotNull("createdBy", createdBy)
+ .putIfNotNull("lastModifiedBy", lastModifiedBy)
+ .putIfNotNull("creationDate", creationDate)
+ .putIfNotNull("lastModificationDate", lastModificationDate)
+ .putIfNotNull("sourceInstanceId", sourceInstanceId)
+ .putIfNotNull("lastSyncDate", lastSyncDate)
.build();
} finally {
// Only remove if we added it (i.e., if it wasn't already visited)
diff --git a/api/src/main/java/org/apache/unomi/api/Parameter.java b/api/src/main/java/org/apache/unomi/api/Parameter.java
index 425d86280..1809af8ec 100644
--- a/api/src/main/java/org/apache/unomi/api/Parameter.java
+++ b/api/src/main/java/org/apache/unomi/api/Parameter.java
@@ -116,5 +116,4 @@ public class Parameter implements Serializable, YamlConvertible {
public String toString() {
return YamlUtils.format(toYaml());
}
-
}
diff --git a/api/src/main/java/org/apache/unomi/api/Profile.java b/api/src/main/java/org/apache/unomi/api/Profile.java
index 133fc7599..76d9d63c4 100644
--- a/api/src/main/java/org/apache/unomi/api/Profile.java
+++ b/api/src/main/java/org/apache/unomi/api/Profile.java
@@ -297,6 +297,7 @@ public class Profile extends Item implements SystemPropertiesItem {
sb.append(", itemId='").append(itemId).append('\'');
sb.append(", itemType='").append(itemType).append('\'');
sb.append(", scope='").append(scope).append('\'');
+ sb.append(", tenantId'").append(getTenantId()).append('\'');
sb.append(", version=").append(version);
sb.append('}');
return sb.toString();
diff --git a/api/src/main/java/org/apache/unomi/api/PropertyMergeStrategyType.java b/api/src/main/java/org/apache/unomi/api/PropertyMergeStrategyType.java
index dd47a510f..8a2249ec1 100644
--- a/api/src/main/java/org/apache/unomi/api/PropertyMergeStrategyType.java
+++ b/api/src/main/java/org/apache/unomi/api/PropertyMergeStrategyType.java
@@ -18,11 +18,12 @@
package org.apache.unomi.api;
import javax.xml.bind.annotation.XmlTransient;
+import java.io.Serializable;
/**
* A unomi plugin that defines a new property merge strategy.
*/
-public class PropertyMergeStrategyType implements PluginType {
+public class PropertyMergeStrategyType implements PluginType, Serializable {
private String id;
private String filter;
diff --git a/api/src/main/java/org/apache/unomi/api/ValueType.java b/api/src/main/java/org/apache/unomi/api/ValueType.java
index 16e1eac9b..d470a694b 100644
--- a/api/src/main/java/org/apache/unomi/api/ValueType.java
+++ b/api/src/main/java/org/apache/unomi/api/ValueType.java
@@ -18,13 +18,14 @@
package org.apache.unomi.api;
import javax.xml.bind.annotation.XmlTransient;
+import java.io.Serializable;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* A value type to be used to constrain property values.
*/
-public class ValueType implements PluginType {
+public class ValueType implements PluginType, Serializable {
private String id;
private String nameKey;
diff --git a/api/src/main/java/org/apache/unomi/api/actions/ActionType.java b/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
index e170c779b..03c4e6e0b 100644
--- a/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
+++ b/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
@@ -20,6 +20,7 @@ package org.apache.unomi.api.actions;
import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.Parameter;
+import org.apache.unomi.api.PluginType;
import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
@@ -31,12 +32,13 @@ import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A type definition for {@link Action}s.
*/
-public class ActionType extends MetadataItem implements YamlConvertible {
+public class ActionType extends MetadataItem implements PluginType, YamlConvertible {
public static final String ITEM_TYPE = "actionType";
private static final long serialVersionUID = -3522958600710010935L;
private String actionExecutor;
private List<Parameter> parameters = new ArrayList<Parameter>();
+ private long pluginId;
/**
* Instantiates a new Action type.
@@ -106,6 +108,16 @@ public class ActionType extends MetadataItem implements YamlConvertible {
return itemId.hashCode();
}
+ @Override
+ public long getPluginId() {
+ return pluginId;
+ }
+
+ @Override
+ public void setPluginId(long pluginId) {
+ this.pluginId = pluginId;
+ }
+
/**
* Converts this action type to a Map structure for YAML output.
* Implements YamlConvertible interface with circular reference detection.
@@ -118,6 +130,7 @@ public class ActionType extends MetadataItem implements YamlConvertible {
if (maxDepth <= 0) {
return YamlMapBuilder.create()
.put("parameters", "<max depth exceeded>")
+ .put("pluginId", pluginId)
.build();
}
if (visited != null && visited.contains(this)) {
@@ -130,6 +143,7 @@ public class ActionType extends MetadataItem implements YamlConvertible {
.mergeObject(super.toYaml(visitedSet, maxDepth))
.putIfNotNull("actionExecutor", actionExecutor)
.putIfNotEmpty("parameters", parameters != null ? (Collection<?>) toYamlValue(parameters, visitedSet, maxDepth - 1) : null)
+ .put("pluginId", pluginId)
.build();
} finally {
visitedSet.remove(this);
diff --git a/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java b/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
index e8af9c41f..1b650f44f 100644
--- a/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
+++ b/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
@@ -20,6 +20,7 @@ package org.apache.unomi.api.conditions;
import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.Parameter;
+import org.apache.unomi.api.PluginType;
import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
@@ -35,7 +36,7 @@ import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
* optimized by coding it. They may also be defined as combination of other conditions. A simple condition could be: “User is male”, while a more generic condition with
* parameters may test whether a given property has a specific value: “User property x has value y”.
*/
-public class ConditionType extends MetadataItem implements YamlConvertible {
+public class ConditionType extends MetadataItem implements PluginType, YamlConvertible {
public static final String ITEM_TYPE = "conditionType";
private static final long serialVersionUID = -6965481691241954969L;
@@ -43,6 +44,7 @@ public class ConditionType extends MetadataItem implements YamlConvertible {
private String queryBuilder;
private Condition parentCondition;
private List<Parameter> parameters = new ArrayList<Parameter>();
+ private long pluginId;
/**
* Instantiates a new Condition type.
@@ -147,6 +149,16 @@ public class ConditionType extends MetadataItem implements YamlConvertible {
return itemId.hashCode();
}
+ @Override
+ public long getPluginId() {
+ return pluginId;
+ }
+
+ @Override
+ public void setPluginId(long pluginId) {
+ this.pluginId = pluginId;
+ }
+
/**
* Converts this condition type to a Map structure for YAML output.
* Implements YamlConvertible interface with circular reference detection.
@@ -160,6 +172,7 @@ public class ConditionType extends MetadataItem implements YamlConvertible {
return YamlMapBuilder.create()
.put("parentCondition", "<max depth exceeded>")
.put("parameters", "<max depth exceeded>")
+ .put("pluginId", pluginId)
.build();
}
if (visited != null && visited.contains(this)) {
@@ -174,6 +187,7 @@ public class ConditionType extends MetadataItem implements YamlConvertible {
.putIfNotNull("queryBuilder", queryBuilder)
.putIfNotNull("parentCondition", parentCondition != null ? toYamlValue(parentCondition, visitedSet, maxDepth - 1) : null)
.putIfNotEmpty("parameters", parameters != null ? (Collection<?>) toYamlValue(parameters, visitedSet, maxDepth - 1) : null)
+ .put("pluginId", pluginId)
.build();
} finally {
visitedSet.remove(this);
diff --git a/services/src/main/resources/META-INF/cxs/painless/resetScoringPlan.painless b/api/src/main/java/org/apache/unomi/api/security/EncryptionService.java
similarity index 58%
copy from services/src/main/resources/META-INF/cxs/painless/resetScoringPlan.painless
copy to api/src/main/java/org/apache/unomi/api/security/EncryptionService.java
index 2324069d2..d77c8c1e6 100644
--- a/services/src/main/resources/META-INF/cxs/painless/resetScoringPlan.painless
+++ b/api/src/main/java/org/apache/unomi/api/security/EncryptionService.java
@@ -14,18 +14,25 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+package org.apache.unomi.api.security;
-/*
- This script is used to reset all profiles that have a scoring plan matching the given params.scoringId
- required params:
- - params.scoringId: the ID of the Scoring plan
-*/
-
-// remove score for the given params.scoringId
-ctx._source.scores.remove(params.scoringId);
+/**
+ * Service for handling encryption operations.
+ */
+public interface EncryptionService {
+ /**
+ * Get the encryption key for a specific tenant.
+ *
+ * @param tenantId the tenant ID
+ * @return the encryption key as a byte array
+ */
+ byte[] getTenantEncryptionKey(String tenantId);
-// Update lastUpdated date on profile
-if (!ctx._source.containsKey("systemProperties")) {
- ctx._source.put("systemProperties", [:]);
-}
-ctx._source.systemProperties.put("lastUpdated", ZonedDateTime.ofInstant(Instant.ofEpochMilli(System.currentTimeMillis()), ZoneId.of("Z")));
\ No newline at end of file
+ /**
+ * Generate a new encryption key for a tenant.
+ *
+ * @param tenantId the tenant ID
+ * @return the newly generated encryption key
+ */
+ byte[] generateTenantEncryptionKey(String tenantId);
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/security/SecurityService.java b/api/src/main/java/org/apache/unomi/api/security/SecurityService.java
new file mode 100644
index 000000000..33c06cf22
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/security/SecurityService.java
@@ -0,0 +1,234 @@
+/*
+ * 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.api.security;
+
+import javax.security.auth.Subject;
+import java.security.Principal;
+import java.util.Set;
+
+/**
+ * A service to manage security-related operations in Apache Unomi.
+ * This service provides comprehensive security management including:
+ * - Subject management (authentication and authorization)
+ * - Role-based access control (RBAC)
+ * - Tenant isolation and access control
+ * - Operation validation
+ * - System and privileged operations
+ * - Encryption key management
+ */
+public interface SecurityService {
+ /** The system tenant identifier used for system-wide operations */
+ String SYSTEM_TENANT = "SYSTEM_TENANT";
+
+ /**
+ * Retrieves the current subject from the security context. The subject is determined in the following order:
+ * 1. JAAS context - If a JAAS authentication is active
+ * 2. Privileged subject - If a temporary privileged operation is in progress
+ * 3. Current request subject - The subject associated with the current request
+ *
+ * @return the current subject or null if no subject is set in any context
+ */
+ Subject getCurrentSubject();
+
+ /**
+ * Retrieves the current principal from the active subject.
+ * The principal represents the primary identity of the authenticated entity.
+ *
+ * @return the current principal or null if no subject is set or the subject has no principals
+ */
+ Principal getCurrentPrincipal();
+
+ /**
+ * Sets the current request subject and updates the tenant context accordingly.
+ * This is typically called during authentication to establish the security context.
+ * The tenant context will be updated based on the subject's tenant ID.
+ *
+ * @param subject the subject to set as the current request subject
+ */
+ void setCurrentSubject(Subject subject);
+
+ /**
+ * Clears all security contexts including:
+ * - JAAS context
+ * - Privileged subject
+ * - Current request subject
+ * This should be called when cleaning up after request processing or when switching contexts.
+ */
+ void clearCurrentSubject();
+
+ /**
+ * Checks if the current context has a specific role by examining subjects in the following order:
+ * 1. JAAS context
+ * 2. Privileged subject
+ * 3. Current request subject
+ *
+ * @param role the role to check for (e.g., ROLE_UNOMI_ADMIN, ROLE_UNOMI_TENANT_USER)
+ * @return true if any active subject has the specified role, false otherwise
+ */
+ boolean hasRole(String role);
+
+ /**
+ * Checks if the current context has a specific permission by examining subjects in order:
+ * 1. JAAS context
+ * 2. Privileged subject
+ * 3. Current request subject
+ *
+ * Permissions are currently mapped directly to roles but may be enhanced in future versions.
+ *
+ * @param permission the permission to check for
+ * @return true if any active subject has the specified permission, false otherwise
+ */
+ boolean hasPermission(String permission);
+
+ /**
+ * Executes code with temporarily elevated privileges using the specified subject.
+ * The privileged subject will be available only during the execution of the operation
+ * and will be automatically cleaned up afterward, restoring the previous context.
+ *
+ * This is useful for operations that require temporary elevation of privileges.
+ *
+ * @param privilegedSubject the subject with elevated privileges to use during execution
+ * @param operation the operation to execute with elevated privileges
+ */
+ void executeWithPrivilegedSubject(Subject privilegedSubject, Runnable operation);
+
+ /**
+ * Retrieves the current tenant ID based on the active subject context.
+ * The tenant ID is determined from the subject's principal.
+ *
+ * @return the current tenant ID, or SYSTEM_TENANT if operating in system context
+ */
+ String getCurrentSubjectTenantId();
+
+ /**
+ * Checks if the current operation is being performed in the system tenant context.
+ * System tenant operations have special privileges and bypass tenant isolation.
+ *
+ * @return true if operating in the system tenant context, false otherwise
+ */
+ boolean isOperatingOnSystemTenant();
+
+ /**
+ * Retrieves the encryption key for a specific tenant.
+ * This key is used for encrypting sensitive data within the tenant's context.
+ *
+ * @param tenantId the ID of the tenant whose encryption key should be retrieved
+ * @return the tenant's encryption key as a byte array, or null if encryption is not configured
+ */
+ byte[] getTenantEncryptionKey(String tenantId);
+
+ /**
+ * Logs a tenant operation for auditing purposes.
+ * This creates an audit trail of security-relevant operations performed within each tenant.
+ *
+ * @param tenantId the ID of the tenant where the operation was performed
+ * @param operation the type of operation that was performed
+ */
+ void auditTenantOperation(String tenantId, String operation);
+
+ /**
+ * Sets a temporary privileged subject for operations requiring elevated permissions.
+ * The privileged subject will be used in addition to the current subject for permission checks.
+ *
+ * Note: This is different from executeWithPrivilegedSubject as it doesn't automatically clean up.
+ * You must call clearPrivilegedSubject() when the elevated privileges are no longer needed.
+ *
+ * @param subject the privileged subject to set
+ */
+ void setPrivilegedSubject(Subject subject);
+
+ /**
+ * Clears the temporary privileged subject.
+ * This should be called after operations requiring elevated privileges are complete.
+ */
+ void clearPrivilegedSubject();
+
+ /**
+ * Checks if the current subject has administrative privileges.
+ * An admin has elevated privileges within their scope but may still be restricted by tenant boundaries.
+ *
+ * @return true if the current subject has the ROLE_UNOMI_ADMIN role, false otherwise
+ */
+ boolean isAdmin();
+
+ /**
+ * Checks if the current subject has access to a specific tenant.
+ * Access is granted if any of the following conditions are met:
+ * - The subject has the ROLE_UNOMI_SYSTEM role
+ * - The subject is an admin of the specified tenant
+ * - The subject belongs to the specified tenant
+ *
+ * @param tenantId the ID of the tenant to check access for
+ * @return true if the subject has access to the tenant, false otherwise
+ */
+ boolean hasTenantAccess(String tenantId);
+
+ /**
+ * Checks if the current subject has system-level access.
+ * This includes both administrator and tenant administrator roles.
+ *
+ * @return true if the current subject has system-level access, false otherwise
+ */
+ boolean hasSystemAccess();
+
+ /**
+ * Get the system subject with administrative privileges
+ * @return the system subject
+ */
+ Subject getSystemSubject();
+
+ /**
+ * Extract roles from a subject
+ * @param subject the subject to extract roles from
+ * @return set of role names
+ */
+ Set<String> extractRolesFromSubject(Subject subject);
+
+ /**
+ * Get the security service configuration
+ * @return the security configuration
+ */
+ SecurityServiceConfiguration getConfiguration();
+
+ /**
+ * Gets all permissions associated with a specific role based on the security configuration.
+ *
+ * @param role The role name to retrieve permissions for. This should be one of the standard
+ * roles defined in {@link UnomiRoles} or a custom role defined in the security
+ * configuration.
+ *
+ * @return A Set of String containing all permissions granted to the specified role. The permissions
+ * are derived from the security configuration's operation roles mapping. If the role has no
+ * explicitly mapped permissions, or if the configuration is not properly set up, an empty
+ * Set will be returned.
+ *
+ * @see SecurityServiceConfiguration#getPermissionRoles()
+ * @see UnomiRoles
+ */
+ Set<String> getPermissionsForRole(String role);
+
+ /**
+ * Creates a new Subject with the appropriate principals for a tenant.
+ * The subject will be created with the tenant principal and appropriate roles
+ * based on whether it's a private or public access.
+ *
+ * @param tenantId the ID of the tenant to create the subject for
+ * @param isPrivate whether to create a subject with private (admin) access or public access
+ * @return a new Subject configured with the appropriate principals and roles
+ */
+ Subject createSubject(String tenantId, boolean isPrivate);
+}
diff --git a/api/src/main/java/org/apache/unomi/api/security/SecurityServiceConfiguration.java b/api/src/main/java/org/apache/unomi/api/security/SecurityServiceConfiguration.java
new file mode 100644
index 000000000..f3ccc0d31
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/security/SecurityServiceConfiguration.java
@@ -0,0 +1,120 @@
+/*
+ * 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.api.security;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Configuration for the Security Service
+ */
+public class SecurityServiceConfiguration {
+ // Permission constants
+ public static final String PERMISSION_QUERY = "QUERY";
+ public static final String PERMISSION_AGGREGATE = "AGGREGATE";
+ public static final String PERMISSION_SCROLL_QUERY = "SCROLL_QUERY";
+ public static final String PERMISSION_SAVE = "SAVE";
+ public static final String PERMISSION_UPDATE = "UPDATE";
+ public static final String PERMISSION_DELETE = "DELETE";
+ public static final String PERMISSION_REMOVE_BY_QUERY = "REMOVE_BY_QUERY";
+ public static final String PERMISSION_PURGE = "PURGE";
+ public static final String PERMISSION_SYSTEM_MAINTENANCE = "SYSTEM_MAINTENANCE";
+ public static final String PERMISSION_ENCRYPT_PROFILE_DATA = "ENCRYPT_PROFILE_DATA";
+ public static final String PERMISSION_DECRYPT_PROFILE_DATA = "DECRYPT_PROFILE_DATA";
+ public static final String PERMISSION_SCHEMA_WRITE = "SCHEMA_WRITE";
+ public static final String PERMISSION_SCHEMA_DELETE = "SCHEMA_DELETE";
+
+ private Map<String, String[]> permissionRoles;
+ private String defaultRole;
+ private Set<String> systemRoles = new HashSet<>();
+ private boolean enableEncryption = false;
+
+ public SecurityServiceConfiguration() {
+ // Initialize default system roles
+ systemRoles.add(UnomiRoles.ADMINISTRATOR);
+ systemRoles.add(UnomiRoles.TENANT_ADMINISTRATOR);
+
+ // Initialize default operation roles
+ permissionRoles = new HashMap<>();
+ permissionRoles.put(PERMISSION_QUERY, new String[]{UnomiRoles.USER, UnomiRoles.TENANT_ADMINISTRATOR});
+ permissionRoles.put(PERMISSION_AGGREGATE, new String[]{UnomiRoles.USER, UnomiRoles.TENANT_ADMINISTRATOR});
+ permissionRoles.put(PERMISSION_SCROLL_QUERY, new String[]{UnomiRoles.USER, UnomiRoles.TENANT_ADMINISTRATOR});
+ permissionRoles.put(PERMISSION_SAVE, new String[]{UnomiRoles.USER, UnomiRoles.TENANT_ADMINISTRATOR});
+ permissionRoles.put(PERMISSION_UPDATE, new String[]{UnomiRoles.USER, UnomiRoles.TENANT_ADMINISTRATOR});
+ permissionRoles.put(PERMISSION_DELETE, new String[]{UnomiRoles.USER, UnomiRoles.TENANT_ADMINISTRATOR});
+ permissionRoles.put(PERMISSION_REMOVE_BY_QUERY, new String[]{UnomiRoles.USER, UnomiRoles.TENANT_ADMINISTRATOR});
+ permissionRoles.put(PERMISSION_PURGE, new String[]{UnomiRoles.SYSTEM_MAINTENANCE, UnomiRoles.TENANT_ADMINISTRATOR});
+ permissionRoles.put(PERMISSION_SYSTEM_MAINTENANCE, new String[]{UnomiRoles.SYSTEM_MAINTENANCE});
+ permissionRoles.put(PERMISSION_ENCRYPT_PROFILE_DATA, new String[]{UnomiRoles.PROFILE_ENCRYPT});
+ permissionRoles.put(PERMISSION_DECRYPT_PROFILE_DATA, new String[]{UnomiRoles.PROFILE_DECRYPT});
+ permissionRoles.put(PERMISSION_SCHEMA_WRITE, new String[]{UnomiRoles.TENANT_ADMINISTRATOR});
+ permissionRoles.put(PERMISSION_SCHEMA_DELETE, new String[]{UnomiRoles.TENANT_ADMINISTRATOR});
+ defaultRole = UnomiRoles.USER;
+ }
+
+ public Map<String, String[]> getPermissionRoles() {
+ return permissionRoles;
+ }
+
+ public void setPermissionRoles(Map<String, String[]> permissionRoles) {
+ this.permissionRoles = permissionRoles;
+ }
+
+ public String getDefaultRole() {
+ return defaultRole;
+ }
+
+ public void setDefaultRole(String defaultRole) {
+ this.defaultRole = defaultRole;
+ }
+
+ /**
+ * Get required roles for an permission
+ * @param permission the permission to check
+ * @return array of required roles, or array containing default role if permission not mapped
+ */
+ public String[] getRequiredRolesForPermission(String permission) {
+ return permissionRoles.getOrDefault(permission, new String[]{defaultRole});
+ }
+
+ public Set<String> getSystemRoles() {
+ return systemRoles;
+ }
+
+ public void setSystemRoles(Set<String> systemRoles) {
+ this.systemRoles = systemRoles;
+ }
+
+ public void addSystemRole(String role) {
+ systemRoles.add(role);
+ }
+
+ public void removeSystemRole(String role) {
+ systemRoles.remove(role);
+ }
+
+ public boolean isEnableEncryption() {
+ return enableEncryption;
+ }
+
+ public void setEnableEncryption(boolean enableEncryption) {
+ this.enableEncryption = enableEncryption;
+ }
+
+}
diff --git a/api/src/main/java/org/apache/unomi/api/security/TenantPrincipal.java b/api/src/main/java/org/apache/unomi/api/security/TenantPrincipal.java
new file mode 100644
index 000000000..1b0e3d0d0
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/security/TenantPrincipal.java
@@ -0,0 +1,74 @@
+/*
+ * 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.api.security;
+
+import java.security.Principal;
+import java.util.Objects;
+
+/**
+ * A Principal that represents a tenant's identity in the system.
+ * This is used to explicitly identify which tenant a Subject belongs to,
+ * separate from any roles or user identity the Subject may have.
+ */
+public class TenantPrincipal implements Principal {
+ private final String tenantId;
+
+ /**
+ * Creates a new TenantPrincipal for the specified tenant.
+ *
+ * @param tenantId the ID of the tenant this principal represents
+ */
+ public TenantPrincipal(String tenantId) {
+ if (tenantId == null) {
+ throw new IllegalArgumentException("Tenant ID cannot be null");
+ }
+ this.tenantId = tenantId;
+ }
+
+ /**
+ * Gets the tenant ID associated with this principal.
+ * This is equivalent to getName() but more semantically clear.
+ *
+ * @return the tenant ID
+ */
+ public String getTenantId() {
+ return tenantId;
+ }
+
+ @Override
+ public String getName() {
+ return tenantId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ TenantPrincipal that = (TenantPrincipal) o;
+ return Objects.equals(tenantId, that.tenantId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(tenantId);
+ }
+
+ @Override
+ public String toString() {
+ return "TenantPrincipal[" + tenantId + "]";
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/security/UnomiRoles.java b/api/src/main/java/org/apache/unomi/api/security/UnomiRoles.java
new file mode 100644
index 000000000..98f568fa2
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/security/UnomiRoles.java
@@ -0,0 +1,113 @@
+/*
+ * 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.api.security;
+
+/**
+ * Constants for roles in Unomi.
+ */
+public final class UnomiRoles {
+
+ private UnomiRoles() {
+ // Prevent instantiation
+ }
+
+ /**
+ * Role for administrators with full system access
+ */
+ public static final String ADMINISTRATOR = "ROLE_UNOMI_ADMIN";
+
+ /**
+ * Role for tenant administrators
+ */
+ public static final String TENANT_ADMINISTRATOR = "ROLE_UNOMI_TENANT_ADMIN";
+
+ /**
+ * Role for regular users
+ */
+ public static final String USER = "ROLE_UNOMI_TENANT_USER";
+
+ /**
+ * Role for anonymous users
+ */
+ public static final String ANONYMOUS = "ROLE_UNOMI_ANONYMOUS";
+
+ /**
+ * Role for system-level operations
+ */
+ public static final String SYSTEM = "ROLE_UNOMI_SYSTEM";
+
+ /**
+ * Role for public tenant access
+ */
+ public static final String TENANT_PUBLIC = "ROLE_UNOMI_TENANT_PUBLIC";
+
+ /**
+ * Role for private tenant access
+ */
+ public static final String TENANT_PRIVATE = "ROLE_UNOMI_TENANT_PRIVATE";
+
+ /**
+ * Prefix for tenant-specific user roles
+ */
+ public static final String TENANT_USER_PREFIX = "ROLE_UNOMI_TENANT_USER_";
+
+ /**
+ * Prefix for tenant-specific admin roles
+ */
+ public static final String TENANT_ADMIN_PREFIX = "ROLE_UNOMI_TENANT_ADMIN_";
+
+ /**
+ * Role for profile encryption operations
+ */
+ public static final String PROFILE_ENCRYPT = "ROLE_UNOMI_PROFILE_ENCRYPT";
+
+ /**
+ * Role for profile decryption operations
+ */
+ public static final String PROFILE_DECRYPT = "ROLE_UNOMI_PROFILE_DECRYPT";
+
+ /**
+ * Permission for system maintenance operations
+ */
+ public static final String SYSTEM_MAINTENANCE = "ROLE_SYSTEM_MAINTENANCE";
+
+ /**
+ * Role for guest access
+ */
+ public static final String GUEST = "ROLE_UNOMI_GUEST";
+
+ /**
+ * Role for public API access
+ */
+ public static final String PUBLIC = "ROLE_UNOMI_PUBLIC";
+
+ /**
+ * Role for system operations
+ */
+ public static final String SYSTEM_OPERATIONS = "ROLE_UNOMI_SYSTEM_OPERATIONS";
+
+ /**
+ * Role for tenant operations
+ */
+ public static final String TENANT_OPERATIONS = "ROLE_UNOMI_TENANT_OPERATIONS";
+
+ /**
+ * Role for profile operations
+ */
+ public static final String PROFILE_OPERATIONS = "ROLE_UNOMI_PROFILE_OPERATIONS";
+
+}
diff --git a/api/src/main/java/org/apache/unomi/api/services/DefinitionsService.java b/api/src/main/java/org/apache/unomi/api/services/DefinitionsService.java
index b4cf75a68..13a4943da 100644
--- a/api/src/main/java/org/apache/unomi/api/services/DefinitionsService.java
+++ b/api/src/main/java/org/apache/unomi/api/services/DefinitionsService.java
@@ -147,6 +147,20 @@ public interface DefinitionsService {
*/
ValueType getValueType(String id);
+ /**
+ * Stores the value type
+ *
+ * @param valueType the value type to store
+ */
+ void setValueType(ValueType valueType);
+
+ /**
+ * Remove the value type
+ *
+ * @param id the value type to remove
+ */
+ void removeValueType(String id);
+
/**
* Retrieves a Map of plugin identifier to a list of plugin types defined by that particular plugin.
*
@@ -162,6 +176,27 @@ public interface DefinitionsService {
*/
PropertyMergeStrategyType getPropertyMergeStrategyType(String id);
+ /**
+ * Stores the property merge strategy type
+ *
+ * @param propertyMergeStrategyType the property merge strategy type to store
+ */
+ void setPropertyMergeStrategyType(PropertyMergeStrategyType propertyMergeStrategyType);
+
+ /**
+ * Remove the property merge strategy type
+ *
+ * @param id the property merge strategy type to remove
+ */
+ void removePropertyMergeStrategyType(String id);
+
+ /**
+ * Retrieves all known property merge strategy types.
+ *
+ * @return all known property merge strategy types
+ */
+ Collection<PropertyMergeStrategyType> getAllPropertyMergeStrategyTypes();
+
/**
* Retrieves all conditions of the specified type from the specified root condition.
*
@@ -171,7 +206,7 @@ public interface DefinitionsService {
* @param typeId the identifier of the condition type we want conditions to extract to match
* @return a set of conditions contained in the specified root condition and matching the specified condition type or an empty set if no such condition exists
*/
- Set<Condition> extractConditionsByType(Condition rootCondition, String typeId);
+ List<Condition> extractConditionsByType(Condition rootCondition, String typeId);
/**
* Retrieves a condition matching the specified tag identifier from the specified root condition.
diff --git a/api/src/main/java/org/apache/unomi/api/services/EventService.java b/api/src/main/java/org/apache/unomi/api/services/EventService.java
index 64ca1beeb..d68a2c703 100644
--- a/api/src/main/java/org/apache/unomi/api/services/EventService.java
+++ b/api/src/main/java/org/apache/unomi/api/services/EventService.java
@@ -61,22 +61,22 @@ public interface EventService {
int send(Event event);
/**
- * Check if the sender is allowed to sent the speecified event. Restricted event must be explicitely allowed for a sender.
+ * Check if the tenant is allowed to send the specified event. Restricted events must be explicitly allowed for a tenant.
*
- * @param event event to test
- * @param thirdPartyId third party id
- * @return true if the event is allowed
+ * @param event event to test
+ * @param tenantId the ID of the tenant
+ * @param sourceIP the IP address from which the event was sent (not persisted for privacy)
+ * @return true if the event is allowed for the tenant
*/
- boolean isEventAllowed(Event event, String thirdPartyId);
+ boolean isEventAllowedForTenant(Event event, String tenantId, String sourceIP);
/**
- * Get the third party server name, if the request is originated from a known peer
+ * Retrieves the list of available event properties.
*
- * @param key the key
- * @param ip the ip
- * @return server name
+ * @return a list of available event properties
+ * @deprecated use event types instead
*/
- String authenticateThirdPartyServer(String key, String ip);
+ List<EventProperty> getEventProperties();
/**
* Retrieves the set of known event type identifiers.
@@ -155,8 +155,7 @@ public interface EventService {
void removeProfileEvents(String profileId);
/**
- * Deletes the event identified by the given identifier from persistence.
- *
+ * Delete an event by specifying its event identifier
* @param eventIdentifier the unique identifier for the event
*/
void deleteEvent(String eventIdentifier);
diff --git a/api/src/main/java/org/apache/unomi/api/services/ExecutionContextManager.java b/api/src/main/java/org/apache/unomi/api/services/ExecutionContextManager.java
new file mode 100644
index 000000000..da1ab18a0
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/services/ExecutionContextManager.java
@@ -0,0 +1,78 @@
+/*
+ * 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.api.services;
+
+import org.apache.unomi.api.ExecutionContext;
+
+import java.util.function.Supplier;
+
+/**
+ * Service interface for managing execution contexts in Unomi.
+ */
+public interface ExecutionContextManager {
+
+ /**
+ * Gets the current execution context.
+ * @return the current execution context
+ */
+ ExecutionContext getCurrentContext();
+
+ /**
+ * Sets the current execution context.
+ * @param context the context to set as current
+ */
+ void setCurrentContext(ExecutionContext context);
+
+ /**
+ * Executes an operation as the system user.
+ * @param operation the operation to execute
+ * @param <T> the return type of the operation
+ * @return the result of the operation
+ */
+ <T> T executeAsSystem(Supplier<T> operation);
+
+ /**
+ * Executes an operation as the system user without return value.
+ * @param operation the operation to execute
+ */
+ void executeAsSystem(Runnable operation);
+
+ /**
+ * Executes an operation as a specific tenant.
+ * This method creates a tenant context, executes the operation, and ensures proper cleanup.
+ * @param tenantId the ID of the tenant to execute as
+ * @param operation the operation to execute
+ * @param <T> the return type of the operation
+ * @return the result of the operation
+ */
+ <T> T executeAsTenant(String tenantId, Supplier<T> operation);
+
+ /**
+ * Executes an operation as a specific tenant without return value.
+ * This method creates a tenant context, executes the operation, and ensures proper cleanup.
+ * @param tenantId the ID of the tenant to execute as
+ * @param operation the operation to execute
+ */
+ void executeAsTenant(String tenantId, Runnable operation);
+
+ /**
+ * Creates a new execution context for the given tenant.
+ * @param tenantId the tenant ID
+ * @return the created execution context
+ */
+ ExecutionContext createContext(String tenantId);
+}
diff --git a/api/src/main/java/org/apache/unomi/api/services/ProfileService.java b/api/src/main/java/org/apache/unomi/api/services/ProfileService.java
index bd4c53706..566e6e427 100644
--- a/api/src/main/java/org/apache/unomi/api/services/ProfileService.java
+++ b/api/src/main/java/org/apache/unomi/api/services/ProfileService.java
@@ -217,13 +217,6 @@ public interface ProfileService {
*/
void removeProfileSessions(String profileId);
- /**
- * Deletes the session identified by the given identifier from persistence.
- *
- * @param sessionIdentifier the unique identifier for the session
- */
- void deleteSession(String sessionIdentifier);
-
/**
* Checks whether the specified profile and/or session satisfy the specified condition.
*
@@ -285,7 +278,7 @@ public interface ProfileService {
* a column ({@code :}) and an order specifier: {@code asc} or {@code desc}.
* @return a {@link PartialList} of sessions for the persona identified by the specified identifier
*/
- PartialList<Session> getPersonaSessions(String personaId, int offset, int size, String sortBy);
+ PartialList<PersonaSession> getPersonaSessions(String personaId, int offset, int size, String sortBy);
/**
* Save a persona with its sessions.
@@ -450,4 +443,10 @@ public interface ProfileService {
*/
@Deprecated
void purgeMonthlyItems(int existsNumberOfMonths);
+
+ /**
+ * Delete a session using its identifier
+ * @param sessionIdentifier the unique identifier for the session
+ */
+ void deleteSession(String sessionIdentifier);
}
diff --git a/api/src/main/java/org/apache/unomi/api/services/SchedulerService.java b/api/src/main/java/org/apache/unomi/api/services/SchedulerService.java
index 1458bf746..8f6a401f7 100644
--- a/api/src/main/java/org/apache/unomi/api/services/SchedulerService.java
+++ b/api/src/main/java/org/apache/unomi/api/services/SchedulerService.java
@@ -17,28 +17,391 @@
package org.apache.unomi.api.services;
-import java.util.concurrent.ExecutorService;
+import org.apache.unomi.api.PartialList;
+import org.apache.unomi.api.tasks.ScheduledTask;
+import org.apache.unomi.api.tasks.TaskExecutor;
+
+import java.util.List;
+import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
/**
- * A service to centralize scheduling of tasks instead of using Timers or executors in each service
+ * Service for scheduling and managing tasks in a cluster-aware manner.
+ * This service provides comprehensive task scheduling capabilities including:
+ * <ul>
+ * <li>Task creation and lifecycle management</li>
+ * <li>Cluster-aware task execution and coordination</li>
+ * <li>Task recovery after node failures</li>
+ * <li>Support for persistent and in-memory tasks</li>
+ * <li>Task dependency management</li>
+ * <li>Execution history and metrics tracking</li>
+ * </ul>
*
- * https://stackoverflow.com/questions/409932/java-timer-vs-executorservice
+ * The service supports both single-node and clustered environments, ensuring
+ * tasks are executed reliably and efficiently across the cluster.
*/
public interface SchedulerService {
/**
- * Use this method to get a {@link ScheduledExecutorService}
- * and execute your task with it instead of using {@link java.util.Timer}
+ * Creates a new scheduled task.
+ * This method provides full control over task configuration including
+ * execution timing, persistence, and parallel execution settings.
+ * The task can be either persistent (stored in persistence service and
+ * visible across the cluster) or non-persistent (stored only in memory
+ * on the local node).
*
- * @return {@link ScheduledExecutorService}
+ * @param taskType unique identifier for the task type
+ * @param parameters task-specific parameters
+ * @param initialDelay delay before first execution
+ * @param period period between executions (0 for one-shot tasks)
+ * @param timeUnit time unit for delay and period
+ * @param fixedRate whether to use fixed rate (true) or fixed delay (false)
+ * @param oneShot whether this is a one-time task
+ * @param allowParallelExecution whether parallel execution is allowed
+ * @param persistent whether to store the task in persistence service (true) or only in memory (false)
+ * @return the created task instance
+ * @throws IllegalArgumentException if task configuration is invalid
*/
- ScheduledExecutorService getScheduleExecutorService();
+ ScheduledTask createTask(String taskType,
+ Map<String, Object> parameters,
+ long initialDelay,
+ long period,
+ TimeUnit timeUnit,
+ boolean fixedRate,
+ boolean oneShot,
+ boolean allowParallelExecution,
+ boolean persistent);
/**
- * Same as getScheduleExecutorService but use a shared pool of ScheduledExecutor instead of single one.
- * Use this service is your tasks can be run in parallel of the others.
- * @return {@link ScheduledExecutorService}
+ * Schedules an existing task for execution.
+ * The task will be validated and scheduled according to its configuration.
+ * For periodic tasks, this sets up recurring execution.
+ *
+ * @param task the task to schedule
+ * @throws IllegalArgumentException if task configuration is invalid
*/
- ScheduledExecutorService getSharedScheduleExecutorService();
+ void scheduleTask(ScheduledTask task);
+
+ /**
+ * Cancels a scheduled task.
+ * This will stop any current execution and prevent future executions.
+ * The task remains in storage but is marked as cancelled.
+ *
+ * @param taskId the task ID to cancel
+ */
+ void cancelTask(String taskId);
+
+ /**
+ * Gets all tasks from both storage and memory.
+ * This provides a complete view of all tasks in the system,
+ * both persistent and in-memory.
+ *
+ * @return combined list of all tasks
+ */
+ List<ScheduledTask> getAllTasks();
+
+ /**
+ * Gets a task by ID from either storage or memory.
+ * This will search both persistent storage and in-memory tasks.
+ *
+ * @param taskId the task ID
+ * @return the task or null if not found
+ */
+ ScheduledTask getTask(String taskId);
+
+ /**
+ * Gets all tasks stored in memory.
+ * These are non-persistent tasks that exist only on this node.
+ *
+ * @return list of all in-memory tasks
+ */
+ List<ScheduledTask> getMemoryTasks();
+
+ /**
+ * Gets all tasks from persistent storage.
+ * These tasks are visible across the cluster.
+ *
+ * @return list of all persistent tasks
+ */
+ List<ScheduledTask> getPersistentTasks();
+
+ /**
+ * Registers a task executor.
+ * The executor will be used to execute tasks of its declared type.
+ *
+ * @param executor the executor to register
+ */
+ void registerTaskExecutor(TaskExecutor executor);
+
+ /**
+ * Unregisters a task executor.
+ * Tasks of this type will no longer be executed on this node.
+ *
+ * @param executor the executor to unregister
+ */
+ void unregisterTaskExecutor(TaskExecutor executor);
+
+ /**
+ * Checks if this node is a task executor node.
+ * Executor nodes are responsible for executing tasks in the cluster.
+ *
+ * @return true if this node executes tasks
+ */
+ boolean isExecutorNode();
+
+ /**
+ * Gets the node ID of this scheduler instance.
+ * This ID uniquely identifies this node in the cluster.
+ *
+ * @return the node ID
+ */
+ String getNodeId();
+
+ /**
+ * Gets tasks with the specified status.
+ * This allows filtering tasks by their current state.
+ * The results include both persistent and in-memory tasks.
+ *
+ * @param status the task status to filter by
+ * @param offset the starting offset for pagination
+ * @param size the maximum number of tasks to return
+ * @param sortBy optional sort field (null for default sorting)
+ * @return partial list of matching tasks
+ */
+ PartialList<ScheduledTask> getTasksByStatus(ScheduledTask.TaskStatus status, int offset, int size, String sortBy);
+
+ /**
+ * Gets tasks for a specific executor type.
+ * This allows filtering tasks by their type.
+ * The results include both persistent and in-memory tasks.
+ *
+ * @param taskType the task type to filter by
+ * @param offset the starting offset for pagination
+ * @param size the maximum number of tasks to return
+ * @param sortBy optional sort field (null for default sorting)
+ * @return partial list of matching tasks
+ */
+ PartialList<ScheduledTask> getTasksByType(String taskType, int offset, int size, String sortBy);
+
+ /**
+ * Retries a failed task.
+ * The task will be rescheduled for execution with optional
+ * failure count reset. The task must be in FAILED status
+ * for this operation to succeed.
+ *
+ * @param taskId the task ID to retry
+ * @param resetFailureCount whether to reset the failure count to 0
+ */
+ void retryTask(String taskId, boolean resetFailureCount);
+
+ /**
+ * Resumes a crashed task from its last checkpoint.
+ * This attempts to continue execution from where the task
+ * left off before crashing. The task must be in CRASHED status
+ * and have checkpoint data available for this operation to succeed.
+ *
+ * @param taskId the task ID to resume
+ */
+ void resumeTask(String taskId);
+
+ /**
+ * Checks for crashed tasks from other nodes and attempts recovery.
+ * This is part of the cluster's self-healing mechanism.
+ */
+ void recoverCrashedTasks();
+
+ /**
+ * Saves changes to an existing task.
+ * This persists the task state and configuration changes to storage.
+ *
+ * @param task the task to save
+ * @return true if the save was successful, false otherwise
+ */
+ boolean saveTask(ScheduledTask task);
+
+ /**
+ * Creates a simple recurring task with default settings.
+ * This is a convenience method for services that just need periodic execution.
+ * The task will use fixed rate scheduling and allow parallel execution.
+ * The created task will be automatically scheduled for execution.
+ *
+ * @param taskType unique identifier for the task type
+ * @param period time between executions (must be > 0)
+ * @param timeUnit unit for the period
+ * @param runnable the code to execute
+ * @param persistent whether to store in persistence service (true) or only in memory (false)
+ * @return the created and scheduled task
+ * @throws IllegalArgumentException if period <= 0 or timeUnit is null
+ */
+ ScheduledTask createRecurringTask(String taskType, long period, TimeUnit timeUnit, Runnable runnable, boolean persistent);
+
+ /**
+ * Creates a new task builder for fluent task creation.
+ * The builder pattern provides a more readable way to configure tasks
+ * with optional parameters.
+ * Example usage:
+ * <pre>
+ * schedulerService.newTask("myTask")
+ * .withPeriod(1, TimeUnit.HOURS)
+ * .withSimpleExecutor(() -> doSomething())
+ * .schedule();
+ * </pre>
+ *
+ * @param taskType unique identifier for the task type
+ * @return a builder to configure and create the task
+ */
+ TaskBuilder newTask(String taskType);
+
+ /**
+ * Gets the value of a specific metric.
+ * @param metric The metric name
+ * @return The current value of the metric
+ */
+ long getMetric(String metric);
+
+ /**
+ * Resets all metrics to zero.
+ */
+ void resetMetrics();
+
+ /**
+ * Gets all metrics as a map.
+ * @return Map of metric names to their current values
+ */
+ Map<String, Long> getAllMetrics();
+
+ List<ScheduledTask> findTasksByStatus(ScheduledTask.TaskStatus taskStatus);
+
+ /**
+ * Builder interface for fluent task creation.
+ * This interface provides methods to configure all aspects of a task
+ * in a readable manner.
+ */
+ interface TaskBuilder {
+ /**
+ * Sets task parameters.
+ * @param parameters task-specific parameters
+ */
+ TaskBuilder withParameters(Map<String, Object> parameters);
+
+ /**
+ * Sets initial execution delay.
+ * @param initialDelay delay before first execution
+ * @param timeUnit time unit for delay
+ */
+ TaskBuilder withInitialDelay(long initialDelay, TimeUnit timeUnit);
+
+ /**
+ * Sets execution period.
+ * @param period time between executions
+ * @param timeUnit time unit for period
+ */
+ TaskBuilder withPeriod(long period, TimeUnit timeUnit);
+
+ /**
+ * Uses fixed delay scheduling.
+ * Period is measured from completion of one execution to start of next.
+ */
+ TaskBuilder withFixedDelay();
+
+ /**
+ * Uses fixed rate scheduling.
+ * Period is measured from start of one execution to start of next.
+ */
+ TaskBuilder withFixedRate();
+
+ /**
+ * Makes this a one-shot task.
+ * Task will execute once and then be disabled.
+ */
+ TaskBuilder asOneShot();
+
+ /**
+ * Disallows parallel execution.
+ * Task will use locking to ensure only one instance runs at a time.
+ */
+ TaskBuilder disallowParallelExecution();
+
+ /**
+ * Sets the task executor.
+ * @param executor the executor to handle this task
+ */
+ TaskBuilder withExecutor(TaskExecutor executor);
+
+ /**
+ * Sets a simple runnable as the executor.
+ * @param runnable the code to execute
+ */
+ TaskBuilder withSimpleExecutor(Runnable runnable);
+
+ /**
+ * Makes this a non-persistent task.
+ * Task will only exist in memory on this node.
+ */
+ TaskBuilder nonPersistent();
+
+ /**
+ * Runs the task on all nodes in the cluster rather than just executor nodes.
+ * This is helpful for distributed cache refreshes or local data maintenance.
+ */
+ TaskBuilder runOnAllNodes();
+
+ /**
+ * Marks this task as a system task.
+ * System tasks are created during system initialization and should be
+ * preserved across restarts rather than being recreated.
+ *
+ * @return this builder for method chaining
+ */
+ TaskBuilder asSystemTask();
+
+ /**
+ * Sets the maximum number of retry attempts after failures.
+ * For one-shot tasks:
+ * - When a task fails, it will be automatically retried up to this many times
+ * - Each retry attempt occurs after waiting for retryDelay
+ * - After reaching this limit, the task remains in FAILED state until manually retried
+ *
+ * For periodic tasks:
+ * - Retries only apply within a single scheduled execution
+ * - If retries are exhausted, the task will still attempt its next scheduled execution
+ * - The next scheduled execution resets the failure count
+ *
+ * A value of 0 means no automatic retries in either case.
+ *
+ * @param maxRetries maximum number of retries (must be >= 0)
+ * @throws IllegalArgumentException if maxRetries is negative
+ */
+ TaskBuilder withMaxRetries(int maxRetries);
+
+ /**
+ * Sets the delay between retry attempts.
+ * For one-shot tasks:
+ * - This delay is applied between each retry attempt after a failure
+ * - Helps prevent rapid-fire retries that could overload the system
+ *
+ * For periodic tasks:
+ * - This delay is used between retry attempts within a single scheduled execution
+ * - Does not affect the task's configured period/scheduling
+ *
+ * @param delay delay duration (must be >= 0)
+ * @param unit time unit for delay
+ * @throws IllegalArgumentException if delay is negative
+ */
+ TaskBuilder withRetryDelay(long delay, TimeUnit unit);
+
+ /**
+ * Sets the task dependencies.
+ * The task will not execute until all dependencies have completed.
+ * @param taskIds IDs of tasks this task depends on
+ */
+ TaskBuilder withDependencies(String... taskIds);
+
+ /**
+ * Creates and schedules the task with current configuration.
+ * @return the created and scheduled task
+ */
+ ScheduledTask schedule();
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/services/SegmentService.java b/api/src/main/java/org/apache/unomi/api/services/SegmentService.java
index b9fdff6a9..601cd5633 100644
--- a/api/src/main/java/org/apache/unomi/api/services/SegmentService.java
+++ b/api/src/main/java/org/apache/unomi/api/services/SegmentService.java
@@ -239,4 +239,20 @@ public interface SegmentService {
* So use it carefully or execute this method in a dedicated thread.
*/
void recalculatePastEventConditions();
+
+ /**
+ * This will recalculate the past event conditions from existing rules
+ * It will also recalculate date relative Segments and Scorings (when they contains date expression conditions for example)
+ * This operation can be heavy and take time, it will:
+ * - browse existing rules to extract the past event condition,
+ * - query the matching events for those conditions,
+ * - update the corresponding profiles
+ * - reevaluate segments/scorings linked to this rules to engaged/disengaged profiles after the occurrences have been updated
+ * - reevaluate segments/scoring that contains date expressions
+ * So use it carefully or execute this method in a dedicated thread.
+ *
+ * @param sendProfileUpdateEvents if true, profileUpdated events will be sent when profiles are updated. Set to false to disable
+ * event sending (useful in tests to avoid race conditions).
+ */
+ void recalculatePastEventConditions(boolean sendProfileUpdateEvents);
}
diff --git a/tools/shell-commands/src/main/resources/requestBody/2.2.0/suffix_ids.painless b/api/src/main/java/org/apache/unomi/api/services/TenantLifecycleListener.java
similarity index 69%
copy from tools/shell-commands/src/main/resources/requestBody/2.2.0/suffix_ids.painless
copy to api/src/main/java/org/apache/unomi/api/services/TenantLifecycleListener.java
index 3b63549f6..5aab7ad78 100644
--- a/tools/shell-commands/src/main/resources/requestBody/2.2.0/suffix_ids.painless
+++ b/api/src/main/java/org/apache/unomi/api/services/TenantLifecycleListener.java
@@ -14,6 +14,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+package org.apache.unomi.api.services;
-ctx._id = ctx._id + '#ID_SUFFIX';
-ctx._source.itemId = ctx._id;
\ No newline at end of file
+/**
+ * Interface for services that need to be notified of tenant lifecycle events.
+ */
+public interface TenantLifecycleListener {
+ /**
+ * Called when a tenant is removed from the system.
+ * @param tenantId the ID of the tenant that was removed
+ */
+ void onTenantRemoved(String tenantId);
+}
\ No newline at end of file
diff --git a/services/src/main/resources/META-INF/cxs/painless/resetScoringPlan.painless b/api/src/main/java/org/apache/unomi/api/services/TriFunction.java
similarity index 55%
copy from services/src/main/resources/META-INF/cxs/painless/resetScoringPlan.painless
copy to api/src/main/java/org/apache/unomi/api/services/TriFunction.java
index 2324069d2..a833cff9c 100644
--- a/services/src/main/resources/META-INF/cxs/painless/resetScoringPlan.painless
+++ b/api/src/main/java/org/apache/unomi/api/services/TriFunction.java
@@ -14,18 +14,25 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+package org.apache.unomi.api.services;
-/*
- This script is used to reset all profiles that have a scoring plan matching the given params.scoringId
- required params:
- - params.scoringId: the ID of the Scoring plan
-*/
-
-// remove score for the given params.scoringId
-ctx._source.scores.remove(params.scoringId);
-
-// Update lastUpdated date on profile
-if (!ctx._source.containsKey("systemProperties")) {
- ctx._source.put("systemProperties", [:]);
-}
-ctx._source.systemProperties.put("lastUpdated", ZonedDateTime.ofInstant(Instant.ofEpochMilli(System.currentTimeMillis()), ZoneId.of("Z")));
\ No newline at end of file
+/**
+ * Represents a function that accepts three arguments and produces a result.
+ *
+ * @param <T> the type of the first argument
+ * @param <U> the type of the second argument
+ * @param <V> the type of the third argument
+ * @param <R> the type of the result
+ */
+@FunctionalInterface
+public interface TriFunction<T, U, V, R> {
+ /**
+ * Applies this function to the given arguments.
+ *
+ * @param t the first function argument
+ * @param u the second function argument
+ * @param v the third function argument
+ * @return the function result
+ */
+ R apply(T t, U u, V v);
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/services/cache/CacheableTypeConfig.java b/api/src/main/java/org/apache/unomi/api/services/cache/CacheableTypeConfig.java
new file mode 100644
index 000000000..3194305f7
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/services/cache/CacheableTypeConfig.java
@@ -0,0 +1,620 @@
+/*
+ * 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.api.services.cache;
+
+import org.apache.unomi.api.Item;
+import org.osgi.framework.BundleContext;
+import org.apache.unomi.api.services.TriFunction;
+
+import java.io.Serializable;
+import java.util.Comparator;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.net.URL;
+import java.util.Map;
+import java.io.InputStream;
+
+/**
+ * Configuration for a cacheable item type in Unomi.
+ *
+ * <p>This class defines how a specific type of item is cached, loaded, and processed within
+ * the Unomi caching system. It supports a comprehensive callback system for processing items
+ * at different stages of their lifecycle:</p>
+ *
+ * <h2>Callback System Overview</h2>
+ *
+ * <p>The callback system includes two major categories of callbacks:</p>
+ *
+ * <h3>1. Item-Level Processing Callbacks</h3>
+ * <p>These callbacks operate on individual items during loading and are executed in the following
+ * order of precedence (only the first applicable callback is called):</p>
+ * <ul>
+ * <li><b>urlAwareBundleItemProcessor</b>: Most specific, gets item, bundle context, and resource URL</li>
+ * <li><b>bundleItemProcessor</b>: Gets item and bundle context</li>
+ * <li><b>postProcessor</b>: Most general, gets only the item</li>
+ * </ul>
+ *
+ * <h3>2. Cache Refresh Callbacks</h3>
+ * <p>These callbacks operate after items are loaded and cached:</p>
+ * <ul>
+ * <li><b>tenantRefreshCallback</b>: Called for each tenant that has changes after refresh</li>
+ * <li><b>postRefreshCallback</b>: Called once after all tenants are processed if any changes occurred</li>
+ * </ul>
+ *
+ * <h2>Example Usage</h2>
+ *
+ * <pre>{@code
+ * // Define a cacheable type for PropertyType
+ * CacheableTypeConfig.<PropertyType>builder(PropertyType.class, "propertyType", "properties")
+ * .withInheritFromSystemTenant(true)
+ * .withRequiresRefresh(true)
+ * .withRefreshInterval(10000)
+ * .withIdExtractor(PropertyType::getItemId)
+ *
+ * // Simple post-processor example
+ * .withPostProcessor(propertyType -> {
+ * // Normalize or initialize fields
+ * if (propertyType.getPriority() == 0) {
+ * propertyType.setPriority(1);
+ * }
+ * })
+ *
+ * // URL-aware processor example
+ * .withUrlAwareBundleItemProcessor((bundleContext, propertyType, url) -> {
+ * // Extract information from the URL path
+ * if (url.getPath().contains("/profiles/")) {
+ * propertyType.setTarget("profiles");
+ * }
+ * })
+ *
+ * // Tenant-specific callback example
+ * .withTenantRefreshCallback((tenantId, oldState, newState) -> {
+ * // Process tenant-specific changes efficiently
+ * boolean hasChanges = !oldState.equals(newState);
+ * if (hasChanges) {
+ * System.out.println("Tenant " + tenantId + " property types updated");
+ * // Update tenant-specific caches or indices
+ * }
+ * })
+ *
+ * // Global callback example
+ * .withPostRefreshCallback((oldState, newState) -> {
+ * // Process cross-tenant relationships or global state
+ * System.out.println("All property types refreshed, updating type registry");
+ * // Update cross-tenant registries or perform global operations
+ * })
+ * .build();
+ * }</pre>
+ *
+ * @param <T> the type of the cacheable item
+ */
+public class CacheableTypeConfig<T extends Serializable> {
+ private final Class<T> type;
+ private final String itemType;
+ private final String metaInfPath;
+ private final boolean inheritFromSystemTenant;
+ private final boolean requiresRefresh;
+ private final long refreshInterval;
+ private final Function<T, String> idExtractor;
+ private final Consumer<T> postProcessor;
+ private final boolean hasPredefinedItems;
+ private final BiConsumer<BundleContext, T> bundleItemProcessor;
+ private final TriConsumer<BundleContext, T, URL> urlAwareBundleItemProcessor;
+ private final Comparator<URL> urlComparator;
+ private final BiConsumer<Map<String, Map<String, T>>, Map<String, Map<String, T>>> postRefreshCallback;
+ private final TriConsumer<String, Map<String, T>, Map<String, T>> tenantRefreshCallback;
+ private final TriFunction<BundleContext, URL, InputStream, T> streamProcessor;
+
+ /**
+ * Private constructor used by the builder
+ */
+ private CacheableTypeConfig(Builder<T> builder) {
+ this.type = builder.type;
+ this.itemType = builder.itemType;
+ this.metaInfPath = builder.metaInfPath;
+ this.inheritFromSystemTenant = builder.inheritFromSystemTenant;
+ this.requiresRefresh = builder.requiresRefresh;
+ this.refreshInterval = builder.refreshInterval;
+ this.idExtractor = builder.idExtractor;
+ this.postProcessor = builder.postProcessor;
+ this.hasPredefinedItems = builder.hasPredefinedItems;
+ this.bundleItemProcessor = builder.bundleItemProcessor;
+ this.urlAwareBundleItemProcessor = builder.urlAwareBundleItemProcessor;
+ this.urlComparator = builder.urlComparator;
+ this.postRefreshCallback = builder.postRefreshCallback;
+ this.tenantRefreshCallback = builder.tenantRefreshCallback;
+ this.streamProcessor = builder.streamProcessor;
+ }
+
+ /**
+ * Creates a new builder for the config
+ * @param type the class of the cacheable type
+ * @param itemType the string identifier for the type
+ * @param metaInfPath the predefined items path in META-INF/cxs
+ * @param <T> the type parameter
+ * @return a new builder
+ */
+ public static <T extends Serializable> Builder<T> builder(Class<T> type, String itemType, String metaInfPath) {
+ return new Builder<>(type, itemType, metaInfPath);
+ }
+
+ /**
+ * Get the class of the cacheable type.
+ *
+ * @return the class of the cacheable type
+ */
+ public Class<T> getType() {
+ return type;
+ }
+
+ /**
+ * Get the item type identifier.
+ *
+ * @return the item type identifier
+ */
+ public String getItemType() {
+ return itemType;
+ }
+
+ /**
+ * Get the META-INF path for predefined items.
+ *
+ * @return the META-INF path for predefined items
+ */
+ public String getMetaInfPath() {
+ return metaInfPath;
+ }
+
+ /**
+ * Check if items should be inherited from the system tenant.
+ *
+ * @return true if items should be inherited from the system tenant
+ */
+ public boolean isInheritFromSystemTenant() {
+ return inheritFromSystemTenant;
+ }
+
+ /**
+ * Check if the cache requires periodic refresh.
+ *
+ * @return true if the cache requires periodic refresh
+ */
+ public boolean isRequiresRefresh() {
+ return requiresRefresh;
+ }
+
+ /**
+ * Get the refresh interval in milliseconds.
+ *
+ * @return the refresh interval in milliseconds
+ */
+ public long getRefreshInterval() {
+ return refreshInterval;
+ }
+
+ /**
+ * Check if the type has predefined items that should be loaded from bundles.
+ *
+ * @return true if the type has predefined items
+ */
+ public boolean hasPredefinedItems() {
+ return hasPredefinedItems;
+ }
+
+ /**
+ * Check if this configuration has a bundle item processor.
+ *
+ * @return true if there is a bundle item processor
+ */
+ public boolean hasBundleItemProcessor() {
+ return bundleItemProcessor != null;
+ }
+
+ /**
+ * Get the bundle item processor that handles bundle-specific processing.
+ *
+ * @return the bundle item processor
+ */
+ public BiConsumer<BundleContext, T> getBundleItemProcessor() {
+ return bundleItemProcessor;
+ }
+
+ /**
+ * Get the ID extractor function.
+ *
+ * @return the ID extractor function
+ */
+ public Function<T, String> getIdExtractor() {
+ return idExtractor;
+ }
+
+ /**
+ * Get the post-processor for items.
+ *
+ * @return the post-processor for items
+ */
+ public Consumer<T> getPostProcessor() {
+ return postProcessor;
+ }
+
+ /**
+ * Check if items of this type are persistable.
+ * An item is persistable if it extends Item.
+ *
+ * @return true if items of this type are persistable
+ */
+ public boolean isPersistable() {
+ return Item.class.isAssignableFrom(type);
+ }
+
+ /**
+ * Get the URL comparator for sorting predefined items.
+ *
+ * @return the URL comparator, or null if none is defined
+ */
+ public Comparator<URL> getUrlComparator() {
+ return urlComparator;
+ }
+
+ /**
+ * Check if this type config has a custom URL comparator.
+ *
+ * @return true if a URL comparator is defined, false otherwise
+ */
+ public boolean hasUrlComparator() {
+ return urlComparator != null;
+ }
+
+ /**
+ * Check if this type config has a URL-aware bundle item processor.
+ *
+ * @return true if a URL-aware bundle item processor is defined, false otherwise
+ */
+ public boolean hasUrlAwareBundleItemProcessor() {
+ return urlAwareBundleItemProcessor != null;
+ }
+
+ /**
+ * Get the URL-aware bundle item processor that handles bundle-specific processing.
+ *
+ * @return the URL-aware bundle item processor
+ */
+ public TriConsumer<BundleContext, T, URL> getUrlAwareBundleItemProcessor() {
+ return urlAwareBundleItemProcessor;
+ }
+
+ /**
+ * Check if this type config has a post-refresh callback.
+ *
+ * @return true if a post-refresh callback is defined, false otherwise
+ */
+ public boolean hasPostRefreshCallback() {
+ return postRefreshCallback != null;
+ }
+
+ /**
+ * Get the post-refresh callback that is executed after all items across all tenants have been reloaded.
+ * The callback receives both old and new states for change detection.
+ *
+ * @return the post-refresh callback
+ */
+ public BiConsumer<Map<String, Map<String, T>>, Map<String, Map<String, T>>> getPostRefreshCallback() {
+ return postRefreshCallback;
+ }
+
+ /**
+ * Check if this type config has a tenant-specific refresh callback.
+ *
+ * @return true if a tenant-specific refresh callback is defined, false otherwise
+ */
+ public boolean hasTenantRefreshCallback() {
+ return tenantRefreshCallback != null;
+ }
+
+ /**
+ * Get the tenant-specific refresh callback that is executed after each tenant's items have been reloaded.
+ * The callback receives the tenant ID, old state, and new state for that specific tenant.
+ *
+ * @return the tenant-specific refresh callback
+ */
+ public TriConsumer<String, Map<String, T>, Map<String, T>> getTenantRefreshCallback() {
+ return tenantRefreshCallback;
+ }
+
+ /**
+ * Check if this configuration has a stream processor.
+ *
+ * @return true if there is a stream processor
+ */
+ public boolean hasStreamProcessor() {
+ return streamProcessor != null;
+ }
+
+ /**
+ * Get the stream processor that handles direct input stream processing.
+ *
+ * @return the stream processor
+ */
+ public TriFunction<BundleContext, URL, InputStream, T> getStreamProcessor() {
+ return streamProcessor;
+ }
+
+ /**
+ * Builder for CacheableTypeConfig
+ * @param <T> the type parameter for the cacheable type
+ */
+ public static class Builder<T extends Serializable> {
+ private final Class<T> type;
+ private final String itemType;
+ private final String metaInfPath;
+ private boolean inheritFromSystemTenant = false;
+ private boolean requiresRefresh = false;
+ private long refreshInterval = 0;
+ private Function<T, String> idExtractor;
+ private Consumer<T> postProcessor = null;
+ private boolean hasPredefinedItems = true;
+ private BiConsumer<BundleContext, T> bundleItemProcessor = null;
+ private TriConsumer<BundleContext, T, URL> urlAwareBundleItemProcessor = null;
+ private Comparator<URL> urlComparator = null;
+ private BiConsumer<Map<String, Map<String, T>>, Map<String, Map<String, T>>> postRefreshCallback = null;
+ private TriConsumer<String, Map<String, T>, Map<String, T>> tenantRefreshCallback = null;
+ private TriFunction<BundleContext, URL, InputStream, T> streamProcessor = null;
+
+ private Builder(Class<T> type, String itemType, String metaInfPath) {
+ this.type = type;
+ this.itemType = itemType;
+ this.metaInfPath = metaInfPath;
+ }
+
+ /**
+ * Set whether items should be inherited from the system tenant.
+ *
+ * <p>When set to true, items defined in the system tenant will be available to all tenants.
+ * This is useful for sharing base configurations across multiple tenants.</p>
+ *
+ * @param inheritFromSystemTenant whether items should be inherited from the system tenant
+ * @return this builder for method chaining
+ */
+ public Builder<T> withInheritFromSystemTenant(boolean inheritFromSystemTenant) {
+ this.inheritFromSystemTenant = inheritFromSystemTenant;
+ return this;
+ }
+
+ /**
+ * Set whether the cache requires periodic refresh.
+ *
+ * <p>When set to true, the cache will be refreshed at regular intervals defined by
+ * {@link #withRefreshInterval(long)}. This is useful for items that change frequently
+ * or need to be synchronized with external systems.</p>
+ *
+ * @param requiresRefresh whether the cache requires periodic refresh
+ * @return this builder for method chaining
+ */
+ public Builder<T> withRequiresRefresh(boolean requiresRefresh) {
+ this.requiresRefresh = requiresRefresh;
+ return this;
+ }
+
+ /**
+ * Set the refresh interval in milliseconds.
+ *
+ * <p>This setting is only used when {@link #withRequiresRefresh(boolean)} is set to true.
+ * The cache will be refreshed at this interval after the initial loading.</p>
+ *
+ * @param refreshInterval the refresh interval in milliseconds
+ * @return this builder for method chaining
+ */
+ public Builder<T> withRefreshInterval(long refreshInterval) {
+ this.refreshInterval = refreshInterval;
+ return this;
+ }
+
+ /**
+ * Set the ID extractor function.
+ *
+ * <p>This function is called during item loading and caching to extract a unique identifier
+ * from each item. The extracted ID is used as the cache key for retrieving items.</p>
+ *
+ * <p>This function is invoked:</p>
+ * <ul>
+ * <li>When loading predefined items from bundles</li>
+ * <li>When adding new items to the cache</li>
+ * <li>When retrieving items by their ID</li>
+ * </ul>
+ *
+ * @param idExtractor the function that extracts a unique ID from an item of type T
+ * @return this builder for method chaining
+ */
+ public Builder<T> withIdExtractor(Function<T, String> idExtractor) {
+ this.idExtractor = idExtractor;
+ return this;
+ }
+
+ /**
+ * Set the post-processor for items.
+ *
+ * <p>This consumer is called after an item is loaded but before it is cached. It allows
+ * for additional processing, validation, or enrichment of items.</p>
+ *
+ * <p>The post-processor is invoked:</p>
+ * <ul>
+ * <li>After loading predefined items from bundles or JSON files</li>
+ * <li>After deserializing items from persistence</li>
+ * <li>Before adding new or updated items to the cache</li>
+ * </ul>
+ *
+ * <p>Note: Modifications made by the post-processor will be reflected in the cached item.</p>
+ *
+ * @param postProcessor the consumer that processes items after loading but before caching
+ * @return this builder for method chaining
+ */
+ public Builder<T> withPostProcessor(Consumer<T> postProcessor) {
+ this.postProcessor = postProcessor;
+ return this;
+ }
+
+ /**
+ * Set whether the type has predefined items.
+ *
+ * <p>When set to true, the cache service will look for predefined items in the META-INF
+ * path specified when creating the builder. When set to false, only programmatically
+ * added items will be available in the cache.</p>
+ *
+ * @param hasPredefinedItems whether the type has predefined items to load from bundles
+ * @return this builder for method chaining
+ */
+ public Builder<T> withPredefinedItems(boolean hasPredefinedItems) {
+ this.hasPredefinedItems = hasPredefinedItems;
+ return this;
+ }
+
+ /**
+ * Set the bundle item processor.
+ *
+ * <p>This processor is called during the bundle scanning phase, when predefined items
+ * are being loaded from OSGi bundles. It provides access to the BundleContext along
+ * with each item being processed.</p>
+ *
+ * <p>The bundle item processor is invoked:</p>
+ * <ul>
+ * <li>When a bundle is installed or updated and contains predefined items</li>
+ * <li>During system initialization when scanning all active bundles</li>
+ * <li>Before the post-processor (if defined) is called</li>
+ * </ul>
+ *
+ * <p>This processor is particularly useful for bundle-specific initialization that
+ * requires access to the bundle context, such as registering services or retrieving
+ * bundle-specific configuration.</p>
+ *
+ * @param bundleItemProcessor the bi-consumer that processes items with the bundle context
+ * @return this builder for method chaining
+ */
+ public Builder<T> withBundleItemProcessor(BiConsumer<BundleContext, T> bundleItemProcessor) {
+ this.bundleItemProcessor = bundleItemProcessor;
+ return this;
+ }
+
+ /**
+ * Sets a URL-aware processor for bundle items that includes the resource URL.
+ * This is called after an item is loaded from a bundle but before it is persisted.
+ * This allows for customization based on both the item and its source URL.
+ * If both this and bundleItemProcessor are set, this one takes precedence.
+ *
+ * @param urlAwareBundleItemProcessor the TriConsumer that processes bundle items with URL access
+ * @return the builder
+ */
+ public Builder<T> withUrlAwareBundleItemProcessor(TriConsumer<BundleContext, T, URL> urlAwareBundleItemProcessor) {
+ this.urlAwareBundleItemProcessor = urlAwareBundleItemProcessor;
+ return this;
+ }
+
+ /**
+ * Set a custom comparator for sorting URLs when loading predefined items.
+ *
+ * <p>This comparator determines the order in which predefined items are loaded from bundles.
+ * When defined, the URLs of predefined items will be sorted using this comparator before
+ * loading the items.</p>
+ *
+ * <p>This is particularly useful for items that need to be processed in a specific order,
+ * such as patches or migrations that must be applied sequentially.</p>
+ *
+ * @param urlComparator the comparator for sorting URLs
+ * @return this builder for method chaining
+ */
+ public Builder<T> withUrlComparator(Comparator<URL> urlComparator) {
+ this.urlComparator = urlComparator;
+ return this;
+ }
+
+ /**
+ * Sets a post-refresh callback that is executed after all items across all tenants have been reloaded.
+ * This allows for comparing the old and new states to detect changes and perform additional operations.
+ * The first parameter is the old state (Map of tenant ID to a Map of item ID to item).
+ * The second parameter is the new state (same structure).
+ *
+ * @param postRefreshCallback the callback to execute after a full refresh
+ * @return the builder
+ */
+ public Builder<T> withPostRefreshCallback(BiConsumer<Map<String, Map<String, T>>, Map<String, Map<String, T>>> postRefreshCallback) {
+ this.postRefreshCallback = postRefreshCallback;
+ return this;
+ }
+
+ /**
+ * Sets a tenant-specific refresh callback that is executed after each tenant's items have been reloaded.
+ * This allows for efficient processing of changes on a per-tenant basis.
+ * The first parameter is the tenant ID.
+ * The second parameter is the old state for this tenant (Map of item ID to item).
+ * The third parameter is the new state for this tenant (same structure).
+ *
+ * @param tenantRefreshCallback the callback to execute after each tenant's refresh
+ * @return the builder
+ */
+ public Builder<T> withTenantRefreshCallback(TriConsumer<String, Map<String, T>, Map<String, T>> tenantRefreshCallback) {
+ this.tenantRefreshCallback = tenantRefreshCallback;
+ return this;
+ }
+
+ /**
+ * Set a stream processor that will directly process the input stream from a predefined item resource.
+ * This is an alternative to the standard deserialization process and allows for custom processing of the raw data.
+ * When this processor is defined, it takes precedence over the standard JSON deserialization.
+ *
+ * <p>The processor is given the bundle context, the URL of the resource, and the input stream to read from.
+ * It must return a fully constructed item instance or null if processing fails.</p>
+ *
+ * <p>This is particularly useful for items that require special processing of the source data before
+ * they can be instantiated, such as JSON schemas that need to be validated, parsed, or transformed.</p>
+ *
+ * @param streamProcessor the function to process the input stream
+ * @return the builder instance for method chaining
+ */
+ public Builder<T> withStreamProcessor(TriFunction<BundleContext, URL, InputStream, T> streamProcessor) {
+ this.streamProcessor = streamProcessor;
+ return this;
+ }
+
+ /**
+ * Build the config.
+ *
+ * <p>Creates a new immutable CacheableTypeConfig instance with the current builder settings.</p>
+ *
+ * @return a new CacheableTypeConfig instance
+ * @throws IllegalStateException if mandatory settings like idExtractor are missing
+ */
+ public CacheableTypeConfig<T> build() {
+ if (idExtractor == null) {
+ throw new IllegalStateException("idExtractor is required for CacheableTypeConfig");
+ }
+ return new CacheableTypeConfig<>(this);
+ }
+ }
+
+ /**
+ * A functional interface for a consumer that accepts three arguments.
+ * Similar to BiConsumer but with a third argument.
+ *
+ * @param <T> the type of the first argument
+ * @param <U> the type of the second argument
+ * @param <V> the type of the third argument
+ */
+ @FunctionalInterface
+ public interface TriConsumer<T, U, V> {
+ void accept(T t, U u, V v);
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/services/cache/MultiTypeCacheService.java b/api/src/main/java/org/apache/unomi/api/services/cache/MultiTypeCacheService.java
new file mode 100644
index 000000000..6dac7dfc4
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/services/cache/MultiTypeCacheService.java
@@ -0,0 +1,170 @@
+/*
+ * 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.api.services.cache;
+
+import java.io.Serializable;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+
+/**
+ * Service interface for managing multi-tenant type caching.
+ * Provides functionality for caching and retrieving different types of plugin data across tenants.
+ */
+public interface MultiTypeCacheService {
+
+ /**
+ * Statistics for all cache operations
+ */
+ interface CacheStatistics {
+ /**
+ * Gets all type statistics.
+ *
+ * @return a map of type IDs to their statistics
+ */
+ Map<String, TypeStatistics> getAllStats();
+
+ /**
+ * Resets all statistics.
+ */
+ void reset();
+
+ /**
+ * Statistics for a specific type.
+ */
+ interface TypeStatistics {
+ /**
+ * Gets the number of cache hits.
+ *
+ * @return the number of hits
+ */
+ long getHits();
+
+ /**
+ * Gets the number of cache misses.
+ *
+ * @return the number of misses
+ */
+ long getMisses();
+
+ /**
+ * Gets the number of cache updates.
+ *
+ * @return the number of updates
+ */
+ long getUpdates();
+
+ /**
+ * Gets the number of validation failures.
+ *
+ * @return the number of validation failures
+ */
+ long getValidationFailures();
+
+ /**
+ * Gets the number of indexing errors.
+ *
+ * @return the number of indexing errors
+ */
+ long getIndexingErrors();
+ }
+ }
+
+ /**
+ * Gets the cache statistics.
+ *
+ * @return the cache statistics
+ */
+ CacheStatistics getStatistics();
+
+ /**
+ * Registers a new type configuration.
+ *
+ * @param config the configuration for the type to register
+ * @param <T> the type of plugin to register
+ */
+ <T extends Serializable> void registerType(CacheableTypeConfig<T> config);
+
+ /**
+ * Puts a value in the cache for a specific type, ID, and tenant.
+ *
+ * @param itemType the type identifier
+ * @param id the item identifier
+ * @param tenantId the tenant identifier
+ * @param value the value to cache
+ * @param <T> the type of the value
+ */
+ <T extends Serializable> void put(String itemType, String id, String tenantId, T value);
+
+ /**
+ * Gets a value from the cache with inheritance support.
+ *
+ * @param id the item identifier
+ * @param tenantId the tenant identifier
+ * @param typeClass the class of the type to retrieve
+ * @param <T> the type to retrieve
+ * @return the cached value, or null if not found
+ */
+ <T extends Serializable> T getWithInheritance(String id, String tenantId, Class<T> typeClass);
+
+ /**
+ * Gets all values for a tenant and type that match a predicate.
+ *
+ * @param tenantId the tenant identifier
+ * @param typeClass the class of the type to retrieve
+ * @param predicate the predicate to filter values
+ * @param <T> the type to retrieve
+ * @return a set of matching values
+ */
+ <T extends Serializable> Set<T> getValuesByPredicateWithInheritance(String tenantId, Class<T> typeClass, Predicate<T> predicate);
+
+ /**
+ * Gets the tenant-specific cache for a type.
+ *
+ * @param tenantId the tenant identifier
+ * @param typeClass the class of the type to retrieve
+ * @param <T> the type to retrieve
+ * @return a map of cached values for the tenant
+ */
+ <T extends Serializable> Map<String, T> getTenantCache(String tenantId, Class<T> typeClass);
+
+ /**
+ * Removes a value from the cache.
+ *
+ * @param itemType the type identifier
+ * @param id the item identifier
+ * @param tenantId the tenant identifier
+ * @param typeClass the class of the type to remove
+ * @param <T> the type to remove
+ */
+ <T extends Serializable> void remove(String itemType, String id, String tenantId, Class<T> typeClass);
+
+ /**
+ * Clears all cached values for a tenant.
+ *
+ * @param tenantId the tenant identifier
+ */
+ void clear(String tenantId);
+
+ /**
+ * Refreshes the cache for a specific type configuration.
+ *
+ * @param config the type configuration to refresh
+ * @param <T> the type to refresh
+ */
+ <T extends Serializable> void refreshTypeCache(CacheableTypeConfig<T> config);
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/tasks/ScheduledTask.java b/api/src/main/java/org/apache/unomi/api/tasks/ScheduledTask.java
new file mode 100644
index 000000000..d377aea31
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tasks/ScheduledTask.java
@@ -0,0 +1,873 @@
+/*
+ * 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.api.tasks;
+
+import org.apache.unomi.api.Item;
+
+import java.io.Serializable;
+import java.util.Date;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.HashSet;
+
+/**
+ * Represents a persistent scheduled task that can be executed across a cluster.
+ * This class provides a comprehensive model for task scheduling and execution with features including:
+ * <ul>
+ * <li>Task lifecycle management through states (SCHEDULED, WAITING, RUNNING, etc.)</li>
+ * <li>Lock management for cluster coordination</li>
+ * <li>Execution history and checkpoint data for recovery</li>
+ * <li>Support for one-shot and periodic execution</li>
+ * <li>Task dependencies and parallel execution control</li>
+ * <li>Cluster-wide task distribution</li>
+ * </ul>
+ */
+public class ScheduledTask extends Item implements Serializable {
+
+ public static final String ITEM_TYPE = "scheduledTask";
+
+ /**
+ * Enumeration of possible task states in its lifecycle.
+ * Tasks transition between these states based on execution progress and cluster conditions.
+ */
+ public enum TaskStatus {
+ /** Task is scheduled but not yet running */
+ SCHEDULED,
+ /** Task is waiting for a lock to be released or dependencies to complete */
+ WAITING,
+ /** Task is currently executing */
+ RUNNING,
+ /** Task has completed successfully */
+ COMPLETED,
+ /** Task failed with an error */
+ FAILED,
+ /** Task was explicitly cancelled */
+ CANCELLED,
+ /** Task crashed due to node failure or other unexpected conditions */
+ CRASHED
+ }
+
+ private String taskType;
+ private Map<String, Object> parameters;
+ private String executingNodeId; // The ID of the node currently executing this task
+ /**
+ * The initial delay before first execution, in the specified time unit.
+ */
+ private long initialDelay;
+ private long period;
+ private TimeUnit timeUnit;
+ private boolean fixedRate;
+ /**
+ * Gets the date of the last execution attempt.
+ *
+ * @return the last execution date or null if never executed
+ */
+ private Date lastExecutionDate;
+ /**
+ * Gets the node ID that last executed this task.
+ *
+ * @return the ID of the last executing node
+ */
+ private String lastExecutedBy;
+ /**
+ * Gets the error message from the last failed execution.
+ *
+ * @return the last error message or null if no error
+ */
+ private String lastError;
+ private boolean enabled;
+ private String lockOwner;
+ /**
+ * Gets the date when the current lock was acquired.
+ *
+ * @return the lock acquisition date or null if unlocked
+ */
+ private Date lockDate;
+ private boolean oneShot;
+ private boolean allowParallelExecution;
+ /**
+ * Gets the current task status.
+ *
+ * @return the current status
+ */
+ private TaskStatus status;
+ private Map<String, Object> statusDetails;
+ /**
+ * Gets the next scheduled execution date for periodic tasks.
+ *
+ * @return the next scheduled execution date or null if not scheduled
+ */
+ private Date nextScheduledExecution;
+ /**
+ * Gets the number of consecutive execution failures.
+ *
+ * @return the failure count
+ */
+ private int failureCount;
+ /**
+ * Gets the number of successful executions.
+ *
+ * @return the success count
+ */
+ private int successCount;
+ /**
+ * Gets the maximum number of retry attempts after failures.
+ * For one-shot tasks:
+ * - When a task fails, it will be automatically retried up to this many times
+ * - Each retry attempt occurs after waiting for retryDelay
+ * - After reaching this limit, the task remains in FAILED state until manually retried
+ *
+ * For periodic tasks:
+ * - Retries only apply within a single scheduled execution
+ * - If retries are exhausted, the task will still attempt its next scheduled execution
+ * - The next scheduled execution resets the failure count
+ *
+ * A value of 0 means no automatic retries in either case.
+ *
+ * @return the maximum retry count
+ */
+ private int maxRetries;
+ /**
+ * Gets the delay between retry attempts.
+ * For one-shot tasks:
+ * - This delay is applied between each retry attempt after a failure
+ * - Helps prevent rapid-fire retries that could overload the system
+ *
+ * For periodic tasks:
+ * - This delay is used between retry attempts within a single scheduled execution
+ * - Does not affect the task's configured period/scheduling
+ *
+ * @return the retry delay in milliseconds
+ */
+ private long retryDelay;
+ /**
+ * Gets the name of the current execution step.
+ * This is used to track progress through multi-step tasks.
+ *
+ * @return the current step name or null if not set
+ */
+ private String currentStep;
+ /**
+ * Gets the checkpoint data for task resumption.
+ * This data allows a task to resume from where it left off after a crash.
+ *
+ * @return map of checkpoint data or null if no checkpoint
+ */
+ private Map<String, Object> checkpointData;
+ private boolean persistent = true; // By default tasks are persistent
+ private boolean runOnAllNodes = false; // By default tasks run on a single node
+ /**
+ * Indicates if this is a system task that should not be recreated on startup.
+ * System tasks are created by the system during initialization and should be
+ * preserved across restarts.
+ */
+ private boolean systemTask = false; // By default tasks are not system tasks
+ /**
+ * Gets the task type that this task is waiting for a lock on.
+ * This is used when tasks of the same type cannot run in parallel.
+ *
+ * @return the task type being waited on or null if not waiting
+ */
+ private String waitingForTaskType;
+ private Set<String> dependsOn = new HashSet<>(); // Set of task IDs this task depends on
+ private Set<String> waitingOnTasks = new HashSet<>(); // Set of task IDs this task is currently waiting on
+
+ public ScheduledTask() {
+ super();
+ this.status = TaskStatus.SCHEDULED;
+ this.failureCount = 0;
+ this.maxRetries = 3;
+ this.retryDelay = 60000; // 1 minute default retry delay
+ }
+
+ /**
+ * Gets the task type identifier.
+ * The task type determines which executor will handle this task.
+ *
+ * @return the task type identifier
+ */
+ public String getTaskType() {
+ return taskType;
+ }
+
+ /**
+ * Sets the task type identifier.
+ *
+ * @param taskType the task type identifier
+ */
+ public void setTaskType(String taskType) {
+ this.taskType = taskType;
+ }
+
+ /**
+ * Gets the task parameters.
+ * These parameters are passed to the task executor during execution.
+ *
+ * @return map of task parameters
+ */
+ public Map<String, Object> getParameters() {
+ return parameters;
+ }
+
+ /**
+ * Sets the task parameters.
+ *
+ * @param parameters map of task parameters
+ */
+ public void setParameters(Map<String, Object> parameters) {
+ this.parameters = parameters;
+ }
+
+ /**
+ * Gets the initial delay before first execution.
+ *
+ * @return the initial delay in the specified time unit
+ */
+ public long getInitialDelay() {
+ return initialDelay;
+ }
+
+ /**
+ * Sets the initial delay before first execution.
+ *
+ * @param initialDelay the initial delay in the specified time unit
+ */
+ public void setInitialDelay(long initialDelay) {
+ this.initialDelay = initialDelay;
+ }
+
+ /**
+ * Gets the period between successive task executions.
+ * A period of 0 indicates a one-time task and will automatically set oneShot=true.
+ *
+ * @return the period between executions in the specified time unit
+ */
+ public long getPeriod() {
+ return period;
+ }
+
+ /**
+ * Sets the period for task execution.
+ * A period of 0 indicates a one-time task and will automatically set oneShot=true.
+ * A positive period indicates a recurring task and is incompatible with oneShot=true.
+ *
+ * @param period the period between successive task executions
+ * @throws IllegalArgumentException if period is negative or if period > 0 and oneShot=true
+ */
+ public void setPeriod(long period) {
+ if (period < 0) {
+ throw new IllegalArgumentException("Period cannot be negative");
+ }
+ if (period > 0 && oneShot) {
+ throw new IllegalArgumentException("One-shot tasks cannot have a period");
+ }
+ this.period = period;
+ if (period == 0) {
+ this.oneShot = true;
+ }
+ }
+
+ /**
+ * Gets the time unit for delay and period values.
+ *
+ * @return the time unit used for scheduling
+ */
+ public TimeUnit getTimeUnit() {
+ return timeUnit;
+ }
+
+ /**
+ * Sets the time unit for delay and period values.
+ *
+ * @param timeUnit the time unit to use for scheduling
+ */
+ public void setTimeUnit(TimeUnit timeUnit) {
+ this.timeUnit = timeUnit;
+ }
+
+ /**
+ * Gets whether this task uses fixed-rate scheduling.
+ * If true, executions are scheduled at fixed intervals from the start time.
+ * If false, executions are scheduled at fixed delays from completion.
+ *
+ * @return true if using fixed-rate scheduling
+ */
+ public boolean isFixedRate() {
+ return fixedRate;
+ }
+
+ /**
+ * Sets whether this task uses fixed-rate scheduling.
+ *
+ * @param fixedRate true to use fixed-rate scheduling, false for fixed-delay
+ */
+ public void setFixedRate(boolean fixedRate) {
+ this.fixedRate = fixedRate;
+ }
+
+ /**
+ * Gets the date of the last execution attempt.
+ *
+ * @return the last execution date or null if never executed
+ */
+ public Date getLastExecutionDate() {
+ return lastExecutionDate;
+ }
+
+ /**
+ * Sets the date of the last execution attempt.
+ *
+ * @param lastExecutionDate the last execution date
+ */
+ public void setLastExecutionDate(Date lastExecutionDate) {
+ this.lastExecutionDate = lastExecutionDate;
+ }
+
+ /**
+ * Gets the node ID that last executed this task.
+ *
+ * @return the ID of the last executing node
+ */
+ public String getLastExecutedBy() {
+ return lastExecutedBy;
+ }
+
+ /**
+ * Sets the node ID that last executed this task.
+ *
+ * @param lastExecutedBy the ID of the executing node
+ */
+ public void setLastExecutedBy(String lastExecutedBy) {
+ this.lastExecutedBy = lastExecutedBy;
+ }
+
+ /**
+ * Gets the error message from the last failed execution.
+ *
+ * @return the last error message or null if no error
+ */
+ public String getLastError() {
+ return lastError;
+ }
+
+ /**
+ * Sets the error message from a failed execution.
+ *
+ * @param lastError the error message
+ */
+ public void setLastError(String lastError) {
+ this.lastError = lastError;
+ }
+
+ /**
+ * Gets whether this task is enabled.
+ * Disabled tasks will not be executed.
+ *
+ * @return true if the task is enabled
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Sets whether this task is enabled.
+ *
+ * @param enabled true to enable the task, false to disable
+ */
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ /**
+ * Gets the ID of the node that currently holds the execution lock.
+ *
+ * @return the current lock owner's node ID or null if unlocked
+ */
+ public String getLockOwner() {
+ return lockOwner;
+ }
+
+ /**
+ * Sets the ID of the node that holds the execution lock.
+ *
+ * @param lockOwner the lock owner's node ID
+ */
+ public void setLockOwner(String lockOwner) {
+ this.lockOwner = lockOwner;
+ }
+
+ /**
+ * Gets the date when the current lock was acquired.
+ *
+ * @return the lock acquisition date or null if unlocked
+ */
+ public Date getLockDate() {
+ return lockDate;
+ }
+
+ /**
+ * Sets the date when the current lock was acquired.
+ *
+ * @param lockDate the lock acquisition date
+ */
+ public void setLockDate(Date lockDate) {
+ this.lockDate = lockDate;
+ }
+
+ /**
+ * Returns whether this task should execute only once.
+ * Tasks with period=0 are automatically marked as one-shot tasks.
+ *
+ * @return true if the task should execute only once
+ */
+ public boolean isOneShot() {
+ return oneShot;
+ }
+
+ /**
+ * Sets whether this task should execute only once.
+ * Setting oneShot=true is incompatible with a period > 0.
+ *
+ * @param oneShot true if the task should execute only once
+ * @throws IllegalArgumentException if oneShot=true and period > 0
+ */
+ public void setOneShot(boolean oneShot) {
+ if (oneShot && period > 0) {
+ throw new IllegalArgumentException("One-shot tasks cannot have a period");
+ }
+ this.oneShot = oneShot;
+ }
+
+ /**
+ * Gets whether parallel execution is allowed for this task.
+ * If true, multiple instances of this task can run simultaneously.
+ * If false, the task uses locking to ensure only one instance runs at a time.
+ *
+ * @return true if parallel execution is allowed
+ */
+ public boolean isAllowParallelExecution() {
+ return allowParallelExecution;
+ }
+
+ /**
+ * Sets whether parallel execution is allowed for this task.
+ *
+ * @param allowParallelExecution true to allow parallel execution
+ */
+ public void setAllowParallelExecution(boolean allowParallelExecution) {
+ this.allowParallelExecution = allowParallelExecution;
+ }
+
+ /**
+ * Gets the current task status.
+ *
+ * @return the current status
+ */
+ public TaskStatus getStatus() {
+ return status;
+ }
+
+ /**
+ * Sets the task status.
+ * Status transitions should be validated before setting.
+ *
+ * @param status the new status
+ */
+ public void setStatus(TaskStatus status) {
+ this.status = status;
+ }
+
+ /**
+ * Gets additional details about the task's current status.
+ * This may include execution progress, history, or other metadata.
+ *
+ * @return map of status details
+ */
+ public Map<String, Object> getStatusDetails() {
+ return statusDetails;
+ }
+
+ /**
+ * Sets additional details about the task's current status.
+ *
+ * @param statusDetails map of status details
+ */
+ public void setStatusDetails(Map<String, Object> statusDetails) {
+ this.statusDetails = statusDetails;
+ }
+
+ /**
+ * Gets the next scheduled execution date for periodic tasks.
+ *
+ * @return the next scheduled execution date or null if not scheduled
+ */
+ public Date getNextScheduledExecution() {
+ return nextScheduledExecution;
+ }
+
+ /**
+ * Sets the next scheduled execution date.
+ *
+ * @param nextScheduledExecution the next execution date
+ */
+ public void setNextScheduledExecution(Date nextScheduledExecution) {
+ this.nextScheduledExecution = nextScheduledExecution;
+ }
+
+ /**
+ * Gets the number of consecutive execution failures.
+ *
+ * @return the failure count
+ */
+ public int getFailureCount() {
+ return failureCount;
+ }
+
+ /**
+ * Sets the number of consecutive execution failures.
+ *
+ * @param failureCount the new failure count
+ */
+ public void setFailureCount(int failureCount) {
+ this.failureCount = failureCount;
+ }
+
+ /**
+ * Gets the number of successful executions.
+ *
+ * @return the success count
+ */
+ public int getSuccessCount() {
+ return successCount;
+ }
+
+ /**
+ * Sets the number of successful executions.
+ *
+ * @param successCount the new success count
+ */
+ public void setSuccessCount(int successCount) {
+ this.successCount = successCount;
+ }
+
+ /**
+ * Gets the maximum number of retry attempts after failures.
+ * For one-shot tasks:
+ * - When a task fails, it will be automatically retried up to this many times
+ * - Each retry attempt occurs after waiting for retryDelay
+ * - After reaching this limit, the task remains in FAILED state until manually retried
+ *
+ * For periodic tasks:
+ * - Retries only apply within a single scheduled execution
+ * - If retries are exhausted, the task will still attempt its next scheduled execution
+ * - The next scheduled execution resets the failure count
+ *
+ * A value of 0 means no automatic retries in either case.
+ *
+ * @return the maximum retry count
+ */
+ public int getMaxRetries() {
+ return maxRetries;
+ }
+
+ /**
+ * Sets the maximum number of retry attempts after failures.
+ *
+ * @param maxRetries the maximum retry count
+ */
+ public void setMaxRetries(int maxRetries) {
+ this.maxRetries = maxRetries;
+ }
+
+ /**
+ * Gets the delay between retry attempts.
+ * For one-shot tasks:
+ * - This delay is applied between each retry attempt after a failure
+ * - Helps prevent rapid-fire retries that could overload the system
+ *
+ * For periodic tasks:
+ * - This delay is used between retry attempts within a single scheduled execution
+ * - Does not affect the task's configured period/scheduling
+ *
+ * @return the retry delay in milliseconds
+ */
+ public long getRetryDelay() {
+ return retryDelay;
+ }
+
+ /**
+ * Sets the delay between retry attempts.
+ *
+ * @param retryDelay the retry delay in milliseconds
+ */
+ public void setRetryDelay(long retryDelay) {
+ this.retryDelay = retryDelay;
+ }
+
+ /**
+ * Gets the name of the current execution step.
+ * This is used to track progress through multi-step tasks.
+ *
+ * @return the current step name or null if not set
+ */
+ public String getCurrentStep() {
+ return currentStep;
+ }
+
+ /**
+ * Sets the name of the current execution step.
+ *
+ * @param currentStep the current step name
+ */
+ public void setCurrentStep(String currentStep) {
+ this.currentStep = currentStep;
+ }
+
+ /**
+ * Gets the checkpoint data for task resumption.
+ * This data allows a task to resume from where it left off after a crash.
+ *
+ * @return map of checkpoint data or null if no checkpoint
+ */
+ public Map<String, Object> getCheckpointData() {
+ return checkpointData;
+ }
+
+ /**
+ * Sets the checkpoint data for task resumption.
+ *
+ * @param checkpointData map of checkpoint data
+ */
+ public void setCheckpointData(Map<String, Object> checkpointData) {
+ this.checkpointData = checkpointData;
+ }
+
+ /**
+ * Gets whether this task is stored persistently.
+ * Persistent tasks survive system restarts and are visible across the cluster.
+ * Non-persistent tasks exist only in memory on a single node.
+ *
+ * @return true if the task is persistent
+ */
+ public boolean isPersistent() {
+ return persistent;
+ }
+
+ public void setPersistent(boolean persistent) {
+ this.persistent = persistent;
+ }
+
+ /**
+ * Gets whether this task should run on all cluster nodes.
+ * If false, the task runs only on executor nodes.
+ *
+ * @return true if the task should run on all nodes
+ */
+ public boolean isRunOnAllNodes() {
+ return runOnAllNodes;
+ }
+
+ /**
+ * Sets whether this task should run on all cluster nodes.
+ *
+ * @param runOnAllNodes true to run on all nodes, false for executor nodes only
+ */
+ public void setRunOnAllNodes(boolean runOnAllNodes) {
+ this.runOnAllNodes = runOnAllNodes;
+ }
+
+ /**
+ * Gets whether this task is a system task.
+ * System tasks are created by the system during initialization and should be
+ * preserved across restarts rather than being recreated.
+ *
+ * @return true if the task is a system task
+ */
+ public boolean isSystemTask() {
+ return systemTask;
+ }
+
+ /**
+ * Sets whether this task is a system task.
+ *
+ * @param systemTask true to mark the task as a system task
+ */
+ public void setSystemTask(boolean systemTask) {
+ this.systemTask = systemTask;
+ }
+
+ /**
+ * Gets the task type that this task is waiting for a lock on.
+ * This is used when tasks of the same type cannot run in parallel.
+ *
+ * @return the task type being waited on or null if not waiting
+ */
+ public String getWaitingForTaskType() {
+ return waitingForTaskType;
+ }
+
+ /**
+ * Sets the task type that this task is waiting for a lock on.
+ *
+ * @param waitingForTaskType the task type to wait for
+ */
+ public void setWaitingForTaskType(String waitingForTaskType) {
+ this.waitingForTaskType = waitingForTaskType;
+ }
+
+ /**
+ * Gets the set of task IDs that this task depends on.
+ * The task will not execute until all dependencies have completed.
+ *
+ * @return set of dependency task IDs
+ */
+ public Set<String> getDependsOn() {
+ return dependsOn;
+ }
+
+ /**
+ * Sets the set of task IDs that this task depends on.
+ *
+ * @param dependsOn set of dependency task IDs
+ */
+ public void setDependsOn(Set<String> dependsOn) {
+ this.dependsOn = dependsOn;
+ }
+
+ /**
+ * Gets the set of task IDs that this task is currently waiting on.
+ * This represents the subset of dependencies that have not yet completed.
+ *
+ * @return set of task IDs being waited on
+ */
+ public Set<String> getWaitingOnTasks() {
+ return waitingOnTasks;
+ }
+
+ /**
+ * Sets the set of task IDs that this task is currently waiting on.
+ *
+ * @param waitingOnTasks set of task IDs to wait on
+ */
+ public void setWaitingOnTasks(Set<String> waitingOnTasks) {
+ this.waitingOnTasks = waitingOnTasks;
+ }
+
+ /**
+ * Adds a task dependency.
+ * The task will not execute until all dependencies have completed.
+ *
+ * @param taskId ID of the task to depend on
+ */
+ public void addDependency(String taskId) {
+ if (dependsOn == null) {
+ dependsOn = new HashSet<>();
+ }
+ dependsOn.add(taskId);
+ }
+
+ /**
+ * Removes a task dependency.
+ *
+ * @param taskId ID of the task to remove from dependencies
+ */
+ public void removeDependency(String taskId) {
+ if (dependsOn != null) {
+ dependsOn.remove(taskId);
+ }
+ }
+
+ /**
+ * Adds a task to the set of tasks being waited on.
+ *
+ * @param taskId ID of the task to wait on
+ */
+ public void addWaitingOnTask(String taskId) {
+ if (waitingOnTasks == null) {
+ waitingOnTasks = new HashSet<>();
+ }
+ waitingOnTasks.add(taskId);
+ }
+
+ /**
+ * Removes a task from the set of tasks being waited on.
+ *
+ * @param taskId ID of the task to stop waiting on
+ */
+ public void removeWaitingOnTask(String taskId) {
+ if (waitingOnTasks != null) {
+ waitingOnTasks.remove(taskId);
+ }
+ }
+
+ /**
+ * Gets the ID of the node currently executing this task.
+ * This is different from lockOwner as it specifically indicates which node
+ * is actively executing the task, not just holding the lock.
+ *
+ * @return the ID of the executing node or null if not being executed
+ */
+ public String getExecutingNodeId() {
+ return executingNodeId;
+ }
+
+ /**
+ * Sets the ID of the node currently executing this task.
+ *
+ * @param executingNodeId the ID of the executing node
+ */
+ public void setExecutingNodeId(String executingNodeId) {
+ this.executingNodeId = executingNodeId;
+ }
+
+ @Override
+ public String toString() {
+ return "ScheduledTask{" +
+ "taskType='" + taskType + '\'' +
+ ", parameters=" + parameters +
+ ", executingNodeId='" + executingNodeId + '\'' +
+ ", initialDelay=" + initialDelay +
+ ", period=" + period +
+ ", timeUnit=" + timeUnit +
+ ", fixedRate=" + fixedRate +
+ ", lastExecutionDate=" + lastExecutionDate +
+ ", lastExecutedBy='" + lastExecutedBy + '\'' +
+ ", lastError='" + lastError + '\'' +
+ ", enabled=" + enabled +
+ ", lockOwner='" + lockOwner + '\'' +
+ ", lockDate=" + lockDate +
+ ", oneShot=" + oneShot +
+ ", allowParallelExecution=" + allowParallelExecution +
+ ", status=" + status +
+ ", statusDetails=" + statusDetails +
+ ", nextScheduledExecution=" + nextScheduledExecution +
+ ", failureCount=" + failureCount +
+ ", successCount=" + successCount +
+ ", maxRetries=" + maxRetries +
+ ", retryDelay=" + retryDelay +
+ ", currentStep='" + currentStep + '\'' +
+ ", checkpointData=" + checkpointData +
+ ", persistent=" + persistent +
+ ", runOnAllNodes=" + runOnAllNodes +
+ ", systemTask=" + systemTask +
+ ", waitingForTaskType='" + waitingForTaskType + '\'' +
+ ", dependsOn=" + dependsOn +
+ ", waitingOnTasks=" + waitingOnTasks +
+ '}';
+ }
+}
diff --git a/api/src/main/java/org/apache/unomi/api/tasks/TaskExecutor.java b/api/src/main/java/org/apache/unomi/api/tasks/TaskExecutor.java
new file mode 100644
index 000000000..370784f85
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tasks/TaskExecutor.java
@@ -0,0 +1,139 @@
+/*
+ * 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.api.tasks;
+
+import java.util.Map;
+
+/**
+ * Interface for task executors that can execute scheduled tasks.
+ * Task executors are responsible for the actual execution of tasks and provide:
+ * <ul>
+ * <li>Task type identification</li>
+ * <li>Task execution logic</li>
+ * <li>Optional task resumption capabilities</li>
+ * <li>Progress and status reporting through callbacks</li>
+ * </ul>
+ *
+ * Implementations should be thread-safe as they may be called concurrently
+ * from multiple threads to execute different tasks of the same type.
+ */
+public interface TaskExecutor {
+
+ /**
+ * Gets the type of tasks this executor can handle.
+ * The task type is used to match tasks with their appropriate executor.
+ * Each executor must have a unique task type.
+ *
+ * @return the task type string identifier
+ */
+ String getTaskType();
+
+ /**
+ * Executes a scheduled task.
+ * This method contains the core execution logic for the task.
+ * The implementation should:
+ * <ul>
+ * <li>Use the task parameters to perform the required work</li>
+ * <li>Report progress through the status callback</li>
+ * <li>Handle errors appropriately</li>
+ * <li>Call callback.complete() on successful completion</li>
+ * <li>Call callback.fail() if execution fails</li>
+ * </ul>
+ *
+ * @param task the task to execute
+ * @param statusCallback callback to update task status during execution
+ * @throws Exception if task execution fails
+ */
+ void execute(ScheduledTask task, TaskStatusCallback statusCallback) throws Exception;
+
+ /**
+ * Checks if this executor can resume a crashed task from its checkpoint.
+ * Implementations should examine the task's checkpoint data to determine
+ * if resumption is possible.
+ *
+ * @param task the crashed task
+ * @return true if the task can be resumed from its checkpoint
+ */
+ default boolean canResume(ScheduledTask task) {
+ return false;
+ }
+
+ /**
+ * Resumes a crashed task from its checkpoint.
+ * This method is called instead of execute() when resuming a crashed task.
+ * The default implementation simply calls execute(), but implementations
+ * can override this to provide custom resumption logic.
+ *
+ * @param task the crashed task
+ * @param statusCallback callback to update task status
+ * @throws Exception if task resumption fails
+ */
+ default void resume(ScheduledTask task, TaskStatusCallback statusCallback) throws Exception {
+ execute(task, statusCallback);
+ }
+
+ /**
+ * Callback interface for task status updates.
+ * This interface allows executors to report progress and status changes
+ * during task execution.
+ */
+ interface TaskStatusCallback {
+ /**
+ * Updates the current step of the task.
+ * Use this to indicate progress through different phases of execution.
+ *
+ * @param step the current step name
+ * @param details optional step details as key-value pairs
+ */
+ void updateStep(String step, Map<String, Object> details);
+
+ /**
+ * Saves a checkpoint for the task.
+ * Checkpoints allow long-running tasks to be resumed after crashes.
+ * The checkpoint data should contain sufficient information to
+ * resume execution from this point.
+ *
+ * @param checkpointData the checkpoint data as key-value pairs
+ */
+ void checkpoint(Map<String, Object> checkpointData);
+
+ /**
+ * Updates task status details.
+ * Use this to provide additional information about the task's
+ * current state or progress.
+ *
+ * @param details the status details as key-value pairs
+ */
+ void updateStatusDetails(Map<String, Object> details);
+
+ /**
+ * Marks task as completed.
+ * This should be called when the task has successfully finished
+ * all its work.
+ */
+ void complete();
+
+ /**
+ * Marks task as failed.
+ * This should be called when the task encounters an error that
+ * prevents successful completion.
+ *
+ * @param error the error message describing the failure
+ */
+ void fail(String error);
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/ApiKey.java b/api/src/main/java/org/apache/unomi/api/tenants/ApiKey.java
new file mode 100644
index 000000000..0d05dc5cc
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/ApiKey.java
@@ -0,0 +1,204 @@
+/*
+ * 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.api.tenants;
+
+import org.apache.unomi.api.Item;
+
+import java.util.Date;
+
+/**
+ * Represents an API key for tenant authentication and authorization.
+ * This class extends the base Item class and provides functionality for managing
+ * API keys including their lifecycle (creation, expiration, revocation) and metadata.
+ */
+public class ApiKey extends Item {
+ /**
+ * The item type for an API key.
+ */
+ public static final String ITEM_TYPE = "apiKey";
+
+ /**
+ * Enum defining the types of API keys.
+ */
+ public enum ApiKeyType {
+ /**
+ * Public API key for context.json, event collector and other public-facing endpoints
+ */
+ PUBLIC,
+
+ /**
+ * Private API key for protected endpoints including login and updateProperties
+ */
+ PRIVATE
+ }
+
+ /**
+ * The API key value.
+ */
+ private String key;
+
+ /**
+ * The type of API key (public or private).
+ */
+ private ApiKeyType keyType;
+
+ /**
+ * The name or identifier of the API key.
+ */
+ private String name;
+
+ /**
+ * A description of the API key's purpose or usage.
+ */
+ private String description;
+
+ /**
+ * The date when the API key was created.
+ */
+ private Date creationDate;
+
+ /**
+ * The date when the API key expires.
+ */
+ private Date expirationDate;
+
+ /**
+ * Whether the API key has been revoked.
+ */
+ private boolean revoked;
+
+ /**
+ * Default constructor that initializes the API key as an Item.
+ */
+ public ApiKey() {
+ super();
+ setItemType(ITEM_TYPE);
+ }
+
+ /**
+ * Gets the API key value.
+ * @return the API key value
+ */
+ public String getKey() {
+ return key;
+ }
+
+ /**
+ * Sets the API key value.
+ * @param key the API key value to set
+ */
+ public void setKey(String key) {
+ this.key = key;
+ }
+
+ /**
+ * Gets the name or identifier of the API key.
+ * @return the API key name
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Sets the name or identifier of the API key.
+ * @param name the API key name to set
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Gets the description of the API key's purpose or usage.
+ * @return the API key description
+ */
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * Sets the description of the API key's purpose or usage.
+ * @param description the API key description to set
+ */
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ /**
+ * Gets the creation date of the API key.
+ * @return the creation date
+ */
+ @Override
+ public Date getCreationDate() {
+ return creationDate;
+ }
+
+ /**
+ * Sets the creation date of the API key.
+ * @param creationDate the creation date to set
+ */
+ public void setCreationDate(Date creationDate) {
+ this.creationDate = creationDate;
+ }
+
+ /**
+ * Gets the expiration date of the API key.
+ * @return the expiration date
+ */
+ public Date getExpirationDate() {
+ return expirationDate;
+ }
+
+ /**
+ * Sets the expiration date of the API key.
+ * @param expirationDate the expiration date to set
+ */
+ public void setExpirationDate(Date expirationDate) {
+ this.expirationDate = expirationDate;
+ }
+
+ /**
+ * Checks if the API key has been revoked.
+ * @return true if the API key is revoked, false otherwise
+ */
+ public boolean isRevoked() {
+ return revoked;
+ }
+
+ /**
+ * Sets the revocation status of the API key.
+ * @param revoked true to revoke the API key, false to reinstate
+ */
+ public void setRevoked(boolean revoked) {
+ this.revoked = revoked;
+ }
+
+ /**
+ * Gets the type of the API key.
+ * @return the API key type
+ */
+ public ApiKeyType getKeyType() {
+ return keyType;
+ }
+
+ /**
+ * Sets the type of the API key.
+ * @param keyType the API key type to set
+ */
+ public void setKeyType(ApiKeyType keyType) {
+ this.keyType = keyType;
+ }
+}
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/ApiKeyConfig.java b/api/src/main/java/org/apache/unomi/api/tenants/ApiKeyConfig.java
new file mode 100644
index 000000000..aada3c21b
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/ApiKeyConfig.java
@@ -0,0 +1,164 @@
+/*
+ * 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.api.tenants;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Configuration settings for API keys.
+ * This class defines the configuration parameters for API key management,
+ * including validation rules and usage limits.
+ */
+public class ApiKeyConfig {
+ private int minLength;
+ private int maxLength;
+ private String pattern;
+ private int maxActiveKeys;
+ private int defaultExpirationDays;
+ private List<String> allowedScopes;
+ private Map<String, Integer> rateLimits;
+ private Map<String, Object> additionalSettings;
+
+ /**
+ * Gets the minimum length required for API keys.
+ * @return the minimum length
+ */
+ public int getMinLength() {
+ return minLength;
+ }
+
+ /**
+ * Sets the minimum length required for API keys.
+ * @param minLength the minimum length to set
+ */
+ public void setMinLength(int minLength) {
+ this.minLength = minLength;
+ }
+
+ /**
+ * Gets the maximum length allowed for API keys.
+ * @return the maximum length
+ */
+ public int getMaxLength() {
+ return maxLength;
+ }
+
+ /**
+ * Sets the maximum length allowed for API keys.
+ * @param maxLength the maximum length to set
+ */
+ public void setMaxLength(int maxLength) {
+ this.maxLength = maxLength;
+ }
+
+ /**
+ * Gets the regex pattern for API key validation.
+ * @return the validation pattern
+ */
+ public String getPattern() {
+ return pattern;
+ }
+
+ /**
+ * Sets the regex pattern for API key validation.
+ * @param pattern the validation pattern to set
+ */
+ public void setPattern(String pattern) {
+ this.pattern = pattern;
+ }
+
+ /**
+ * Gets the maximum number of active API keys allowed.
+ * @return the maximum number of active keys
+ */
+ public int getMaxActiveKeys() {
+ return maxActiveKeys;
+ }
+
+ /**
+ * Sets the maximum number of active API keys allowed.
+ * @param maxActiveKeys the maximum number to set
+ */
+ public void setMaxActiveKeys(int maxActiveKeys) {
+ this.maxActiveKeys = maxActiveKeys;
+ }
+
+ /**
+ * Gets the default expiration period in days for new API keys.
+ * @return the default expiration period in days
+ */
+ public int getDefaultExpirationDays() {
+ return defaultExpirationDays;
+ }
+
+ /**
+ * Sets the default expiration period in days for new API keys.
+ * @param defaultExpirationDays the default expiration period to set
+ */
+ public void setDefaultExpirationDays(int defaultExpirationDays) {
+ this.defaultExpirationDays = defaultExpirationDays;
+ }
+
+ /**
+ * Gets the list of allowed scopes for API keys.
+ * @return list of allowed scopes
+ */
+ public List<String> getAllowedScopes() {
+ return allowedScopes;
+ }
+
+ /**
+ * Sets the list of allowed scopes for API keys.
+ * @param allowedScopes list of allowed scopes to set
+ */
+ public void setAllowedScopes(List<String> allowedScopes) {
+ this.allowedScopes = allowedScopes;
+ }
+
+ /**
+ * Gets the rate limits for different operations.
+ * @return map of operation names to their rate limits
+ */
+ public Map<String, Integer> getRateLimits() {
+ return rateLimits;
+ }
+
+ /**
+ * Sets the rate limits for different operations.
+ * @param rateLimits map of operation names to their rate limits
+ */
+ public void setRateLimits(Map<String, Integer> rateLimits) {
+ this.rateLimits = rateLimits;
+ }
+
+ /**
+ * Gets additional configuration settings.
+ * @return map of additional settings
+ */
+ public Map<String, Object> getAdditionalSettings() {
+ return additionalSettings;
+ }
+
+ /**
+ * Sets additional configuration settings.
+ * @param additionalSettings map of additional settings to set
+ */
+ public void setAdditionalSettings(Map<String, Object> additionalSettings) {
+ this.additionalSettings = additionalSettings;
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/AuditService.java b/api/src/main/java/org/apache/unomi/api/tenants/AuditService.java
new file mode 100644
index 000000000..6fb6460af
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/AuditService.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.api.tenants;
+
+import org.apache.unomi.api.Item;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * Combined service interface for both item and tenant auditing operations.
+ */
+public interface AuditService extends ItemAuditService, TenantAuditService {
+ /**
+ * Records the creation of an item.
+ *
+ * @param item the item being created
+ * @param userId the user performing the creation
+ */
+ void auditCreate(Item item, String userId);
+
+ /**
+ * Records the update of an item.
+ *
+ * @param item the item being updated
+ * @param userId the user performing the update
+ */
+ void auditUpdate(Item item, String userId);
+
+ /**
+ * Records the deletion of an item.
+ *
+ * @param item the item being deleted
+ * @param userId the user performing the deletion
+ */
+ void auditDelete(Item item, String userId);
+
+ /**
+ * Retrieves items modified since a specific date.
+ *
+ * @param tenantId the tenant ID to filter by
+ * @param since the date to check modifications from
+ * @return a list of modified items
+ */
+ List<Item> getModifiedItems(String tenantId, Date since);
+
+ /**
+ * Retrieves items modified since the last synchronization.
+ *
+ * @param tenantId the tenant ID to filter by
+ * @param sourceInstanceId the source instance ID
+ * @return a list of modified items
+ */
+ List<Item> getModifiedItemsSinceLastSync(String tenantId, String sourceInstanceId);
+
+ /**
+ * Updates the last synchronization date.
+ *
+ * @param tenantId the tenant ID
+ * @param sourceInstanceId the source instance ID
+ * @param syncDate the synchronization date to set
+ */
+ void updateLastSyncDate(String tenantId, String sourceInstanceId, Date syncDate);
+
+ /**
+ * Retrieves the last synchronization date.
+ *
+ * @param tenantId the tenant ID
+ * @param sourceInstanceId the source instance ID
+ * @return the last synchronization date
+ */
+ Date getLastSyncDate(String tenantId, String sourceInstanceId);
+
+ default void updateModificationMetadata(Item item, String userId) {
+ item.setLastModifiedBy(userId);
+ item.setLastModificationDate(new Date());
+ }
+}
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/ItemAuditService.java b/api/src/main/java/org/apache/unomi/api/tenants/ItemAuditService.java
new file mode 100644
index 000000000..77b5bb2c2
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/ItemAuditService.java
@@ -0,0 +1,98 @@
+/*
+ * 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.api.tenants;
+
+import org.apache.unomi.api.Item;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * A service to track and audit changes to items.
+ */
+public interface ItemAuditService {
+ /**
+ * Records the creation of an item.
+ *
+ * @param item the item being created
+ * @param userId the user performing the creation
+ */
+ void auditCreate(Item item, String userId);
+
+ /**
+ * Records the update of an item.
+ *
+ * @param item the item being updated
+ * @param userId the user performing the update
+ */
+ void auditUpdate(Item item, String userId);
+
+ /**
+ * Records the deletion of an item.
+ *
+ * @param item the item being deleted
+ * @param userId the user performing the deletion
+ */
+ void auditDelete(Item item, String userId);
+
+ /**
+ * Retrieves items modified since a specific date.
+ *
+ * @param tenantId the tenant ID to filter by
+ * @param since the date to check modifications from
+ * @return a list of modified items
+ */
+ List<Item> getModifiedItems(String tenantId, Date since);
+
+ /**
+ * Retrieves items modified since the last synchronization.
+ *
+ * @param tenantId the tenant ID to filter by
+ * @param sourceInstanceId the source instance ID
+ * @return a list of modified items
+ */
+ List<Item> getModifiedItemsSinceLastSync(String tenantId, String sourceInstanceId);
+
+ /**
+ * Updates the last synchronization date.
+ *
+ * @param tenantId the tenant ID
+ * @param sourceInstanceId the source instance ID
+ * @param syncDate the synchronization date to set
+ */
+ void updateLastSyncDate(String tenantId, String sourceInstanceId, Date syncDate);
+
+ /**
+ * Retrieves the last synchronization date.
+ *
+ * @param tenantId the tenant ID
+ * @param sourceInstanceId the source instance ID
+ * @return the last synchronization date
+ */
+ Date getLastSyncDate(String tenantId, String sourceInstanceId);
+
+ /**
+ * Updates the modification metadata of an item.
+ *
+ * @param item the item to update
+ * @param userId the user performing the modification
+ */
+ default void updateModificationMetadata(Item item, String userId) {
+ item.setLastModifiedBy(userId);
+ item.setLastModificationDate(new Date());
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/ResourceQuota.java b/api/src/main/java/org/apache/unomi/api/tenants/ResourceQuota.java
new file mode 100644
index 000000000..6d9e3359c
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/ResourceQuota.java
@@ -0,0 +1,258 @@
+/*
+ * 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.api.tenants;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Defines resource quotas and limits for a tenant.
+ * This class manages various resource constraints to ensure fair usage and prevent abuse.
+ * Each quota represents a maximum limit that the tenant cannot exceed.
+ * When a quota is reached, the system will prevent further resource allocation until
+ * resources are freed or the quota is increased.
+ */
+public class ResourceQuota {
+ /**
+ * The maximum number of profiles that can be stored for this tenant.
+ * When this limit is reached, attempts to create new profiles will be rejected.
+ */
+ private long maxProfiles;
+
+ /**
+ * The maximum number of events that can be processed per time period for this tenant.
+ * Events beyond this limit will be rejected until the next period begins.
+ */
+ private long maxEvents;
+
+ /**
+ * The maximum number of rules that can be defined for this tenant.
+ * Attempts to create rules beyond this limit will be rejected.
+ */
+ private long maxRules;
+
+ /**
+ * The maximum number of segments that can be defined for this tenant.
+ * Attempts to create segments beyond this limit will be rejected.
+ */
+ private long maxSegments;
+
+ /**
+ * The maximum storage size in bytes that this tenant can use.
+ * This includes all data associated with the tenant including profiles,
+ * events, rules, and other stored data.
+ */
+ private long maxStorageSize;
+
+ /**
+ * The maximum number of concurrent API requests that can be processed
+ * for this tenant. Additional requests will be rejected with a 429 status
+ * until ongoing requests complete.
+ */
+ private int maxConcurrentRequests;
+
+ /**
+ * The maximum number of API keys (both public and private) that can be
+ * generated for this tenant. This includes both active and historical keys
+ * stored for auditing purposes.
+ */
+ private int maxApiKeys;
+
+ /**
+ * The maximum number of days that data will be retained for this tenant.
+ * Data older than this period will be automatically purged from the system.
+ * A value of 0 indicates no automatic purging.
+ */
+ private long maxDataRetentionDays;
+
+ /**
+ * The maximum number of API requests that can be made per time period
+ * for this tenant. Requests beyond this limit will be rejected with
+ * a 429 status until the next period begins.
+ */
+ private long maxRequests;
+
+ /**
+ * Custom quota limits that can be defined for tenant-specific needs.
+ * The map keys represent the quota type and the values represent the limits.
+ * These quotas can be used to limit custom resources or actions specific
+ * to certain tenant use cases.
+ */
+ private Map<String, Long> customQuotas = new HashMap<>();
+
+ /**
+ * Gets the maximum number of profiles allowed for the tenant.
+ * @return the maximum number of profiles
+ */
+ public long getMaxProfiles() {
+ return maxProfiles;
+ }
+
+ /**
+ * Sets the maximum number of profiles allowed for the tenant.
+ * @param maxProfiles the maximum number of profiles to set (must be >= 0)
+ */
+ public void setMaxProfiles(long maxProfiles) {
+ this.maxProfiles = maxProfiles;
+ }
+
+ /**
+ * Gets the maximum number of events allowed for the tenant per time period.
+ * @return the maximum number of events
+ */
+ public long getMaxEvents() {
+ return maxEvents;
+ }
+
+ /**
+ * Sets the maximum number of events allowed for the tenant per time period.
+ * @param maxEvents the maximum number of events to set (must be >= 0)
+ */
+ public void setMaxEvents(long maxEvents) {
+ this.maxEvents = maxEvents;
+ }
+
+ /**
+ * Gets the maximum number of rules allowed for the tenant.
+ * @return the maximum number of rules
+ */
+ public long getMaxRules() {
+ return maxRules;
+ }
+
+ /**
+ * Sets the maximum number of rules allowed for the tenant.
+ * @param maxRules the maximum number of rules to set (must be >= 0)
+ */
+ public void setMaxRules(long maxRules) {
+ this.maxRules = maxRules;
+ }
+
+ /**
+ * Gets the maximum number of segments allowed for the tenant.
+ * @return the maximum number of segments
+ */
+ public long getMaxSegments() {
+ return maxSegments;
+ }
+
+ /**
+ * Sets the maximum number of segments allowed for the tenant.
+ * @param maxSegments the maximum number of segments to set (must be >= 0)
+ */
+ public void setMaxSegments(long maxSegments) {
+ this.maxSegments = maxSegments;
+ }
+
+ /**
+ * Gets the maximum storage size in bytes allowed for the tenant.
+ * @return the maximum storage size in bytes
+ */
+ public long getMaxStorageSize() {
+ return maxStorageSize;
+ }
+
+ /**
+ * Sets the maximum storage size in bytes allowed for the tenant.
+ * @param maxStorageSize the maximum storage size in bytes to set (must be >= 0)
+ */
+ public void setMaxStorageSize(long maxStorageSize) {
+ this.maxStorageSize = maxStorageSize;
+ }
+
+ /**
+ * Gets the maximum number of concurrent requests allowed for the tenant.
+ * @return the maximum number of concurrent requests
+ */
+ public int getMaxConcurrentRequests() {
+ return maxConcurrentRequests;
+ }
+
+ /**
+ * Sets the maximum number of concurrent requests allowed for the tenant.
+ * @param maxConcurrentRequests the maximum number of concurrent requests to set (must be >= 0)
+ */
+ public void setMaxConcurrentRequests(int maxConcurrentRequests) {
+ this.maxConcurrentRequests = maxConcurrentRequests;
+ }
+
+ /**
+ * Gets the maximum number of API keys allowed for the tenant.
+ * @return the maximum number of API keys
+ */
+ public int getMaxApiKeys() {
+ return maxApiKeys;
+ }
+
+ /**
+ * Sets the maximum number of API keys allowed for the tenant.
+ * @param maxApiKeys the maximum number of API keys to set (must be >= 0)
+ */
+ public void setMaxApiKeys(int maxApiKeys) {
+ this.maxApiKeys = maxApiKeys;
+ }
+
+ /**
+ * Gets the maximum number of days to retain data for the tenant.
+ * @return the maximum data retention period in days (0 for no limit)
+ */
+ public long getMaxDataRetentionDays() {
+ return maxDataRetentionDays;
+ }
+
+ /**
+ * Sets the maximum number of days to retain data for the tenant.
+ * @param maxDataRetentionDays the maximum data retention period in days to set (0 for no limit, must be >= 0)
+ */
+ public void setMaxDataRetentionDays(long maxDataRetentionDays) {
+ this.maxDataRetentionDays = maxDataRetentionDays;
+ }
+
+ /**
+ * Gets the maximum number of API requests allowed per time period.
+ * @return the maximum number of requests per time period
+ */
+ public long getMaxRequests() {
+ return maxRequests;
+ }
+
+ /**
+ * Sets the maximum number of API requests allowed per time period.
+ * @param maxRequests the maximum number of requests to set (must be >= 0)
+ */
+ public void setMaxRequests(long maxRequests) {
+ this.maxRequests = maxRequests;
+ }
+
+ /**
+ * Gets the custom quotas map. Custom quotas can be used to define
+ * tenant-specific resource limits beyond the standard quotas.
+ * @return map of custom quota types to their limits
+ */
+ public Map<String, Long> getCustomQuotas() {
+ return customQuotas;
+ }
+
+ /**
+ * Sets the custom quotas map. Custom quotas can be used to define
+ * tenant-specific resource limits beyond the standard quotas.
+ * @param customQuotas map of custom quota types to their limits (values must be >= 0)
+ */
+ public void setCustomQuotas(Map<String, Long> customQuotas) {
+ this.customQuotas = customQuotas;
+ }
+}
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java b/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java
new file mode 100644
index 000000000..f38b9b8d7
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java
@@ -0,0 +1,367 @@
+/*
+ * 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.api.tenants;
+
+import org.apache.unomi.api.Item;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+import javax.xml.bind.annotation.XmlTransient;
+
+/**
+ * Represents a tenant in the system.
+ * A tenant is an isolated entity within the system with its own users, data, and configuration.
+ * Each tenant has its own set of API keys (public and private) for authentication and authorization,
+ * resource quotas to limit usage, and event permissions to control access to specific event types.
+ * This class extends the base Item class and provides functionality for managing tenant
+ * settings, resource quotas, and lifecycle.
+ */
+public class Tenant extends Item {
+ /**
+ * The item type for a tenant.
+ */
+ public static final String ITEM_TYPE = "tenant";
+
+ /**
+ * The display name of the tenant.
+ */
+ private String name;
+
+ /**
+ * A description of the tenant's purpose or usage.
+ */
+ private String description;
+
+ /**
+ * The current operational status of the tenant.
+ */
+ private TenantStatus status;
+
+ /**
+ * The date when the tenant was created.
+ */
+ private Date creationDate;
+
+ /**
+ * The date when the tenant was last modified.
+ */
+ private Date lastModificationDate;
+
+ /**
+ * The resource quota limits for the tenant.
+ * This includes limits on profiles, events, and requests.
+ */
+ private ResourceQuota resourceQuota;
+
+ /**
+ * The list of all API keys (both active and historical) associated with the tenant.
+ * This list maintains a history of all API keys that have been generated for the tenant,
+ * including both public and private keys, for auditing purposes.
+ */
+ private List<ApiKey> apiKeys;
+
+ /**
+ * Additional custom properties for the tenant.
+ */
+ private Map<String, Object> properties;
+
+ /**
+ * The set of event types that are restricted for this tenant.
+ * Events of these types will require IP validation before being processed.
+ * This is used to control which event types require additional validation
+ * at the tenant level.
+ */
+ private Set<String> restrictedEventTypes = new HashSet<>();
+
+ /**
+ * The set of IP addresses or CIDR ranges that are authorized to make requests
+ * for this tenant. Requests from IP addresses not in this set will be rejected.
+ */
+ private Set<String> authorizedIPs = new HashSet<>();
+
+ /**
+ * Default constructor that initializes the tenant as an Item.
+ * Sets the item type to TENANT and initializes empty collections.
+ */
+ public Tenant() {
+ super();
+ setItemType(ITEM_TYPE);
+ }
+
+ /**
+ * Gets the tenant's display name.
+ * @return the tenant name
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Sets the tenant's display name.
+ * @param name the tenant name to set
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Gets the tenant's description.
+ * @return the tenant description
+ */
+ public String getDescription() {
+ return description;
+ }
+
+ /**
+ * Sets the tenant's description.
+ * @param description the tenant description to set
+ */
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ /**
+ * Gets the tenant's current status.
+ * @return the tenant status
+ */
+ public TenantStatus getStatus() {
+ return status;
+ }
+
+ /**
+ * Sets the tenant's status.
+ * @param status the tenant status to set
+ */
+ public void setStatus(TenantStatus status) {
+ this.status = status;
+ }
+
+ /**
+ * Gets the tenant's creation date.
+ * @return the creation date
+ */
+ @Override
+ public Date getCreationDate() {
+ return creationDate;
+ }
+
+ /**
+ * Sets the tenant's creation date.
+ * @param creationDate the creation date to set
+ */
+ public void setCreationDate(Date creationDate) {
+ this.creationDate = creationDate;
+ }
+
+ /**
+ * Gets the tenant's last modification date.
+ * @return the last modification date
+ */
+ @Override
+ public Date getLastModificationDate() {
+ return lastModificationDate;
+ }
+
+ /**
+ * Sets the tenant's last modification date.
+ * @param lastModificationDate the last modification date to set
+ */
+ public void setLastModificationDate(Date lastModificationDate) {
+ this.lastModificationDate = lastModificationDate;
+ }
+
+ /**
+ * Gets the tenant's resource quota settings.
+ * @return the resource quota settings
+ */
+ public ResourceQuota getResourceQuota() {
+ return resourceQuota;
+ }
+
+ /**
+ * Sets the tenant's resource quota settings.
+ * @param resourceQuota the resource quota settings to set
+ */
+ public void setResourceQuota(ResourceQuota resourceQuota) {
+ this.resourceQuota = resourceQuota;
+ }
+
+ /**
+ * Gets the list of all API keys associated with the tenant.
+ * This includes both active and historical keys for auditing purposes.
+ * @return the list of API keys
+ */
+ public List<ApiKey> getApiKeys() {
+ return apiKeys;
+ }
+
+ /**
+ * Sets the list of API keys associated with the tenant.
+ * @param apiKeys the list of API keys to set
+ */
+ public void setApiKeys(List<ApiKey> apiKeys) {
+ this.apiKeys = apiKeys;
+ }
+
+ /**
+ * Gets additional tenant properties as key-value pairs.
+ * @return map of additional properties
+ */
+ public Map<String, Object> getProperties() {
+ return properties;
+ }
+
+ /**
+ * Sets additional tenant properties as key-value pairs.
+ * @param properties map of additional properties to set
+ */
+ public void setProperties(Map<String, Object> properties) {
+ this.properties = properties;
+ }
+
+ /**
+ * Gets the set of event types that are restricted for this tenant.
+ * Events of these types will require IP validation before being processed.
+ * @return the set of restricted event types
+ */
+ public Set<String> getRestrictedEventTypes() {
+ return restrictedEventTypes;
+ }
+
+ /**
+ * Sets the event types that are restricted for this tenant.
+ * Events of these types will require IP validation before being processed.
+ * @param restrictedEventTypes the set of restricted event types to set
+ */
+ public void setRestrictedEventTypes(Set<String> restrictedEventTypes) {
+ this.restrictedEventTypes = restrictedEventTypes;
+ }
+
+ /**
+ * Gets the set of authorized IP addresses or CIDR ranges for this tenant.
+ * @return the set of authorized IP addresses/ranges
+ */
+ public Set<String> getAuthorizedIPs() {
+ return authorizedIPs;
+ }
+
+ /**
+ * Sets the authorized IP addresses or CIDR ranges for this tenant.
+ * @param authorizedIPs the set of authorized IP addresses/ranges to set
+ */
+ public void setAuthorizedIPs(Set<String> authorizedIPs) {
+ this.authorizedIPs = authorizedIPs;
+ }
+
+ /**
+ * Gets the currently active private API key for the tenant.
+ * This method resolves the active private API key from the API keys list.
+ * It returns the most recently created, non-revoked, non-expired private key.
+ * This key should be used for secure operations and administrative tasks.
+ * @return the active private API key, or null if no valid private key exists
+ */
+ @XmlTransient
+ public String getPrivateApiKey() {
+ if (apiKeys == null) {
+ return null;
+ }
+
+ return apiKeys.stream()
+ .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PRIVATE)
+ .filter(key -> !key.isRevoked())
+ .filter(key -> key.getExpirationDate() == null || key.getExpirationDate().after(new Date()))
+ .max(Comparator.comparing(ApiKey::getCreationDate))
+ .map(ApiKey::getKey)
+ .orElse(null);
+ }
+
+ /**
+ * Gets the currently active public API key for the tenant.
+ * This method resolves the active public API key from the API keys list.
+ * It returns the most recently created, non-revoked, non-expired public key.
+ * This key can be safely used in client-side applications.
+ * @return the active public API key, or null if no valid public key exists
+ */
+ @XmlTransient
+ public String getPublicApiKey() {
+ if (apiKeys == null) {
+ return null;
+ }
+
+ return apiKeys.stream()
+ .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PUBLIC)
+ .filter(key -> !key.isRevoked())
+ .filter(key -> key.getExpirationDate() == null || key.getExpirationDate().after(new Date()))
+ .max(Comparator.comparing(ApiKey::getCreationDate))
+ .map(ApiKey::getKey)
+ .orElse(null);
+ }
+
+ /**
+ * Gets all active private API keys for the tenant.
+ * This method returns all non-revoked, non-expired private keys.
+ * @return list of active private API keys, or empty list if none exist
+ */
+ @XmlTransient
+ public List<ApiKey> getActivePrivateApiKeys() {
+ if (apiKeys == null) {
+ return new ArrayList<>();
+ }
+
+ return apiKeys.stream()
+ .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PRIVATE)
+ .filter(key -> !key.isRevoked())
+ .filter(key -> key.getExpirationDate() == null || key.getExpirationDate().after(new Date()))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Gets all active public API keys for the tenant.
+ * This method returns all non-revoked, non-expired public keys.
+ * @return list of active public API keys, or empty list if none exist
+ */
+ @XmlTransient
+ public List<ApiKey> getActivePublicApiKeys() {
+ if (apiKeys == null) {
+ return new ArrayList<>();
+ }
+
+ return apiKeys.stream()
+ .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PUBLIC)
+ .filter(key -> !key.isRevoked())
+ .filter(key -> key.getExpirationDate() == null || key.getExpirationDate().after(new Date()))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Gets all active API keys for the tenant.
+ * This method returns all non-revoked, non-expired keys regardless of type.
+ * @return list of all active API keys, or empty list if none exist
+ */
+ @XmlTransient
+ public List<ApiKey> getActiveApiKeys() {
+ if (apiKeys == null) {
+ return new ArrayList<>();
+ }
+
+ return apiKeys.stream()
+ .filter(key -> !key.isRevoked())
+ .filter(key -> key.getExpirationDate() == null || key.getExpirationDate().after(new Date()))
+ .collect(Collectors.toList());
+ }
+}
diff --git a/tools/shell-commands/src/main/resources/requestBody/2.2.0/suffix_ids.painless b/api/src/main/java/org/apache/unomi/api/tenants/TenantAuditService.java
similarity index 68%
copy from tools/shell-commands/src/main/resources/requestBody/2.2.0/suffix_ids.painless
copy to api/src/main/java/org/apache/unomi/api/tenants/TenantAuditService.java
index 3b63549f6..261a36e23 100644
--- a/tools/shell-commands/src/main/resources/requestBody/2.2.0/suffix_ids.painless
+++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantAuditService.java
@@ -14,6 +14,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+package org.apache.unomi.api.tenants;
-ctx._id = ctx._id + '#ID_SUFFIX';
-ctx._source.itemId = ctx._id;
\ No newline at end of file
+/**
+ * A service to audit tenant-related operations.
+ */
+public interface TenantAuditService {
+ /**
+ * Logs a tenant operation for auditing purposes.
+ *
+ * @param tenantId the ID of the tenant
+ * @param operation the operation being performed
+ */
+ void logTenantOperation(String tenantId, String operation);
+}
\ No newline at end of file
diff --git a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java b/api/src/main/java/org/apache/unomi/api/tenants/TenantBackupMetadata.java
similarity index 52%
copy from services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java
copy to api/src/main/java/org/apache/unomi/api/tenants/TenantBackupMetadata.java
index 5ba37d5e5..4cae6cfba 100644
--- a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java
+++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantBackupMetadata.java
@@ -14,27 +14,25 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.unomi.services.impl.scheduler;
+package org.apache.unomi.api.tenants;
-import org.junit.Test;
+public class TenantBackupMetadata {
+ private String tenantId;
+ private long timestamp;
-import java.time.LocalDateTime;
-import java.time.ZoneOffset;
-import java.time.ZonedDateTime;
+ public String getTenantId() {
+ return tenantId;
+ }
-import static org.junit.Assert.*;
+ public void setTenantId(String tenantId) {
+ this.tenantId = tenantId;
+ }
-public class SchedulerServiceImplTest {
+ public long getTimestamp() {
+ return timestamp;
+ }
- @Test
- public void getTimeDiffInSeconds_whenGiveHourOfDay_shouldReturnDifferenceInSeconds(){
- //Arrange
- SchedulerServiceImpl service = new SchedulerServiceImpl();
- int hourToRunInUtc = 11;
- ZonedDateTime timeNowInUtc = ZonedDateTime.of(LocalDateTime.parse("2020-01-13T10:00:00"), ZoneOffset.UTC);
- //Act
- long seconds = service.getTimeDiffInSeconds(hourToRunInUtc, timeNowInUtc);
- //Assert
- assertEquals(3600, seconds);
+ public void setTimestamp(long timestamp) {
+ this.timestamp = timestamp;
}
-}
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java b/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
new file mode 100644
index 000000000..a730b99b0
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
@@ -0,0 +1,145 @@
+/*
+ * 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.api.tenants;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Service interface for managing multi-tenant functionality in Apache Unomi.
+ * This service provides methods for creating, retrieving, and managing tenants,
+ * as well as handling tenant-specific API keys and tenant context management.
+ * It ensures proper isolation between different tenants' data and configurations.
+ */
+public interface TenantService {
+
+ /**
+ * The ID of the system tenant, which is used for system-wide configurations and data.
+ * The system tenant is special and cannot be removed.
+ */
+ String SYSTEM_TENANT = "system";
+
+ /**
+ * Creates a new tenant in the system with the specified ID and properties.
+ *
+ * @param requestedId the requested ID for the tenant
+ * @param properties additional properties to associate with the tenant
+ * @return the newly created Tenant object
+ * @throws IllegalArgumentException if the requestedId is invalid, already exists, or is a reserved ID
+ */
+ Tenant createTenant(String requestedId, Map<String, Object> properties);
+
+ /**
+ * Generates a new API key for the specified tenant with an optional validity period.
+ *
+ * @param tenantId the ID of the tenant for which to generate the API key
+ * @param validityPeriod the period (in milliseconds) for which the API key should be valid, null for no expiration
+ * @return the generated ApiKey object containing the key and associated metadata
+ * @throws IllegalArgumentException if tenantId is null or does not exist
+ */
+ ApiKey generateApiKey(String tenantId, Long validityPeriod);
+
+ /**
+ * Retrieves a tenant by its ID.
+ *
+ * @param tenantId the ID of the tenant to retrieve
+ * @return the Tenant object if found, null otherwise
+ */
+ Tenant getTenant(String tenantId);
+
+ /**
+ * Retrieves all tenants registered in the system.
+ * This method provides access to all tenant configurations and metadata,
+ * and should be used with appropriate access controls.
+ *
+ * @return a List of all Tenant objects in the system
+ */
+ List<Tenant> getAllTenants();
+
+ /**
+ * Updates an existing tenant's information.
+ *
+ * @param tenant the tenant with updated information
+ * @throws IllegalArgumentException if tenant is null or does not exist
+ */
+ void saveTenant(Tenant tenant);
+
+ /**
+ * Deletes a tenant and all associated data from the system.
+ *
+ * @param tenantId the ID of the tenant to delete
+ * @throws IllegalArgumentException if tenantId is null or does not exist
+ */
+ void deleteTenant(String tenantId);
+
+ /**
+ * Validates an API key for a given tenant.
+ *
+ * @param tenantId the ID of the tenant
+ * @param key the API key to validate
+ * @return true if the key is valid, false otherwise
+ */
+ boolean validateApiKey(String tenantId, String key);
+
+ /**
+ * Generates a new API key of the specified type for the tenant.
+ *
+ * @param tenantId the ID of the tenant for which to generate the API key
+ * @param keyType the type of API key to generate (PUBLIC or PRIVATE)
+ * @param validityPeriod the period (in milliseconds) for which the API key should be valid, null for no expiration
+ * @return the generated ApiKey object containing the key and associated metadata
+ * @throws IllegalArgumentException if tenantId is null or does not exist
+ */
+ ApiKey generateApiKeyWithType(String tenantId, ApiKey.ApiKeyType keyType, Long validityPeriod);
+
+ /**
+ * Validates an API key for a given tenant and checks if it has the required type.
+ *
+ * @param tenantId the ID of the tenant
+ * @param key the API key to validate
+ * @param requiredType the required type of the API key
+ * @return true if the key is valid and matches the required type, false otherwise
+ */
+ boolean validateApiKeyWithType(String tenantId, String key, ApiKey.ApiKeyType requiredType);
+
+ /**
+ * Gets the API key of the specified type for a tenant.
+ *
+ * @param tenantId the ID of the tenant
+ * @param keyType the type of API key to retrieve
+ * @return the API key of the specified type, or null if not found
+ */
+ ApiKey getApiKey(String tenantId, ApiKey.ApiKeyType keyType);
+
+ /**
+ * Retrieves a tenant by its API key.
+ *
+ * @param key the API key to look up
+ * @return the Tenant object if found, null otherwise
+ */
+ Tenant getTenantByApiKey(String key);
+
+ /**
+ * Retrieves a tenant by its API key, ensuring it matches the required type.
+ *
+ * @param key the API key to look up
+ * @param requiredType the required type of the API key (PUBLIC or PRIVATE)
+ * @return the Tenant object if found and key type matches, null otherwise
+ */
+ Tenant getTenantByApiKey(String key, ApiKey.ApiKeyType requiredType);
+
+}
diff --git a/services/src/main/resources/META-INF/cxs/painless/resetScoringPlan.painless b/api/src/main/java/org/apache/unomi/api/tenants/TenantStatus.java
similarity index 54%
copy from services/src/main/resources/META-INF/cxs/painless/resetScoringPlan.painless
copy to api/src/main/java/org/apache/unomi/api/tenants/TenantStatus.java
index 2324069d2..aad239918 100644
--- a/services/src/main/resources/META-INF/cxs/painless/resetScoringPlan.painless
+++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantStatus.java
@@ -14,18 +14,35 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+package org.apache.unomi.api.tenants;
-/*
- This script is used to reset all profiles that have a scoring plan matching the given params.scoringId
- required params:
- - params.scoringId: the ID of the Scoring plan
-*/
+/**
+ * Enumeration of possible tenant statuses.
+ * This enum defines the various states a tenant can be in within the system.
+ */
+public enum TenantStatus {
+ /**
+ * Tenant is active and fully operational
+ */
+ ACTIVE,
+
+ /**
+ * Tenant is disabled and cannot perform any operations
+ */
+ DISABLED,
+
+ /**
+ * Tenant is temporarily suspended, typically due to policy violations or maintenance
+ */
+ SUSPENDED,
-// remove score for the given params.scoringId
-ctx._source.scores.remove(params.scoringId);
+ /**
+ * Tenant is created but waiting for activation process to complete
+ */
+ PENDING_ACTIVATION,
-// Update lastUpdated date on profile
-if (!ctx._source.containsKey("systemProperties")) {
- ctx._source.put("systemProperties", [:]);
+ /**
+ * Tenant is undergoing scheduled maintenance
+ */
+ MAINTENANCE
}
-ctx._source.systemProperties.put("lastUpdated", ZonedDateTime.ofInstant(Instant.ofEpochMilli(System.currentTimeMillis()), ZoneId.of("Z")));
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantTransformationListener.java b/api/src/main/java/org/apache/unomi/api/tenants/TenantTransformationListener.java
new file mode 100644
index 000000000..eb8073537
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantTransformationListener.java
@@ -0,0 +1,72 @@
+/*
+ * 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.api.tenants;
+
+import org.apache.unomi.api.Item;
+
+/**
+ * Interface for item-specific data transformations that can be implemented by Unomi extensions.
+ * Transformations can include data masking, format conversion, or other data modifications.
+ * Multiple listeners can be registered and will be called in order of priority.
+ */
+public interface TenantTransformationListener {
+
+ /**
+ * Gets the priority of this listener. Listeners with higher priority values will be executed first.
+ * @return the priority value (default is 0)
+ */
+ default int getPriority() {
+ return 0;
+ }
+
+ /**
+ * Applies forward transformation to data in an item for a specific tenant
+ * @param item The item containing data to transform
+ * @param tenantId The ID of the tenant
+ * @return transformed item if transformation was successful, null otherwise
+ */
+ Item transformItem(Item item, String tenantId);
+
+ /**
+ * Checks if transformation is available and enabled
+ * @return true if transformation is available and enabled
+ */
+ boolean isTransformationEnabled();
+
+ /**
+ * Reverses the transformation of data in an item for a specific tenant
+ * @param item The item containing data to reverse transform
+ * @param tenantId The ID of the tenant
+ * @return transformed item if reverse transformation was successful, null otherwise
+ */
+ Item reverseTransformItem(Item item, String tenantId);
+
+ /**
+ * Checks if an item contains transformed data
+ * @param item The item to check
+ * @return true if the item contains transformed data
+ */
+ default boolean isItemTransformed(Item item) {
+ return item != null && Boolean.TRUE.equals(item.getSystemMetadata("transformed"));
+ }
+
+ /**
+ * Gets the transformation type identifier
+ * @return String identifying the type of transformation
+ */
+ String getTransformationType();
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/security/SecurityAuditReport.java b/api/src/main/java/org/apache/unomi/api/tenants/security/SecurityAuditReport.java
new file mode 100644
index 000000000..eb1215bfc
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/security/SecurityAuditReport.java
@@ -0,0 +1,191 @@
+/*
+ * 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.api.tenants.security;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Represents a security audit report for a tenant.
+ * This class contains information about security-related events and statistics
+ * within a specified time period.
+ */
+public class SecurityAuditReport {
+ private String tenantId;
+ private Date startDate;
+ private Date endDate;
+ private List<SecurityEvent> events;
+ private Map<String, Integer> eventCounts;
+ private Map<String, Object> statistics;
+
+ /**
+ * Gets the tenant ID associated with this report.
+ * @return the tenant ID
+ */
+ public String getTenantId() {
+ return tenantId;
+ }
+
+ /**
+ * Sets the tenant ID associated with this report.
+ * @param tenantId the tenant ID to set
+ */
+ public void setTenantId(String tenantId) {
+ this.tenantId = tenantId;
+ }
+
+ /**
+ * Gets the start date of the audit period.
+ * @return the start date
+ */
+ public Date getStartDate() {
+ return startDate;
+ }
+
+ /**
+ * Sets the start date of the audit period.
+ * @param startDate the start date to set
+ */
+ public void setStartDate(Date startDate) {
+ this.startDate = startDate;
+ }
+
+ /**
+ * Gets the end date of the audit period.
+ * @return the end date
+ */
+ public Date getEndDate() {
+ return endDate;
+ }
+
+ /**
+ * Sets the end date of the audit period.
+ * @param endDate the end date to set
+ */
+ public void setEndDate(Date endDate) {
+ this.endDate = endDate;
+ }
+
+ /**
+ * Gets the list of security events.
+ * @return list of security events
+ */
+ public List<SecurityEvent> getEvents() {
+ return events;
+ }
+
+ /**
+ * Sets the list of security events.
+ * @param events list of security events to set
+ */
+ public void setEvents(List<SecurityEvent> events) {
+ this.events = events;
+ }
+
+ /**
+ * Gets the count of events by type.
+ * @return map of event types to their counts
+ */
+ public Map<String, Integer> getEventCounts() {
+ return eventCounts;
+ }
+
+ /**
+ * Sets the count of events by type.
+ * @param eventCounts map of event types to their counts
+ */
+ public void setEventCounts(Map<String, Integer> eventCounts) {
+ this.eventCounts = eventCounts;
+ }
+
+ /**
+ * Gets additional statistics about the audit period.
+ * @return map of statistics
+ */
+ public Map<String, Object> getStatistics() {
+ return statistics;
+ }
+
+ /**
+ * Sets additional statistics about the audit period.
+ * @param statistics map of statistics to set
+ */
+ public void setStatistics(Map<String, Object> statistics) {
+ this.statistics = statistics;
+ }
+
+ /**
+ * Represents a security-related event.
+ */
+ public static class SecurityEvent {
+ private String type;
+ private Date timestamp;
+ private String description;
+ private String userId;
+ private String ipAddress;
+ private Map<String, Object> details;
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public Date getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(Date timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+
+ public String getIpAddress() {
+ return ipAddress;
+ }
+
+ public void setIpAddress(String ipAddress) {
+ this.ipAddress = ipAddress;
+ }
+
+ public Map<String, Object> getDetails() {
+ return details;
+ }
+
+ public void setDetails(Map<String, Object> details) {
+ this.details = details;
+ }
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/security/SecuritySettings.java b/api/src/main/java/org/apache/unomi/api/tenants/security/SecuritySettings.java
new file mode 100644
index 000000000..ade4913db
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/security/SecuritySettings.java
@@ -0,0 +1,171 @@
+/*
+ * 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.api.tenants.security;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Represents security settings for a tenant.
+ * This class contains configuration for various security aspects including
+ * authentication, authorization, and API access.
+ */
+public class SecuritySettings {
+ private boolean enabled;
+ private AuthenticationConfig authentication;
+ private AuthorizationConfig authorization;
+ private Map<String, Object> additionalSettings;
+
+ /**
+ * Gets whether security is enabled for the tenant.
+ * @return true if security is enabled, false otherwise
+ */
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ /**
+ * Sets whether security is enabled for the tenant.
+ * @param enabled true to enable security, false to disable
+ */
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ /**
+ * Gets the authentication configuration.
+ * @return the authentication configuration
+ */
+ public AuthenticationConfig getAuthentication() {
+ return authentication;
+ }
+
+ /**
+ * Sets the authentication configuration.
+ * @param authentication the authentication configuration to set
+ */
+ public void setAuthentication(AuthenticationConfig authentication) {
+ this.authentication = authentication;
+ }
+
+ /**
+ * Gets the authorization configuration.
+ * @return the authorization configuration
+ */
+ public AuthorizationConfig getAuthorization() {
+ return authorization;
+ }
+
+ /**
+ * Sets the authorization configuration.
+ * @param authorization the authorization configuration to set
+ */
+ public void setAuthorization(AuthorizationConfig authorization) {
+ this.authorization = authorization;
+ }
+
+ /**
+ * Gets additional security settings as key-value pairs.
+ * @return map of additional settings
+ */
+ public Map<String, Object> getAdditionalSettings() {
+ return additionalSettings;
+ }
+
+ /**
+ * Sets additional security settings as key-value pairs.
+ * @param additionalSettings map of additional settings to set
+ */
+ public void setAdditionalSettings(Map<String, Object> additionalSettings) {
+ this.additionalSettings = additionalSettings;
+ }
+
+ /**
+ * Configuration for authentication settings.
+ */
+ public static class AuthenticationConfig {
+ private List<String> allowedAuthMethods;
+ private int maxLoginAttempts;
+ private int lockoutDurationMinutes;
+ private boolean requireMfa;
+
+ public List<String> getAllowedAuthMethods() {
+ return allowedAuthMethods;
+ }
+
+ public void setAllowedAuthMethods(List<String> allowedAuthMethods) {
+ this.allowedAuthMethods = allowedAuthMethods;
+ }
+
+ public int getMaxLoginAttempts() {
+ return maxLoginAttempts;
+ }
+
+ public void setMaxLoginAttempts(int maxLoginAttempts) {
+ this.maxLoginAttempts = maxLoginAttempts;
+ }
+
+ public int getLockoutDurationMinutes() {
+ return lockoutDurationMinutes;
+ }
+
+ public void setLockoutDurationMinutes(int lockoutDurationMinutes) {
+ this.lockoutDurationMinutes = lockoutDurationMinutes;
+ }
+
+ public boolean isRequireMfa() {
+ return requireMfa;
+ }
+
+ public void setRequireMfa(boolean requireMfa) {
+ this.requireMfa = requireMfa;
+ }
+ }
+
+ /**
+ * Configuration for authorization settings.
+ */
+ public static class AuthorizationConfig {
+ private List<String> roles;
+ private List<String> permissions;
+ private Map<String, List<String>> rolePermissions;
+
+ public List<String> getRoles() {
+ return roles;
+ }
+
+ public void setRoles(List<String> roles) {
+ this.roles = roles;
+ }
+
+ public List<String> getPermissions() {
+ return permissions;
+ }
+
+ public void setPermissions(List<String> permissions) {
+ this.permissions = permissions;
+ }
+
+ public Map<String, List<String>> getRolePermissions() {
+ return rolePermissions;
+ }
+
+ public void setRolePermissions(Map<String, List<String>> rolePermissions) {
+ this.rolePermissions = rolePermissions;
+ }
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/security/SecurityValidationResult.java b/api/src/main/java/org/apache/unomi/api/tenants/security/SecurityValidationResult.java
new file mode 100644
index 000000000..88ed71ca1
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/security/SecurityValidationResult.java
@@ -0,0 +1,96 @@
+/*
+ * 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.api.tenants.security;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents the result of a security validation operation.
+ * This class contains information about whether the validation was successful,
+ * and if not, what errors were encountered.
+ */
+public class SecurityValidationResult {
+ private boolean valid;
+ private List<String> errors;
+ private String message;
+
+ /**
+ * Default constructor that initializes a valid result with no errors.
+ */
+ public SecurityValidationResult() {
+ this.valid = true;
+ this.errors = new ArrayList<>();
+ }
+
+ /**
+ * Gets whether the validation was successful.
+ * @return true if validation passed, false otherwise
+ */
+ public boolean isValid() {
+ return valid;
+ }
+
+ /**
+ * Sets the validation status.
+ * @param valid true if validation passed, false otherwise
+ */
+ public void setValid(boolean valid) {
+ this.valid = valid;
+ }
+
+ /**
+ * Gets the list of validation errors.
+ * @return list of error messages
+ */
+ public List<String> getErrors() {
+ return errors;
+ }
+
+ /**
+ * Sets the list of validation errors.
+ * @param errors list of error messages
+ */
+ public void setErrors(List<String> errors) {
+ this.errors = errors;
+ }
+
+ /**
+ * Adds an error message to the result.
+ * @param error the error message to add
+ */
+ public void addError(String error) {
+ this.valid = false;
+ this.errors.add(error);
+ }
+
+ /**
+ * Gets the general message associated with the validation result.
+ * @return the message
+ */
+ public String getMessage() {
+ return message;
+ }
+
+ /**
+ * Sets the general message associated with the validation result.
+ * @param message the message to set
+ */
+ public void setMessage(String message) {
+ this.message = message;
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/security/TenantSecurityService.java b/api/src/main/java/org/apache/unomi/api/tenants/security/TenantSecurityService.java
new file mode 100644
index 000000000..0e5a80397
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/security/TenantSecurityService.java
@@ -0,0 +1,56 @@
+/*
+ * 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.api.tenants.security;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Service interface for managing tenants-level security operations and validations.
+ * This service provides comprehensive security features including authentication,
+ * authorization, rate limiting, and security auditing for tenants-specific operations.
+ */
+public interface TenantSecurityService {
+
+ /**
+ * Validates a request against all configured security measures for a tenants.
+ *
+ * @param request the HTTP request to validate
+ * @param tenantId the ID of the tenants making the request
+ * @return a SecurityValidationResult containing the validation outcome and any errors
+ * @throws SecurityException if a critical security violation is detected
+ */
+ SecurityValidationResult validateRequest(HttpServletRequest request, String tenantId);
+
+ /**
+ * Configures security settings for a specific tenants.
+ *
+ * @param tenantId the ID of the tenants to configure
+ * @param settings the security settings to apply
+ * @throws ConfigurationException if the settings are invalid or cannot be applied
+ */
+ void configureSecuritySettings(String tenantId, SecuritySettings settings);
+
+ /**
+ * Generates a security audit report for a tenants within a specified time range.
+ *
+ * @param tenantId the ID of the tenants
+ * @param startTime the start time for the audit period
+ * @param endTime the end time for the audit period
+ * @return a SecurityAuditReport containing security-related events and statistics
+ */
+ SecurityAuditReport generateSecurityAudit(String tenantId, long startTime, long endTime);
+}
diff --git a/api/src/main/java/org/apache/unomi/api/utils/ConditionBuilder.java b/api/src/main/java/org/apache/unomi/api/utils/ConditionBuilder.java
index aceb1ffe5..9871d6a19 100644
--- a/api/src/main/java/org/apache/unomi/api/utils/ConditionBuilder.java
+++ b/api/src/main/java/org/apache/unomi/api/utils/ConditionBuilder.java
@@ -17,13 +17,30 @@
package org.apache.unomi.api.utils;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.conditions.ConditionType;
import org.apache.unomi.api.services.DefinitionsService;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
/**
* Utility class for creating various types of {@link Condition} objects.
* This class provides methods to easily construct conditions used for querying data based on specific criteria.
+ * <p>
+ * The ConditionBuilder supports building complex queries with logical operators (AND, OR, NOT),
+ * property comparisons, nested conditions, and special condition types. The fluent API style
+ * makes it easier to construct readable and maintainable conditions.
+ * <p>
+ * Example usage:
+ * <pre>
+ * ConditionBuilder builder = new ConditionBuilder(definitionsService);
+ * Condition condition = builder.and(
+ * builder.profileProperty("age").greaterThan(18),
+ * builder.profileProperty("gender").equalTo("male")
+ * ).build();
+ * </pre>
*/
public class ConditionBuilder {
@@ -38,276 +55,776 @@ public class ConditionBuilder {
this.definitionsService = definitionsService;
}
+ /**
+ * Sets the definitions service to use for resolving condition types.
+ *
+ * @param definitionsService the definitions service
+ */
public void setDefinitionsService(DefinitionsService definitionsService) {
this.definitionsService = definitionsService;
}
+ /**
+ * Creates an AND condition that combines two sub-conditions, requiring both to be true.
+ *
+ * @param condition1 the first condition to include in the AND operation
+ * @param condition2 the second condition to include in the AND operation
+ * @return a compound condition representing the logical AND of the two conditions
+ */
public CompoundCondition and(ConditionItem condition1, ConditionItem condition2) {
return new CompoundCondition(condition1, condition2, "and");
}
+ /**
+ * Creates an AND condition that combines multiple sub-conditions, requiring all to be true.
+ *
+ * @param conditions the conditions to include in the AND operation
+ * @return a compound condition representing the logical AND of all the conditions
+ */
+ public CompoundCondition and(ConditionItem... conditions) {
+ if (conditions == null || conditions.length == 0) {
+ throw new IllegalArgumentException("At least one condition must be provided");
+ }
+
+ List<Condition> subConditions = new ArrayList<>(conditions.length);
+ for (ConditionItem condition : conditions) {
+ subConditions.add(condition.build());
+ }
+
+ ConditionItem conditionItem = new ConditionItem("booleanCondition", definitionsService);
+ conditionItem.parameter("operator", "and");
+ conditionItem.parameter("subConditions", subConditions);
+
+ return (CompoundCondition) conditionItem;
+ }
+
+ /**
+ * Creates a NOT condition that negates the provided sub-condition.
+ *
+ * @param subCondition the condition to negate
+ * @return a NOT condition that evaluates to true when the sub-condition is false
+ */
public NotCondition not(ConditionItem subCondition) {
return new NotCondition(subCondition);
}
+ /**
+ * Creates an OR condition that combines two sub-conditions, requiring at least one to be true.
+ *
+ * @param condition1 the first condition to include in the OR operation
+ * @param condition2 the second condition to include in the OR operation
+ * @return a compound condition representing the logical OR of the two conditions
+ */
public CompoundCondition or(ConditionItem condition1, ConditionItem condition2) {
return new CompoundCondition(condition1, condition2, "or");
}
+ /**
+ * Creates an OR condition that combines multiple sub-conditions, requiring at least one to be true.
+ *
+ * @param conditions the conditions to include in the OR operation
+ * @return a compound condition representing the logical OR of all the conditions
+ */
+ public CompoundCondition or(ConditionItem... conditions) {
+ if (conditions == null || conditions.length == 0) {
+ throw new IllegalArgumentException("At least one condition must be provided");
+ }
+
+ List<Condition> subConditions = new ArrayList<>(conditions.length);
+ for (ConditionItem condition : conditions) {
+ subConditions.add(condition.build());
+ }
+
+ ConditionItem conditionItem = new ConditionItem("booleanCondition", definitionsService);
+ conditionItem.parameter("operator", "or");
+ conditionItem.parameter("subConditions", subConditions);
+
+ return (CompoundCondition) conditionItem;
+ }
+
+ /**
+ * Creates a matchAll condition that will match all items regardless of other criteria.
+ * This is useful for creating queries that need to return all records.
+ *
+ * @return a condition that matches all items
+ */
+ public ConditionItem matchAll() {
+ ConditionItem conditionItem = new ConditionItem("matchAllCondition", definitionsService);
+ return conditionItem;
+ }
+
+ /**
+ * Creates a nested condition for querying nested objects or nested fields.
+ *
+ * @param subCondition the condition to apply on the nested object or field
+ * @param path the path to the nested object or field
+ * @return a nested condition for the specified path and sub-condition
+ */
public NestedCondition nested(ConditionItem subCondition, String path) {
return new NestedCondition(subCondition, path);
}
+ /**
+ * Creates a condition for comparing a profile property value.
+ * This is a convenience method for creating conditions on profile properties.
+ *
+ * @param propertyName the name of the profile property to use in the condition
+ * @return a property condition configured for the specified profile property
+ */
public PropertyCondition profileProperty(String propertyName) {
return new PropertyCondition("profilePropertyCondition", propertyName, definitionsService);
}
+ /**
+ * Creates a condition for comparing a session property value.
+ *
+ * @param propertyName the name of the session property to use in the condition
+ * @return a property condition configured for the specified session property
+ */
+ public PropertyCondition sessionProperty(String propertyName) {
+ return new PropertyCondition("sessionPropertyCondition", propertyName, definitionsService);
+ }
+
+ /**
+ * Creates a condition for comparing an event property value.
+ *
+ * @param propertyName the name of the event property to use in the condition
+ * @return a property condition configured for the specified event property
+ */
+ public PropertyCondition eventProperty(String propertyName) {
+ return new PropertyCondition("eventPropertyCondition", propertyName, definitionsService);
+ }
+
+ /**
+ * Creates a condition for comparing any property value based on the specified condition type.
+ *
+ * @param conditionTypeId the ID of the condition type to use
+ * @param propertyName the name of the property to use in the condition
+ * @return a property condition for the specified property and condition type
+ */
public PropertyCondition property(String conditionTypeId, String propertyName) {
return new PropertyCondition(conditionTypeId, propertyName, definitionsService);
}
+ /**
+ * Creates a custom condition of the specified type.
+ *
+ * @param conditionTypeId the ID of the condition type to create
+ * @return a new condition item of the specified type
+ */
public ConditionItem condition(String conditionTypeId) {
return new ConditionItem(conditionTypeId, definitionsService);
}
public abstract class ComparisonCondition extends ConditionItem {
+ /**
+ * Constructs a new comparison condition of the specified type.
+ *
+ * @param conditionTypeId the ID of the condition type
+ * @param definitionsService the definitions service to resolve condition types
+ */
ComparisonCondition(String conditionTypeId, DefinitionsService definitionsService) {
super(conditionTypeId, definitionsService);
}
+ /**
+ * Checks if all values match the compared property.
+ *
+ * @param values the string values to check
+ * @return the condition with the all comparison operator and string values
+ */
public ComparisonCondition all(String... values) {
return op("all").stringValues(values);
}
+ /**
+ * Checks if all date values match the compared property.
+ *
+ * @param values the date values to check
+ * @return the condition with the all comparison operator and date values
+ */
public ComparisonCondition all(Date... values) {
return op("all").dateValues(values);
}
+ /**
+ * Checks if all integer values match the compared property.
+ *
+ * @param values the integer values to check
+ * @return the condition with the all comparison operator and integer values
+ */
public ComparisonCondition all(Integer... values) {
return op("all").integerValues(values);
}
+ /**
+ * Checks if the property contains the specified string value.
+ *
+ * @param value the string value to check for
+ * @return the condition with the contains comparison operator
+ */
public ComparisonCondition contains(String value) {
return op("contains").stringValue(value);
}
+ /**
+ * Checks if the property ends with the specified string value.
+ *
+ * @param value the string value to check against
+ * @return the condition with the endsWith comparison operator
+ */
public ComparisonCondition endsWith(String value) {
return op("endsWith").stringValue(value);
}
+ /**
+ * Checks if the property equals the specified string value.
+ *
+ * @param value the string value to compare with
+ * @return the condition with the equals comparison operator
+ */
public ComparisonCondition equalTo(String value) {
return op("equals").stringValue(value);
}
+ /**
+ * Checks if the property equals the specified date value.
+ *
+ * @param value the date value to compare with
+ * @return the condition with the equals comparison operator
+ */
public ComparisonCondition equalTo(Date value) {
return op("equals").dateValue(value);
}
+ /**
+ * Checks if the property equals the specified integer value.
+ *
+ * @param value the integer value to compare with
+ * @return the condition with the equals comparison operator
+ */
public ComparisonCondition equalTo(Integer value) {
return op("equals").integerValue(value);
}
+ /**
+ * Checks if the property equals the specified double value.
+ *
+ * @param value the double value to compare with
+ * @return the condition with the equals comparison operator
+ */
public ComparisonCondition equalTo(Double value) {
return op("equals").doubleValue(value);
}
+ /**
+ * Checks if the property exists (is not null).
+ *
+ * @return the condition with the exists comparison operator
+ */
public ComparisonCondition exists() {
return op("exists");
}
+ /**
+ * Checks if the property is greater than the specified date value.
+ *
+ * @param value the date value to compare with
+ * @return the condition with the greaterThan comparison operator
+ */
public ComparisonCondition greaterThan(Date value) {
return op("greaterThan").dateValue(value);
}
+ /**
+ * Checks if the property is greater than the specified integer value.
+ *
+ * @param value the integer value to compare with
+ * @return the condition with the greaterThan comparison operator
+ */
public ComparisonCondition greaterThan(Integer value) {
return op("greaterThan").integerValue(value);
}
+ /**
+ * Checks if the property is greater than the specified double value.
+ *
+ * @param value the double value to compare with
+ * @return the condition with the greaterThan comparison operator
+ */
public ComparisonCondition greaterThan(Double value) {
return op("greaterThan").doubleValue(value);
}
+ /**
+ * Checks if the property is greater than or equal to the specified date value.
+ *
+ * @param value the date value to compare with
+ * @return the condition with the greaterThanOrEqualTo comparison operator
+ */
public ComparisonCondition greaterThanOrEqualTo(Date value) {
return op("greaterThanOrEqualTo").dateValue(value);
}
+ /**
+ * Checks if the property is greater than or equal to the specified integer value.
+ *
+ * @param value the integer value to compare with
+ * @return the condition with the greaterThanOrEqualTo comparison operator
+ */
public ComparisonCondition greaterThanOrEqualTo(Integer value) {
return op("greaterThanOrEqualTo").integerValue(value);
}
+ /**
+ * Checks if the property is greater than or equal to the specified double value.
+ *
+ * @param value the double value to compare with
+ * @return the condition with the greaterThanOrEqualTo comparison operator
+ */
public ComparisonCondition greaterThanOrEqualTo(Double value) {
return op("greaterThanOrEqualTo").doubleValue(value);
}
+ /**
+ * Checks if the property is in the set of specified string values.
+ *
+ * @param values the string values to check against
+ * @return the condition with the in comparison operator
+ */
public ComparisonCondition in(String... values) {
return op("in").stringValues(values);
}
+ /**
+ * Checks if the property is in the date range specified by expressions.
+ *
+ * @param values the date expression values to check against
+ * @return the condition with the in comparison operator
+ */
public ComparisonCondition inDateExpr(String... values) {
return op("in").dateExprValues(values);
}
+ /**
+ * Checks if the property is in the set of specified date values.
+ *
+ * @param values the date values to check against
+ * @return the condition with the in comparison operator
+ */
public ComparisonCondition in(Date... values) {
return op("in").dateValues(values);
}
+ /**
+ * Checks if the property is the same day as the specified date.
+ *
+ * @param value the date value to compare with
+ * @return the condition with the isDay comparison operator
+ */
public ComparisonCondition isDay(Date value) {
return op("isDay").dateValue(value);
}
+ /**
+ * Checks if the property is the same day as the date specified by the expression.
+ *
+ * @param expression the date expression to compare with
+ * @return the condition with the isDay comparison operator
+ */
public ComparisonCondition isDay(String expression) {
return op("isDay").dateValueExpr(expression);
}
+ /**
+ * Checks if the property is not the same day as the specified date.
+ *
+ * @param value the date value to compare with
+ * @return the condition with the isNotDay comparison operator
+ */
public ComparisonCondition isNotDay(Date value) {
return op("isNotDay").dateValue(value);
}
+ /**
+ * Checks if the property is not the same day as the date specified by the expression.
+ *
+ * @param expression the date expression to compare with
+ * @return the condition with the isNotDay comparison operator
+ */
public ComparisonCondition isNotDay(String expression) {
return op("isNotDay").dateValueExpr(expression);
}
+ /**
+ * Checks if the property is in the set of specified integer values.
+ *
+ * @param values the integer values to check against
+ * @return the condition with the in comparison operator
+ */
public ComparisonCondition in(Integer... values) {
return op("in").integerValues(values);
}
+ /**
+ * Checks if the property is in the set of specified double values.
+ *
+ * @param values the double values to check against
+ * @return the condition with the in comparison operator
+ */
public ComparisonCondition in(Double... values) {
return op("in").doubleValues(values);
}
+ /**
+ * Checks if the property is less than the specified date value.
+ *
+ * @param value the date value to compare with
+ * @return the condition with the lessThan comparison operator
+ */
public ComparisonCondition lessThan(Date value) {
return op("lessThan").dateValue(value);
}
+ /**
+ * Checks if the property is less than the specified integer value.
+ *
+ * @param value the integer value to compare with
+ * @return the condition with the lessThan comparison operator
+ */
public ComparisonCondition lessThan(Integer value) {
return op("lessThan").integerValue(value);
}
+ /**
+ * Checks if the property is less than the specified double value.
+ *
+ * @param value the double value to compare with
+ * @return the condition with the lessThan comparison operator
+ */
public ComparisonCondition lessThan(Double value) {
return op("lessThan").doubleValue(value);
}
+ /**
+ * Checks if the property is less than or equal to the specified date value.
+ *
+ * @param value the date value to compare with
+ * @return the condition with the lessThanOrEqualTo comparison operator
+ */
public ComparisonCondition lessThanOrEqualTo(Date value) {
return op("lessThanOrEqualTo").dateValue(value);
}
+ /**
+ * Checks if the property is less than or equal to the specified integer value.
+ *
+ * @param value the integer value to compare with
+ * @return the condition with the lessThanOrEqualTo comparison operator
+ */
public ComparisonCondition lessThanOrEqualTo(Integer value) {
return op("lessThanOrEqualTo").integerValue(value);
}
+ /**
+ * Checks if the property is between the specified date bounds (inclusive).
+ *
+ * @param lowerBound the lower bound date (inclusive)
+ * @param upperBound the upper bound date (inclusive)
+ * @return the condition with the between comparison operator
+ */
public ComparisonCondition between(Date lowerBound, Date upperBound) {
return op("between").dateValues(lowerBound, upperBound);
}
+ /**
+ * Checks if the property is between the specified integer bounds (inclusive).
+ *
+ * @param lowerBound the lower bound integer (inclusive)
+ * @param upperBound the upper bound integer (inclusive)
+ * @return the condition with the between comparison operator
+ */
public ComparisonCondition between(Integer lowerBound, Integer upperBound) {
return op("between").integerValues(lowerBound, upperBound);
}
+ /**
+ * Checks if the property is between the specified double bounds (inclusive).
+ *
+ * @param lowerBound the lower bound double (inclusive)
+ * @param upperBound the upper bound double (inclusive)
+ * @return the condition with the between comparison operator
+ */
+ public ComparisonCondition between(Double lowerBound, Double upperBound) {
+ return op("between").doubleValues(lowerBound, upperBound);
+ }
+
+ /**
+ * Checks if the property matches the specified regular expression.
+ *
+ * @param value the regular expression to match against
+ * @return the condition with the matchesRegex comparison operator
+ */
public ComparisonCondition matchesRegex(String value) {
return op("matchesRegex").stringValue(value);
}
+ /**
+ * Checks if the property is missing (null).
+ *
+ * @return the condition with the missing comparison operator
+ */
public ComparisonCondition missing() {
return op("missing");
}
+ /**
+ * Checks if the property is not equal to the specified string value.
+ *
+ * @param value the string value to compare with
+ * @return the condition with the notEquals comparison operator
+ */
public ComparisonCondition notEqualTo(String value) {
return op("notEquals").stringValue(value);
}
+ /**
+ * Checks if the property is not equal to the specified date value.
+ *
+ * @param value the date value to compare with
+ * @return the condition with the notEquals comparison operator
+ */
public ComparisonCondition notEqualTo(Date value) {
return op("notEquals").dateValue(value);
}
+ /**
+ * Checks if the property is not equal to the specified integer value.
+ *
+ * @param value the integer value to compare with
+ * @return the condition with the notEquals comparison operator
+ */
public ComparisonCondition notEqualTo(Integer value) {
return op("notEquals").integerValue(value);
}
+ /**
+ * Checks if the property is not equal to the specified double value.
+ *
+ * @param value the double value to compare with
+ * @return the condition with the notEquals comparison operator
+ */
public ComparisonCondition notEqualTo(Double value) {
return op("notEquals").doubleValue(value);
}
+ /**
+ * Checks if the property is not in the set of specified string values.
+ *
+ * @param values the string values to check against
+ * @return the condition with the notIn comparison operator
+ */
public ComparisonCondition notIn(String... values) {
return op("notIn").stringValues(values);
}
+ /**
+ * Checks if the property is not in the set of specified date values.
+ *
+ * @param values the date values to check against
+ * @return the condition with the notIn comparison operator
+ */
public ComparisonCondition notIn(Date... values) {
return op("notIn").dateValues(values);
}
+ /**
+ * Checks if the property is not in the date range specified by expressions.
+ *
+ * @param values the date expression values to check against
+ * @return the condition with the notIn comparison operator
+ */
public ComparisonCondition notInDateExpr(String... values) {
return op("notIn").dateExprValues(values);
}
+ /**
+ * Checks if the property is not in the set of specified integer values.
+ *
+ * @param values the integer values to check against
+ * @return the condition with the notIn comparison operator
+ */
public ComparisonCondition notIn(Integer... values) {
return op("notIn").integerValues(values);
}
+ /**
+ * Checks if the property is not in the set of specified double values.
+ *
+ * @param values the double values to check against
+ * @return the condition with the notIn comparison operator
+ */
public ComparisonCondition notIn(Double... values) {
return op("notIn").doubleValues(values);
}
+ /**
+ * Sets the comparison operator for this condition.
+ *
+ * @param op the comparison operator to set
+ * @return the condition with the specified operator
+ */
private ComparisonCondition op(String op) {
return parameter("comparisonOperator", op);
}
+ /**
+ * Sets a parameter value for this condition.
+ *
+ * @param name the parameter name
+ * @param value the parameter value
+ * @return the condition with the parameter set
+ */
@Override
public ComparisonCondition parameter(String name, Object value) {
return (ComparisonCondition) super.parameter(name, value);
}
+ /**
+ * Sets a parameter with multiple values for this condition.
+ *
+ * @param name the parameter name
+ * @param values the parameter values
+ * @return the condition with the parameter set
+ */
public ComparisonCondition parameter(String name, Object... values) {
return (ComparisonCondition) super.parameter(name, values);
}
+ /**
+ * Checks if the property starts with the specified string value.
+ *
+ * @param value the string value to check against
+ * @return the condition with the startsWith comparison operator
+ */
public ComparisonCondition startsWith(String value) {
return op("startsWith").stringValue(value);
}
+ /**
+ * Sets a string value for the property comparison.
+ *
+ * @param value the string value to set
+ * @return the condition with the string value set
+ */
private ComparisonCondition stringValue(String value) {
return parameter("propertyValue", value);
}
+ /**
+ * Sets an integer value for the property comparison.
+ *
+ * @param value the integer value to set
+ * @return the condition with the integer value set
+ */
private ComparisonCondition integerValue(Integer value) {
return parameter("propertyValueInteger", value);
}
+ /**
+ * Sets a double value for the property comparison.
+ *
+ * @param value the double value to set
+ * @return the condition with the double value set
+ */
private ComparisonCondition doubleValue(Double value) {
return parameter("propertyValueDouble", value);
}
+ /**
+ * Sets a date value for the property comparison.
+ *
+ * @param value the date value to set
+ * @return the condition with the date value set
+ */
private ComparisonCondition dateValue(Date value) {
return parameter("propertyValueDate", value);
}
+ /**
+ * Sets a date expression value for the property comparison.
+ *
+ * @param value the date expression value to set
+ * @return the condition with the date expression value set
+ */
private ComparisonCondition dateValueExpr(String value) {
return parameter("propertyValueDateExpr", value);
}
+ /**
+ * Sets multiple string values for the property comparison.
+ *
+ * @param values the string values to set
+ * @return the condition with the string values set
+ */
private ComparisonCondition stringValues(String... values) {
return parameter("propertyValues", values != null ? Arrays.asList(values) : null);
}
+ /**
+ * Sets multiple integer values for the property comparison.
+ *
+ * @param values the integer values to set
+ * @return the condition with the integer values set
+ */
private ComparisonCondition integerValues(Integer... values) {
return parameter("propertyValuesInteger", values != null ? Arrays.asList(values) : null);
}
+ /**
+ * Sets multiple double values for the property comparison.
+ *
+ * @param values the double values to set
+ * @return the condition with the double values set
+ */
private ComparisonCondition doubleValues(Double... values) {
return parameter("propertyValuesDouble", values != null ? Arrays.asList(values) : null);
}
+ /**
+ * Sets multiple date values for the property comparison.
+ *
+ * @param values the date values to set
+ * @return the condition with the date values set
+ */
private ComparisonCondition dateValues(Date... values) {
return parameter("propertyValuesDate", values != null ? Arrays.asList(values) : null);
}
+ /**
+ * Sets multiple date expression values for the property comparison.
+ *
+ * @param values the date expression values to set
+ * @return the condition with the date expression values set
+ */
private ComparisonCondition dateExprValues(String... values) {
return parameter("propertyValuesDateExpr", values != null ? Arrays.asList(values) : null);
}
}
+ /**
+ * Represents a compound condition combining multiple sub-conditions with a logical operator.
+ */
public class CompoundCondition extends ConditionItem {
+ /**
+ * Creates a compound condition with two sub-conditions and the specified logical operator.
+ *
+ * @param condition1 the first condition
+ * @param condition2 the second condition
+ * @param operator the logical operator to combine the conditions ("and", "or")
+ */
CompoundCondition(ConditionItem condition1, ConditionItem condition2, String operator) {
super("booleanCondition", condition1.definitionsService);
parameter("operator", operator);
@@ -318,7 +835,16 @@ public class ConditionBuilder {
}
}
+ /**
+ * Represents a nested condition for querying nested objects or fields.
+ */
public class NestedCondition extends ConditionItem {
+ /**
+ * Creates a nested condition for the specified path with the given sub-condition.
+ *
+ * @param subCondition the condition to apply on the nested path
+ * @param path the path to the nested field
+ */
NestedCondition(ConditionItem subCondition, String path) {
super("nestedCondition", subCondition.definitionsService);
parameter("path", path);
@@ -326,27 +852,59 @@ public class ConditionBuilder {
}
}
+ /**
+ * Base class for all condition items. Provides methods to build conditions and set parameters.
+ */
public class ConditionItem {
protected Condition condition;
-
- private DefinitionsService definitionsService;
-
+ protected DefinitionsService definitionsService;
+
+ /**
+ * Creates a new condition item of the specified type.
+ *
+ * @param conditionTypeId the ID of the condition type to create
+ * @param definitionsService the definitions service to resolve condition types
+ * @throws IllegalArgumentException if the condition type is not found
+ */
ConditionItem(String conditionTypeId, DefinitionsService definitionsService) {
this.definitionsService = definitionsService;
+ ConditionType conditionType = definitionsService.getConditionType(conditionTypeId);
+ if (conditionType == null) {
+ throw new IllegalArgumentException("ConditionType not found: " + conditionTypeId);
+ }
condition = new Condition(
this.definitionsService.getConditionType(conditionTypeId));
}
+ /**
+ * Builds and returns the final condition object.
+ *
+ * @return the built condition
+ */
public Condition build() {
return condition;
}
+ /**
+ * Sets a parameter value for this condition.
+ *
+ * @param name the parameter name
+ * @param value the parameter value
+ * @return this condition item for method chaining
+ */
public ConditionItem parameter(String name, Object value) {
condition.setParameter(name, value);
return this;
}
+ /**
+ * Sets a parameter with multiple values for this condition.
+ *
+ * @param name the parameter name
+ * @param values the parameter values
+ * @return this condition item for method chaining
+ */
public ConditionItem parameter(String name, Object... values) {
condition.setParameter(name, values != null ? Arrays.asList(values) : null);
return this;
@@ -354,16 +912,34 @@ public class ConditionBuilder {
}
+ /**
+ * Represents a NOT condition that negates the result of a sub-condition.
+ */
public class NotCondition extends ConditionItem {
+ /**
+ * Creates a NOT condition with the specified sub-condition.
+ *
+ * @param subCondition the condition to negate
+ */
NotCondition(ConditionItem subCondition) {
super("notCondition", subCondition.definitionsService);
parameter("subCondition", subCondition.build());
}
}
+ /**
+ * Represents a condition that compares a property value.
+ */
public class PropertyCondition extends ComparisonCondition {
+ /**
+ * Creates a property condition of the specified type for the given property name.
+ *
+ * @param conditionTypeId the ID of the condition type
+ * @param propertyName the name of the property to compare
+ * @param definitionsService the definitions service to resolve condition types
+ */
PropertyCondition(String conditionTypeId, String propertyName, DefinitionsService definitionsService) {
super(conditionTypeId, definitionsService);
condition.setParameter("propertyName", propertyName);
diff --git a/api/src/test/java/org/apache/unomi/api/tenants/TenantTest.java b/api/src/test/java/org/apache/unomi/api/tenants/TenantTest.java
new file mode 100644
index 000000000..d5cd9cc23
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/tenants/TenantTest.java
@@ -0,0 +1,526 @@
+/*
+ * 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.api.tenants;
+
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+import java.util.*;
+
+/**
+ * Unit tests for the Tenant class, specifically testing the API key resolution functionality.
+ */
+public class TenantTest {
+
+ @Test
+ public void testGetPrivateApiKeyWithNoApiKeys() {
+ Tenant tenant = new Tenant();
+ tenant.setApiKeys(null);
+
+ assertNull("Private API key should be null when no API keys exist", tenant.getPrivateApiKey());
+ }
+
+ @Test
+ public void testGetPrivateApiKeyWithEmptyApiKeys() {
+ Tenant tenant = new Tenant();
+ tenant.setApiKeys(new ArrayList<>());
+
+ assertNull("Private API key should be null when API keys list is empty", tenant.getPrivateApiKey());
+ }
+
+ @Test
+ public void testGetPrivateApiKeyWithOnlyPublicKeys() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ ApiKey publicKey = new ApiKey();
+ publicKey.setKey("public-key-1");
+ publicKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ publicKey.setRevoked(false);
+ publicKey.setCreationDate(new Date());
+ apiKeys.add(publicKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertNull("Private API key should be null when only public keys exist", tenant.getPrivateApiKey());
+ }
+
+ @Test
+ public void testGetPrivateApiKeyWithRevokedKeys() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ ApiKey revokedKey = new ApiKey();
+ revokedKey.setKey("private-key-1");
+ revokedKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ revokedKey.setRevoked(true);
+ revokedKey.setCreationDate(new Date());
+ apiKeys.add(revokedKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertNull("Private API key should be null when all private keys are revoked", tenant.getPrivateApiKey());
+ }
+
+ @Test
+ public void testGetPrivateApiKeyWithExpiredKeys() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ ApiKey expiredKey = new ApiKey();
+ expiredKey.setKey("private-key-1");
+ expiredKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ expiredKey.setRevoked(false);
+ expiredKey.setExpirationDate(new Date(System.currentTimeMillis() - 1000)); // Expired
+ expiredKey.setCreationDate(new Date());
+ apiKeys.add(expiredKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertNull("Private API key should be null when all private keys are expired", tenant.getPrivateApiKey());
+ }
+
+ @Test
+ public void testGetPrivateApiKeyWithValidKey() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ ApiKey validKey = new ApiKey();
+ validKey.setKey("private-key-1");
+ validKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ validKey.setRevoked(false);
+ validKey.setCreationDate(new Date());
+ apiKeys.add(validKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertEquals("Private API key should be resolved from API keys", "private-key-1", tenant.getPrivateApiKey());
+ }
+
+ @Test
+ public void testGetPrivateApiKeyWithMultipleKeysReturnsLatest() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ Date oldDate = new Date(System.currentTimeMillis() - 10000);
+ Date newDate = new Date();
+
+ ApiKey oldKey = new ApiKey();
+ oldKey.setKey("private-key-old");
+ oldKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ oldKey.setRevoked(false);
+ oldKey.setCreationDate(oldDate);
+ apiKeys.add(oldKey);
+
+ ApiKey newKey = new ApiKey();
+ newKey.setKey("private-key-new");
+ newKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ newKey.setRevoked(false);
+ newKey.setCreationDate(newDate);
+ apiKeys.add(newKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertEquals("Private API key should return the most recently created key", "private-key-new", tenant.getPrivateApiKey());
+ }
+
+ @Test
+ public void testGetPrivateApiKeyAlwaysResolvesFromApiKeys() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ ApiKey validKey = new ApiKey();
+ validKey.setKey("private-key-1");
+ validKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ validKey.setRevoked(false);
+ validKey.setCreationDate(new Date());
+ apiKeys.add(validKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ // First call should resolve
+ String firstCall = tenant.getPrivateApiKey();
+ assertEquals("First call should return correct key", "private-key-1", firstCall);
+
+ // Modify the API key to be revoked
+ validKey.setRevoked(true);
+
+ // Second call should return null since key is now revoked
+ String secondCall = tenant.getPrivateApiKey();
+ assertNull("Second call should return null after key is revoked", secondCall);
+
+ // Reactivate the key
+ validKey.setRevoked(false);
+
+ // Third call should return the key again
+ String thirdCall = tenant.getPrivateApiKey();
+ assertEquals("Third call should return key again after reactivation", "private-key-1", thirdCall);
+ }
+
+ @Test
+ public void testGetPublicApiKeyWithNoApiKeys() {
+ Tenant tenant = new Tenant();
+ tenant.setApiKeys(null);
+
+ assertNull("Public API key should be null when no API keys exist", tenant.getPublicApiKey());
+ }
+
+ @Test
+ public void testGetPublicApiKeyWithEmptyApiKeys() {
+ Tenant tenant = new Tenant();
+ tenant.setApiKeys(new ArrayList<>());
+
+ assertNull("Public API key should be null when API keys list is empty", tenant.getPublicApiKey());
+ }
+
+ @Test
+ public void testGetPublicApiKeyWithOnlyPrivateKeys() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ ApiKey privateKey = new ApiKey();
+ privateKey.setKey("private-key-1");
+ privateKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ privateKey.setRevoked(false);
+ privateKey.setCreationDate(new Date());
+ apiKeys.add(privateKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertNull("Public API key should be null when only private keys exist", tenant.getPublicApiKey());
+ }
+
+ @Test
+ public void testGetPublicApiKeyWithValidKey() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ ApiKey validKey = new ApiKey();
+ validKey.setKey("public-key-1");
+ validKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ validKey.setRevoked(false);
+ validKey.setCreationDate(new Date());
+ apiKeys.add(validKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertEquals("Public API key should be resolved from API keys", "public-key-1", tenant.getPublicApiKey());
+ }
+
+ @Test
+ public void testGetPublicApiKeyAlwaysResolvesFromApiKeys() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ ApiKey validKey = new ApiKey();
+ validKey.setKey("public-key-1");
+ validKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ validKey.setRevoked(false);
+ validKey.setCreationDate(new Date());
+ apiKeys.add(validKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ // First call should resolve
+ String firstCall = tenant.getPublicApiKey();
+ assertEquals("First call should return correct key", "public-key-1", firstCall);
+
+ // Modify the API key to be revoked
+ validKey.setRevoked(true);
+
+ // Second call should return null since key is now revoked
+ String secondCall = tenant.getPublicApiKey();
+ assertNull("Second call should return null after key is revoked", secondCall);
+
+ // Reactivate the key
+ validKey.setRevoked(false);
+
+ // Third call should return the key again
+ String thirdCall = tenant.getPublicApiKey();
+ assertEquals("Third call should return key again after reactivation", "public-key-1", thirdCall);
+ }
+
+ @Test
+ public void testGetActivePrivateApiKeys() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ // Add various private keys
+ ApiKey revokedKey = new ApiKey();
+ revokedKey.setKey("revoked-private");
+ revokedKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ revokedKey.setRevoked(true);
+ apiKeys.add(revokedKey);
+
+ ApiKey expiredKey = new ApiKey();
+ expiredKey.setKey("expired-private");
+ expiredKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ expiredKey.setRevoked(false);
+ expiredKey.setExpirationDate(new Date(System.currentTimeMillis() - 1000));
+ apiKeys.add(expiredKey);
+
+ ApiKey validKey1 = new ApiKey();
+ validKey1.setKey("valid-private-1");
+ validKey1.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ validKey1.setRevoked(false);
+ apiKeys.add(validKey1);
+
+ ApiKey validKey2 = new ApiKey();
+ validKey2.setKey("valid-private-2");
+ validKey2.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ validKey2.setRevoked(false);
+ apiKeys.add(validKey2);
+
+ tenant.setApiKeys(apiKeys);
+
+ List<ApiKey> activeKeys = tenant.getActivePrivateApiKeys();
+ assertEquals("Should return 2 active private keys", 2, activeKeys.size());
+ assertTrue("Should contain valid-private-1", activeKeys.stream().anyMatch(key -> "valid-private-1".equals(key.getKey())));
+ assertTrue("Should contain valid-private-2", activeKeys.stream().anyMatch(key -> "valid-private-2".equals(key.getKey())));
+ }
+
+ @Test
+ public void testGetActivePublicApiKeys() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ // Add various public keys
+ ApiKey revokedKey = new ApiKey();
+ revokedKey.setKey("revoked-public");
+ revokedKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ revokedKey.setRevoked(true);
+ apiKeys.add(revokedKey);
+
+ ApiKey expiredKey = new ApiKey();
+ expiredKey.setKey("expired-public");
+ expiredKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ expiredKey.setRevoked(false);
+ expiredKey.setExpirationDate(new Date(System.currentTimeMillis() - 1000));
+ apiKeys.add(expiredKey);
+
+ ApiKey validKey1 = new ApiKey();
+ validKey1.setKey("valid-public-1");
+ validKey1.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ validKey1.setRevoked(false);
+ apiKeys.add(validKey1);
+
+ ApiKey validKey2 = new ApiKey();
+ validKey2.setKey("valid-public-2");
+ validKey2.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ validKey2.setRevoked(false);
+ apiKeys.add(validKey2);
+
+ tenant.setApiKeys(apiKeys);
+
+ List<ApiKey> activeKeys = tenant.getActivePublicApiKeys();
+ assertEquals("Should return 2 active public keys", 2, activeKeys.size());
+ assertTrue("Should contain valid-public-1", activeKeys.stream().anyMatch(key -> "valid-public-1".equals(key.getKey())));
+ assertTrue("Should contain valid-public-2", activeKeys.stream().anyMatch(key -> "valid-public-2".equals(key.getKey())));
+ }
+
+ @Test
+ public void testGetActiveApiKeys() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ // Add various keys
+ ApiKey revokedPrivateKey = new ApiKey();
+ revokedPrivateKey.setKey("revoked-private");
+ revokedPrivateKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ revokedPrivateKey.setRevoked(true);
+ apiKeys.add(revokedPrivateKey);
+
+ ApiKey expiredPublicKey = new ApiKey();
+ expiredPublicKey.setKey("expired-public");
+ expiredPublicKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ expiredPublicKey.setRevoked(false);
+ expiredPublicKey.setExpirationDate(new Date(System.currentTimeMillis() - 1000));
+ apiKeys.add(expiredPublicKey);
+
+ ApiKey validPrivateKey = new ApiKey();
+ validPrivateKey.setKey("valid-private");
+ validPrivateKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ validPrivateKey.setRevoked(false);
+ apiKeys.add(validPrivateKey);
+
+ ApiKey validPublicKey = new ApiKey();
+ validPublicKey.setKey("valid-public");
+ validPublicKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ validPublicKey.setRevoked(false);
+ apiKeys.add(validPublicKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ List<ApiKey> activeKeys = tenant.getActiveApiKeys();
+ assertEquals("Should return 2 active keys", 2, activeKeys.size());
+ assertTrue("Should contain valid-private", activeKeys.stream().anyMatch(key -> "valid-private".equals(key.getKey())));
+ assertTrue("Should contain valid-public", activeKeys.stream().anyMatch(key -> "valid-public".equals(key.getKey())));
+ }
+
+ @Test
+ public void testGetActiveApiKeysWithNullApiKeys() {
+ Tenant tenant = new Tenant();
+ tenant.setApiKeys(null);
+
+ List<ApiKey> activeKeys = tenant.getActiveApiKeys();
+ assertNotNull("Should return empty list, not null", activeKeys);
+ assertTrue("Should return empty list", activeKeys.isEmpty());
+ }
+
+ @Test
+ public void testGetActivePrivateApiKeysWithNullApiKeys() {
+ Tenant tenant = new Tenant();
+ tenant.setApiKeys(null);
+
+ List<ApiKey> activeKeys = tenant.getActivePrivateApiKeys();
+ assertNotNull("Should return empty list, not null", activeKeys);
+ assertTrue("Should return empty list", activeKeys.isEmpty());
+ }
+
+ @Test
+ public void testGetActivePublicApiKeysWithNullApiKeys() {
+ Tenant tenant = new Tenant();
+ tenant.setApiKeys(null);
+
+ List<ApiKey> activeKeys = tenant.getActivePublicApiKeys();
+ assertNotNull("Should return empty list, not null", activeKeys);
+ assertTrue("Should return empty list", activeKeys.isEmpty());
+ }
+
+ @Test
+ public void testMixedApiKeyTypes() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ ApiKey privateKey = new ApiKey();
+ privateKey.setKey("private-key");
+ privateKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ privateKey.setRevoked(false);
+ privateKey.setCreationDate(new Date());
+ apiKeys.add(privateKey);
+
+ ApiKey publicKey = new ApiKey();
+ publicKey.setKey("public-key");
+ publicKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ publicKey.setRevoked(false);
+ publicKey.setCreationDate(new Date());
+ apiKeys.add(publicKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertEquals("Private API key should be resolved correctly", "private-key", tenant.getPrivateApiKey());
+ assertEquals("Public API key should be resolved correctly", "public-key", tenant.getPublicApiKey());
+ }
+
+ @Test
+ public void testApiKeyExpirationLogic() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ // Create a key that expires in the future
+ ApiKey futureExpiringKey = new ApiKey();
+ futureExpiringKey.setKey("future-expiring-key");
+ futureExpiringKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ futureExpiringKey.setRevoked(false);
+ futureExpiringKey.setExpirationDate(new Date(System.currentTimeMillis() + 10000)); // 10 seconds in future
+ futureExpiringKey.setCreationDate(new Date());
+ apiKeys.add(futureExpiringKey);
+
+ // Create a key that has already expired
+ ApiKey expiredKey = new ApiKey();
+ expiredKey.setKey("expired-key");
+ expiredKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ expiredKey.setRevoked(false);
+ expiredKey.setExpirationDate(new Date(System.currentTimeMillis() - 1000)); // 1 second ago
+ expiredKey.setCreationDate(new Date());
+ apiKeys.add(expiredKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertEquals("Should return the valid future-expiring key", "future-expiring-key", tenant.getPrivateApiKey());
+ }
+
+ @Test
+ public void testComplexApiKeyScenarios() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ // Add various types of keys
+ ApiKey revokedPrivateKey = new ApiKey();
+ revokedPrivateKey.setKey("revoked-private");
+ revokedPrivateKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ revokedPrivateKey.setRevoked(true);
+ revokedPrivateKey.setCreationDate(new Date(System.currentTimeMillis() - 5000));
+ apiKeys.add(revokedPrivateKey);
+
+ ApiKey expiredPrivateKey = new ApiKey();
+ expiredPrivateKey.setKey("expired-private");
+ expiredPrivateKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ expiredPrivateKey.setRevoked(false);
+ expiredPrivateKey.setExpirationDate(new Date(System.currentTimeMillis() - 1000));
+ expiredPrivateKey.setCreationDate(new Date(System.currentTimeMillis() - 3000));
+ apiKeys.add(expiredPrivateKey);
+
+ ApiKey validPrivateKey = new ApiKey();
+ validPrivateKey.setKey("valid-private");
+ validPrivateKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ validPrivateKey.setRevoked(false);
+ validPrivateKey.setCreationDate(new Date());
+ apiKeys.add(validPrivateKey);
+
+ ApiKey validPublicKey = new ApiKey();
+ validPublicKey.setKey("valid-public");
+ validPublicKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ validPublicKey.setRevoked(false);
+ validPublicKey.setCreationDate(new Date());
+ apiKeys.add(validPublicKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertEquals("Should return the valid private key", "valid-private", tenant.getPrivateApiKey());
+ assertEquals("Should return the valid public key", "valid-public", tenant.getPublicApiKey());
+ }
+
+ @Test
+ public void testApiKeyCreationDateOrdering() {
+ Tenant tenant = new Tenant();
+ List<ApiKey> apiKeys = new ArrayList<>();
+
+ Date oldDate = new Date(System.currentTimeMillis() - 10000);
+ Date newDate = new Date();
+
+ // Create an older valid key
+ ApiKey oldKey = new ApiKey();
+ oldKey.setKey("old-key");
+ oldKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ oldKey.setRevoked(false);
+ oldKey.setCreationDate(oldDate);
+ apiKeys.add(oldKey);
+
+ // Create a newer valid key
+ ApiKey newKey = new ApiKey();
+ newKey.setKey("new-key");
+ newKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
+ newKey.setRevoked(false);
+ newKey.setCreationDate(newDate);
+ apiKeys.add(newKey);
+
+ tenant.setApiKeys(apiKeys);
+
+ assertEquals("Should return the most recently created key", "new-key", tenant.getPrivateApiKey());
+ }
+}
\ No newline at end of file
diff --git a/bom/artifacts/pom.xml b/bom/artifacts/pom.xml
index ecc78948d..5a739b101 100644
--- a/bom/artifacts/pom.xml
+++ b/bom/artifacts/pom.xml
@@ -85,11 +85,21 @@
<artifactId>unomi-json-schema-rest</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-json-schema-shell-commands</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.unomi</groupId>
<artifactId>unomi-rest</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>log4j-extension</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.unomi</groupId>
<artifactId>cxs-geonames-services</artifactId>
@@ -125,10 +135,21 @@
<artifactId>cxs-lists-extension-rest</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-services-common</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-services</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.unomi</groupId>
<artifactId>unomi-services</artifactId>
<version>${project.version}</version>
+ <type>test-jar</type>
</dependency>
<dependency>
<groupId>org.apache.unomi</groupId>
diff --git a/bom/pom.xml b/bom/pom.xml
index c63868276..42882b9b5 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -158,6 +158,16 @@
<artifactId>elasticsearch-rest-client</artifactId>
<version>${elasticsearch.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.opensearch.client</groupId>
+ <artifactId>opensearch-java</artifactId>
+ <version>${opensearch.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ <version>${guava.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.cxf</groupId>
@@ -260,6 +270,16 @@
<artifactId>log4j-slf4j-impl</artifactId>
<version>${log4j.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ <version>${slf4j.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-simple</artifactId>
+ <version>${slf4j.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore-osgi</artifactId>
diff --git a/build.sh b/build.sh
index 19dd5d0f6..c911b86e4 100755
--- a/build.sh
+++ b/build.sh
@@ -149,7 +149,7 @@ print_section() {
print_status() {
local status=$1
local message=$2
-
+
if [ "$HAS_COLORS" -eq 1 ]; then
case $status in
"success")
@@ -228,7 +228,7 @@ prompt_continue() {
if [ -z "$prompt_text" ]; then
prompt_text="Continue?"
fi
-
+
read -p "$prompt_text (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
@@ -496,7 +496,7 @@ check_requirements() {
print_status "info" "Checking required tools..."
local required_tools=("mvn" "java" "tar" "gzip" "dot")
local missing_tools=()
-
+
echo "Required tools:"
for tool in "${required_tools[@]}"; do
if command_exists "$tool"; then
@@ -600,7 +600,7 @@ check_requirements() {
# 3. System Resources Check
print_status "info" "Checking system resources..."
-
+
# Memory check
if command_exists free; then
available_memory=$(free -m | awk '/^Mem:/{print $2}')
@@ -644,7 +644,7 @@ check_requirements() {
# 4. Configuration Check
print_status "info" "Checking configuration..."
-
+
# Maven settings check
if [ ! -f ~/.m2/settings.xml ]; then
print_status "warning" "✗ Maven settings.xml not found"
@@ -696,7 +696,7 @@ check_requirements() {
# 5. Option Validation
print_status "info" "Validating options..."
-
+
if [ "$SKIP_TESTS" = true ] && [ "$RUN_INTEGRATION_TESTS" = true ]; then
print_status "error" "Cannot use --skip-tests and --integration-tests together"
has_errors=true
@@ -817,13 +817,13 @@ if [ "$RUN_INTEGRATION_TESTS" = true ]; then
echo "Running integration tests with ElasticSearch"
fi
MVN_OPTS="$MVN_OPTS -P integration-tests"
-
+
# Add single test option if specified
if [ ! -z "$SINGLE_TEST" ]; then
MVN_OPTS="$MVN_OPTS -Dit.test=$SINGLE_TEST"
echo "Running single integration test: $SINGLE_TEST"
fi
-
+
# Add integration test debug options if enabled
if [ "$IT_DEBUG" = true ]; then
DEBUG_OPTS="port=$IT_DEBUG_PORT"
@@ -847,7 +847,7 @@ else
PROFILES="$PROFILES,!integration-tests,!run-tests"
MVN_OPTS="$MVN_OPTS -DskipTests"
fi
-
+
# Warn if single test was specified but integration tests are not enabled
if [ ! -z "$SINGLE_TEST" ]; then
print_status "warning" "Single test specified but integration tests are not enabled. Use --integration-tests to run the test."
diff --git a/extensions/geonames/services/pom.xml b/extensions/geonames/services/pom.xml
index f66052dd7..c83b1299a 100644
--- a/extensions/geonames/services/pom.xml
+++ b/extensions/geonames/services/pom.xml
@@ -51,7 +51,11 @@
<artifactId>unomi-persistence-spi</artifactId>
<scope>provided</scope>
</dependency>
-
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-services</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.apache.cxf</groupId>
<artifactId>cxf-rt-rs-security-cors</artifactId>
diff --git a/extensions/geonames/services/src/main/java/org/apache/unomi/geonames/services/GeonamesServiceImpl.java b/extensions/geonames/services/src/main/java/org/apache/unomi/geonames/services/GeonamesServiceImpl.java
index a19725035..1e988f2ed 100644
--- a/extensions/geonames/services/src/main/java/org/apache/unomi/geonames/services/GeonamesServiceImpl.java
+++ b/extensions/geonames/services/src/main/java/org/apache/unomi/geonames/services/GeonamesServiceImpl.java
@@ -17,12 +17,16 @@
package org.apache.unomi.geonames.services;
-
import org.apache.commons.lang3.StringUtils;
import org.apache.unomi.api.PartialList;
import org.apache.unomi.api.conditions.Condition;
import org.apache.unomi.api.services.DefinitionsService;
+import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.api.services.SchedulerService;
+import org.apache.unomi.api.tasks.TaskExecutor;
+import org.apache.unomi.api.tasks.TaskExecutor.TaskStatusCallback;
+import org.apache.unomi.api.tasks.ScheduledTask;
+import org.apache.unomi.api.tenants.TenantService;
import org.apache.unomi.persistence.spi.PersistenceService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -43,6 +47,8 @@ public class GeonamesServiceImpl implements GeonamesService {
private DefinitionsService definitionsService;
private PersistenceService persistenceService;
private SchedulerService schedulerService;
+ private TenantService tenantService;
+ private ExecutionContextManager contextManager;
private String pathToGeonamesDatabase;
private Boolean forceDbImport;
@@ -64,6 +70,14 @@ public class GeonamesServiceImpl implements GeonamesService {
this.schedulerService = schedulerService;
}
+ public void setTenantService(TenantService tenantService) {
+ this.tenantService = tenantService;
+ }
+
+ public void setContextManager(ExecutionContextManager contextManager) {
+ this.contextManager = contextManager;
+ }
+
public void setPathToGeonamesDatabase(String pathToGeonamesDatabase) {
this.pathToGeonamesDatabase = pathToGeonamesDatabase;
}
@@ -79,47 +93,99 @@ public class GeonamesServiceImpl implements GeonamesService {
public void stop() {
}
- public void importDatabase() {
- if (!persistenceService.createIndex(GeonameEntry.ITEM_TYPE)) {
- if (forceDbImport) {
- persistenceService.removeIndex(GeonameEntry.ITEM_TYPE);
- persistenceService.createIndex(GeonameEntry.ITEM_TYPE);
- LOGGER.info("Geonames index removed and recreated");
- } else if (persistenceService.getAllItemsCount(GeonameEntry.ITEM_TYPE) > 0) {
- return;
- }
- } else {
- LOGGER.info("Geonames index created");
+ private static class GeonamesImportTaskExecutor implements TaskExecutor {
+ private final GeonamesServiceImpl service;
+ private final File databaseFile;
+
+ public GeonamesImportTaskExecutor(GeonamesServiceImpl service, File databaseFile) {
+ this.service = service;
+ this.databaseFile = databaseFile;
}
- if (pathToGeonamesDatabase == null) {
- LOGGER.info("No geonames DB provided");
- return;
+ @Override
+ public String getTaskType() {
+ return "geonames-import";
}
- final File f = new File(pathToGeonamesDatabase);
- if (f.exists()) {
- schedulerService.getSharedScheduleExecutorService().schedule(new TimerTask() {
- @Override
- public void run() {
- importGeoNameDatabase(f);
+
+ @Override
+ public void execute(ScheduledTask task, TaskStatusCallback statusCallback) throws Exception {
+ service.contextManager.executeAsSystem(() -> {
+ try {
+ service.importGeoNameDatabase(databaseFile);
+ statusCallback.complete();
+ } catch (Exception e) {
+ LOGGER.error("Error importing geoname database", e);
+ statusCallback.fail(e.getMessage());
}
- }, refreshDbInterval, TimeUnit.MILLISECONDS);
+ return null;
+ });
}
}
+ private static class GeonamesImportRetryTaskExecutor implements TaskExecutor {
+ private final GeonamesServiceImpl service;
+ private final File databaseFile;
+
+ public GeonamesImportRetryTaskExecutor(GeonamesServiceImpl service, File databaseFile) {
+ this.service = service;
+ this.databaseFile = databaseFile;
+ }
+
+ @Override
+ public String getTaskType() {
+ return "geonames-import-retry";
+ }
+
+ @Override
+ public void execute(ScheduledTask task, TaskStatusCallback statusCallback) throws Exception {
+ service.importGeoNameDatabase(databaseFile);
+ statusCallback.complete();
+ }
+ }
+
+ public void importDatabase() {
+ contextManager.executeAsSystem(() -> {
+ if (!persistenceService.createIndex(GeonameEntry.ITEM_TYPE)) {
+ if (forceDbImport) {
+ persistenceService.removeIndex(GeonameEntry.ITEM_TYPE);
+ persistenceService.createIndex(GeonameEntry.ITEM_TYPE);
+ LOGGER.info("Geonames index removed and recreated");
+ } else if (persistenceService.getAllItemsCount(GeonameEntry.ITEM_TYPE) > 0) {
+ return;
+ }
+ } else {
+ LOGGER.info("Geonames index created");
+ }
+
+ if (pathToGeonamesDatabase == null) {
+ LOGGER.info("No geonames DB provided");
+ return;
+ }
+ final File f = new File(pathToGeonamesDatabase);
+ if (f.exists()) {
+ schedulerService.newTask("geonames-import")
+ .withInitialDelay(refreshDbInterval, TimeUnit.MILLISECONDS)
+ .asOneShot()
+ .withExecutor(new GeonamesImportTaskExecutor(this, f))
+ .nonPersistent()
+ .schedule();
+ }
+ });
+ }
+
private void importGeoNameDatabase(final File f) {
Map<String,Map<String,Object>> typeMappings = persistenceService.getPropertiesMapping(GeonameEntry.ITEM_TYPE);
if (typeMappings == null || typeMappings.size() == 0) {
LOGGER.warn("Type mappings for type {} are not yet installed, delaying import until they are ready!", GeonameEntry.ITEM_TYPE);
- schedulerService.getSharedScheduleExecutorService().schedule(new TimerTask() {
- @Override
- public void run() {
- importGeoNameDatabase(f);
- }
- }, refreshDbInterval, TimeUnit.MILLISECONDS);
+ schedulerService.newTask("geonames-import-retry")
+ .withInitialDelay(refreshDbInterval, TimeUnit.MILLISECONDS)
+ .asOneShot()
+ .withExecutor(new GeonamesImportRetryTaskExecutor(this, f))
+ .nonPersistent()
+ .schedule();
return;
} else {
- // let's check that the mappings are correct
+ // @TODO: let's check that the mappings are correct
}
try {
@@ -229,48 +295,50 @@ public class GeonamesServiceImpl implements GeonamesService {
}
public List<GeonameEntry> reverseGeoCode(String lat, String lon) {
- List<Condition> l = new ArrayList<Condition>();
- Condition andCondition = new Condition();
- andCondition.setConditionType(definitionsService.getConditionType("booleanCondition"));
- andCondition.setParameter("operator", "and");
- andCondition.setParameter("subConditions", l);
-
-
- Condition geoLocation = new Condition();
- geoLocation.setConditionType(definitionsService.getConditionType("geoLocationByPointSessionCondition"));
- geoLocation.setParameter("type", "circle");
- geoLocation.setParameter("circleLatitude", Double.parseDouble(lat));
- geoLocation.setParameter("circleLongitude", Double.parseDouble(lon));
- geoLocation.setParameter("distance", GEOCODING_MAX_DISTANCE);
- l.add(geoLocation);
-
- l.add(getPropertyCondition("featureCode", "propertyValues", CITIES_FEATURE_CODES, "in"));
-
- PartialList<GeonameEntry> list = persistenceService.query(andCondition, "geo:location:" + lat + ":" + lon, GeonameEntry.class, 0, 1);
- if (!list.getList().isEmpty()) {
- return getHierarchy(list.getList().get(0));
- }
- return Collections.emptyList();
+ return contextManager.executeAsSystem(() -> {
+ List<Condition> l = new ArrayList<Condition>();
+ Condition andCondition = new Condition();
+ andCondition.setConditionType(definitionsService.getConditionType("booleanCondition"));
+ andCondition.setParameter("operator", "and");
+ andCondition.setParameter("subConditions", l);
+
+ Condition geoLocation = new Condition();
+ geoLocation.setConditionType(definitionsService.getConditionType("geoLocationByPointSessionCondition"));
+ geoLocation.setParameter("type", "circle");
+ geoLocation.setParameter("circleLatitude", Double.parseDouble(lat));
+ geoLocation.setParameter("circleLongitude", Double.parseDouble(lon));
+ geoLocation.setParameter("distance", GEOCODING_MAX_DISTANCE);
+ l.add(geoLocation);
+
+ l.add(getPropertyCondition("featureCode", "propertyValues", CITIES_FEATURE_CODES, "in"));
+
+ PartialList<GeonameEntry> list = persistenceService.query(andCondition, "geo:location:" + lat + ":" + lon, GeonameEntry.class, 0, 1);
+ if (!list.getList().isEmpty()) {
+ return getHierarchy(list.getList().get(0));
+ }
+ return Collections.emptyList();
+ });
}
-
public PartialList<GeonameEntry> getChildrenEntries(List<String> items, int offset, int size) {
- Condition andCondition = getItemsInChildrenQuery(items, CITIES_FEATURE_CODES);
- Condition featureCodeCondition = ((List<Condition>) andCondition.getParameter("subConditions")).get(0);
- int level = items.size();
-
- featureCodeCondition.setParameter("propertyValues", ORDERED_FEATURES.get(level));
- PartialList<GeonameEntry> r = persistenceService.query(andCondition, null, GeonameEntry.class, offset, size);
- while (r.size() == 0 && level < ORDERED_FEATURES.size() - 1) {
- level++;
+ return contextManager.executeAsSystem(() -> {
+ Condition andCondition = getItemsInChildrenQuery(items, CITIES_FEATURE_CODES);
+ Condition featureCodeCondition = ((List<Condition>) andCondition.getParameter("subConditions")).get(0);
+ int level = items.size();
+
featureCodeCondition.setParameter("propertyValues", ORDERED_FEATURES.get(level));
- r = persistenceService.query(andCondition, null, GeonameEntry.class, offset, size);
- }
- return r;
+ PartialList<GeonameEntry> r = persistenceService.query(andCondition, null, GeonameEntry.class, offset, size);
+ while (r.size() == 0 && level < ORDERED_FEATURES.size() - 1) {
+ level++;
+ featureCodeCondition.setParameter("propertyValues", ORDERED_FEATURES.get(level));
+ r = persistenceService.query(andCondition, null, GeonameEntry.class, offset, size);
+ }
+ return r;
+ });
}
public PartialList<GeonameEntry> getChildrenCities(List<String> items, int offset, int size) {
- return persistenceService.query(getItemsInChildrenQuery(items, CITIES_FEATURE_CODES), null, GeonameEntry.class, offset, size);
+ return contextManager.executeAsSystem(() -> persistenceService.query(getItemsInChildrenQuery(items, CITIES_FEATURE_CODES), null, GeonameEntry.class, offset, size));
}
private Condition getItemsInChildrenQuery(List<String> items, List<String> featureCodes) {
@@ -296,45 +364,47 @@ public class GeonamesServiceImpl implements GeonamesService {
}
public List<GeonameEntry> getCapitalEntries(String itemId) {
- GeonameEntry entry = persistenceService.load(itemId, GeonameEntry.class);
- List<String> featureCodes;
-
- List<Condition> l = new ArrayList<Condition>();
- Condition andCondition = new Condition();
- andCondition.setConditionType(definitionsService.getConditionType("booleanCondition"));
- andCondition.setParameter("operator", "and");
- andCondition.setParameter("subConditions", l);
+ return contextManager.executeAsSystem(() -> {
+ GeonameEntry entry = persistenceService.load(itemId, GeonameEntry.class);
+ List<String> featureCodes;
+
+ List<Condition> l = new ArrayList<Condition>();
+ Condition andCondition = new Condition();
+ andCondition.setConditionType(definitionsService.getConditionType("booleanCondition"));
+ andCondition.setParameter("operator", "and");
+ andCondition.setParameter("subConditions", l);
+
+ l.add(getPropertyCondition("countryCode", "propertyValue", entry.getCountryCode(), "equals"));
+
+ if (COUNTRY_FEATURE_CODES.contains(entry.getFeatureCode())) {
+ featureCodes = Arrays.asList("PPLC");
+ } else if (ADM1_FEATURE_CODES.contains(entry.getFeatureCode())) {
+ featureCodes = Arrays.asList("PPLA", "PPLC");
+ l.add(getPropertyCondition("admin1Code", "propertyValue", entry.getAdmin1Code(), "equals"));
+ } else if (ADM2_FEATURE_CODES.contains(entry.getFeatureCode())) {
+ featureCodes = Arrays.asList("PPLA2", "PPLA", "PPLC");
+ l.add(getPropertyCondition("admin1Code", "propertyValue", entry.getAdmin1Code(), "equals"));
+ l.add(getPropertyCondition("admin2Code", "propertyValue", entry.getAdmin2Code(), "equals"));
+ } else {
+ return Collections.emptyList();
+ }
- l.add(getPropertyCondition("countryCode", "propertyValue", entry.getCountryCode(), "equals"));
-
- if (COUNTRY_FEATURE_CODES.contains(entry.getFeatureCode())) {
- featureCodes = Arrays.asList("PPLC");
- } else if (ADM1_FEATURE_CODES.contains(entry.getFeatureCode())) {
- featureCodes = Arrays.asList("PPLA", "PPLC");
- l.add(getPropertyCondition("admin1Code", "propertyValue", entry.getAdmin1Code(), "equals"));
- } else if (ADM2_FEATURE_CODES.contains(entry.getFeatureCode())) {
- featureCodes = Arrays.asList("PPLA2", "PPLA", "PPLC");
- l.add(getPropertyCondition("admin1Code", "propertyValue", entry.getAdmin1Code(), "equals"));
- l.add(getPropertyCondition("admin2Code", "propertyValue", entry.getAdmin2Code(), "equals"));
- } else {
+ Condition featureCodeCondition = new Condition();
+ featureCodeCondition.setConditionType(definitionsService.getConditionType("sessionPropertyCondition"));
+ featureCodeCondition.setParameter("propertyName", "featureCode");
+ featureCodeCondition.setParameter("propertyValues", featureCodes);
+ featureCodeCondition.setParameter("comparisonOperator", "in");
+ l.add(featureCodeCondition);
+ List<GeonameEntry> entries = persistenceService.query(andCondition, null, GeonameEntry.class);
+ if (entries.size() == 0) {
+ featureCodeCondition.setParameter("propertyValues", CITIES_FEATURE_CODES);
+ entries = persistenceService.query(andCondition, "population:desc", GeonameEntry.class, 0, 1).getList();
+ }
+ if (entries.size() > 0) {
+ return getHierarchy(entries.get(0));
+ }
return Collections.emptyList();
- }
-
- Condition featureCodeCondition = new Condition();
- featureCodeCondition.setConditionType(definitionsService.getConditionType("sessionPropertyCondition"));
- featureCodeCondition.setParameter("propertyName", "featureCode");
- featureCodeCondition.setParameter("propertyValues", featureCodes);
- featureCodeCondition.setParameter("comparisonOperator", "in");
- l.add(featureCodeCondition);
- List<GeonameEntry> entries = persistenceService.query(andCondition, null, GeonameEntry.class);
- if (entries.size() == 0) {
- featureCodeCondition.setParameter("propertyValues", CITIES_FEATURE_CODES);
- entries = persistenceService.query(andCondition, "population:desc", GeonameEntry.class, 0, 1).getList();
- }
- if (entries.size() > 0) {
- return getHierarchy(entries.get(0));
- }
- return Collections.emptyList();
+ });
}
private Condition getPropertyCondition(String name, String propertyValueField, Object value, String operator) {
diff --git a/extensions/geonames/services/src/main/resources/META-INF/cxs/mappings/geonameEntry.json b/extensions/geonames/services/src/main/resources/META-INF/cxs/mappings/geonameEntry.json
index 695073740..0fcc8dae6 100644
--- a/extensions/geonames/services/src/main/resources/META-INF/cxs/mappings/geonameEntry.json
+++ b/extensions/geonames/services/src/main/resources/META-INF/cxs/mappings/geonameEntry.json
@@ -18,6 +18,15 @@
}
],
"properties": {
+ "creationDate" : {
+ "type" : "date"
+ },
+ "lastModificationDate" : {
+ "type" : "date"
+ },
+ "lastSyncDate" : {
+ "type" : "date"
+ },
"elevation": {
"type": "long"
},
@@ -32,4 +41,4 @@
"type": "long"
}
}
-}
\ No newline at end of file
+}
diff --git a/extensions/geonames/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml b/extensions/geonames/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index 758cd7090..d003633b6 100644
--- a/extensions/geonames/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ b/extensions/geonames/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -22,6 +22,14 @@
xsi:schemaLocation="http://www.osgi.org/xmlns/blueprint/v1.0.0 https://www.osgi.org/xmlns/blueprint/v1.0.0/blueprint.xsd
http://aries.apache.org/blueprint/xmlns/blueprint-cm/v1.3.0 https://aries.apache.org/schemas/blueprint-cm/blueprint-cm-1.3.0.xsd">
+ <!-- References -->
+ <reference id="persistenceService" interface="org.apache.unomi.persistence.spi.PersistenceService"/>
+ <reference id="definitionsService" interface="org.apache.unomi.api.services.DefinitionsService"/>
+ <reference id="schedulerService" interface="org.apache.unomi.api.services.SchedulerService" timeout="2000"/>
+ <reference id="tenantService" interface="org.apache.unomi.api.tenants.TenantService"/>
+ <reference id="contextManager" interface="org.apache.unomi.api.services.ExecutionContextManager"/>
+
+ <!-- Configuration -->
<cm:property-placeholder persistent-id="org.apache.unomi.geonames" update-strategy="reload">
<cm:default-properties>
<cm:property name="request.geonamesDatabase.location" value="file:${karaf.etc}/allCountries.zip"/>
@@ -30,22 +38,20 @@
</cm:default-properties>
</cm:property-placeholder>
- <reference id="persistenceService" interface="org.apache.unomi.persistence.spi.PersistenceService"/>
- <reference id="definitionsService" interface="org.apache.unomi.api.services.DefinitionsService"/>
- <reference id="schedulerService" interface="org.apache.unomi.api.services.SchedulerService"/>
-
-
- <!-- User List service -->
-
+ <!-- Beans -->
<bean id="geonamesServiceImpl" class="org.apache.unomi.geonames.services.GeonamesServiceImpl" init-method="start"
destroy-method="stop">
<property name="persistenceService" ref="persistenceService"/>
<property name="definitionsService" ref="definitionsService"/>
<property name="schedulerService" ref="schedulerService"/>
+ <property name="tenantService" ref="tenantService"/>
+ <property name="contextManager" ref="contextManager"/>
<property name="pathToGeonamesDatabase" value="${request.geonamesDatabase.location}"/>
<property name="forceDbImport" value="${request.geonamesDatabase.forceImport}"/>
<property name="refreshDbInterval" value="${service.geonames.refresh.interval}"/>
</bean>
+
+ <!-- Services -->
<service id="geonamesService" ref="geonamesServiceImpl" interface="org.apache.unomi.geonames.services.GeonamesService"/>
</blueprint>
diff --git a/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml b/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml
index a4d5a7c17..5d527b1e8 100644
--- a/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml
+++ b/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml
@@ -20,6 +20,7 @@
<details>${project.description}</details>
<feature>wrap</feature>
<feature>unomi-services</feature>
+ <configfile finalname="/etc/org.apache.unomi.groovy.actions.cfg">mvn:org.apache.unomi/unomi-groovy-actions-services/${project.version}/cfg/groovyactionscfg</configfile>
<bundle start="false">mvn:org.apache.unomi/unomi-groovy-actions-services/${project.version}</bundle>
<bundle start="false">mvn:org.apache.unomi/unomi-groovy-actions-rest/${project.version}</bundle>
</feature>
diff --git a/extensions/groovy-actions/services/pom.xml b/extensions/groovy-actions/services/pom.xml
index 5e6540f55..751baf13e 100644
--- a/extensions/groovy-actions/services/pom.xml
+++ b/extensions/groovy-actions/services/pom.xml
@@ -56,6 +56,11 @@
<artifactId>unomi-persistence-spi</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-services-common</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.apache.unomi</groupId>
<artifactId>unomi-services</artifactId>
@@ -82,6 +87,11 @@
<artifactId>org.osgi.service.component.annotations</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.osgi</groupId>
+ <artifactId>org.osgi.service.event</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>commons-io</groupId>
@@ -115,6 +125,46 @@
<version>${groovy.version}</version>
<scope>provided</scope>
</dependency>
+
+ <!-- Test dependencies -->
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <version>3.11.2</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-services</artifactId>
+ <type>test-jar</type>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.karaf.jaas</groupId>
+ <artifactId>org.apache.karaf.jaas.boot</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.datatype</groupId>
+ <artifactId>jackson-datatype-jsr310</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-simple</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.module</groupId>
+ <artifactId>jackson-module-jaxb-annotations</artifactId>
+ <scope>test</scope>
+ </dependency>
+
</dependencies>
<build>
diff --git a/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/listener/GroovyActionListener.java b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/listener/GroovyActionListener.java
deleted file mode 100644
index 21778bb0d..000000000
--- a/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/listener/GroovyActionListener.java
+++ /dev/null
@@ -1,145 +0,0 @@
-/*
- * 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.groovy.actions.listener;
-
-import org.apache.commons.io.FilenameUtils;
-import org.apache.commons.io.IOUtils;
-import org.apache.unomi.groovy.actions.services.GroovyActionsService;
-import org.osgi.framework.Bundle;
-import org.osgi.framework.BundleContext;
-import org.osgi.framework.BundleEvent;
-import org.osgi.framework.SynchronousBundleListener;
-import org.osgi.service.component.annotations.Activate;
-import org.osgi.service.component.annotations.Component;
-import org.osgi.service.component.annotations.Deactivate;
-import org.osgi.service.component.annotations.Reference;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.IOException;
-import java.net.URL;
-import java.util.Enumeration;
-
-/**
- * An implementation of a BundleListener for the Groovy language.
- * It will load the groovy files in the folder META-INF/cxs/actions.
- * The description of the action will be loaded from the ActionDescriptor annotation present in the groovy file.
- * The script will be stored in the ES index groovyAction
- */
-@Component(service = SynchronousBundleListener.class)
-public class GroovyActionListener implements SynchronousBundleListener {
-
- private static final Logger LOGGER = LoggerFactory.getLogger(GroovyActionListener.class.getName());
- public static final String ENTRIES_LOCATION = "META-INF/cxs/actions";
-
- private GroovyActionsService groovyActionsService;
- private BundleContext bundleContext;
-
- @Reference
- public void setGroovyActionsService(GroovyActionsService groovyActionsService) {
- this.groovyActionsService = groovyActionsService;
- }
-
- @Activate
- public void postConstruct(BundleContext bundleContext) {
- this.bundleContext = bundleContext;
- LOGGER.debug("postConstruct {}", bundleContext.getBundle());
- loadGroovyActions(bundleContext);
- for (Bundle bundle : bundleContext.getBundles()) {
- if (bundle.getBundleContext() != null && bundle.getBundleId() != bundleContext.getBundle().getBundleId()) {
- loadGroovyActions(bundle.getBundleContext());
- }
- }
-
- bundleContext.addBundleListener(this);
- LOGGER.info("Groovy Action Dispatcher initialized.");
- }
-
- @Deactivate
- public void preDestroy() {
- processBundleStop(bundleContext);
- bundleContext.removeBundleListener(this);
- LOGGER.info("Groovy Action Dispatcher shutdown.");
- }
-
- private void processBundleStartup(BundleContext bundleContext) {
- if (bundleContext == null) {
- return;
- }
- loadGroovyActions(bundleContext);
- }
-
- private void processBundleStop(BundleContext bundleContext) {
- if (bundleContext == null) {
- return;
- }
- unloadGroovyActions(bundleContext);
- }
-
- public void bundleChanged(BundleEvent event) {
- switch (event.getType()) {
- case BundleEvent.STARTED:
- processBundleStartup(event.getBundle().getBundleContext());
- break;
- case BundleEvent.STOPPING:
- if (!event.getBundle().getSymbolicName().equals("org.apache.unomi.groovy-actions-services")) {
- processBundleStop(event.getBundle().getBundleContext());
- }
- break;
- }
- }
-
- private void addGroovyAction(URL groovyActionURL) {
- try {
- groovyActionsService.save(FilenameUtils.getName(groovyActionURL.getPath()).replace(".groovy", ""),
- IOUtils.toString(groovyActionURL.openStream()));
- } catch (IOException e) {
- LOGGER.error("Failed to load the groovy action {}", groovyActionURL.getPath(), e);
- }
- }
-
- private void removeGroovyAction(URL groovyActionURL) {
- String actionName = FilenameUtils.getName(groovyActionURL.getPath()).replace(".groovy", "");
- groovyActionsService.remove(actionName);
- LOGGER.info("The script {} has been removed.", actionName);
- }
-
- private void loadGroovyActions(BundleContext bundleContext) {
- Enumeration<URL> bundleGroovyActions = bundleContext.getBundle().findEntries(ENTRIES_LOCATION, "*.groovy", true);
- if (bundleGroovyActions == null) {
- return;
- }
- while (bundleGroovyActions.hasMoreElements()) {
- URL groovyActionURL = bundleGroovyActions.nextElement();
- LOGGER.debug("Found Groovy action at {}, loading... ", groovyActionURL.getPath());
- addGroovyAction(groovyActionURL);
- }
- }
-
- private void unloadGroovyActions(BundleContext bundleContext) {
- Enumeration<URL> bundleGroovyActions = bundleContext.getBundle().findEntries(ENTRIES_LOCATION, "*.groovy", true);
- if (bundleGroovyActions == null) {
- return;
- }
-
- while (bundleGroovyActions.hasMoreElements()) {
- URL groovyActionURL = bundleGroovyActions.nextElement();
- LOGGER.debug("Found Groovy action at {}, loading... ", groovyActionURL.getPath());
- removeGroovyAction(groovyActionURL);
- }
- }
-}
diff --git a/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
index 3ad70b69b..6f1b737b2 100644
--- a/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
+++ b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
@@ -21,125 +21,320 @@ import groovy.lang.GroovyCodeSource;
import groovy.lang.GroovyShell;
import groovy.lang.Script;
import groovy.util.GroovyScriptEngine;
+import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.Parameter;
import org.apache.unomi.api.actions.ActionType;
import org.apache.unomi.api.services.DefinitionsService;
+import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.api.services.SchedulerService;
+import org.apache.unomi.api.services.cache.CacheableTypeConfig;
+import org.apache.unomi.api.services.cache.MultiTypeCacheService;
+import org.apache.unomi.api.tenants.TenantService;
import org.apache.unomi.groovy.actions.GroovyAction;
import org.apache.unomi.groovy.actions.GroovyBundleResourceConnector;
import org.apache.unomi.groovy.actions.ScriptMetadata;
import org.apache.unomi.groovy.actions.annotations.Action;
import org.apache.unomi.groovy.actions.services.GroovyActionsService;
import org.apache.unomi.persistence.spi.PersistenceService;
+import org.apache.unomi.services.actions.ActionExecutorDispatcher;
+import org.apache.unomi.services.common.cache.AbstractMultiTypeCachingService;
import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.control.customizers.ImportCustomizer;
import org.osgi.framework.BundleContext;
import org.osgi.framework.wiring.BundleWiring;
-import org.osgi.service.component.annotations.*;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
import org.osgi.service.metatype.annotations.Designate;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.Serializable;
import java.net.URL;
import java.nio.charset.StandardCharsets;
-import java.util.HashSet;
-import java.util.Set;
-
-import java.util.Map;
-import java.util.TimerTask;
+import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static java.util.Arrays.asList;
+import static org.apache.unomi.api.tenants.TenantService.SYSTEM_TENANT;
/**
* High-performance GroovyActionsService implementation with pre-compilation,
* hash-based change detection, and thread-safe execution.
+ *
+ * This implementation handles three distinct scenarios for Groovy actions:
+ *
+ * 1. Preloading from bundle resources:
+ * - Groovy scripts are loaded from META-INF/cxs/actions/*.groovy files
+ * - ActionTypes are registered directly during processGroovyScript
+ * - Custom loadPredefinedItemsForType handles storing code sources in tenant map
+ *
+ * 2. Manual saving via API:
+ * - ActionTypes are registered directly during save method
+ * - Code sources are stored in the tenant map for runtime execution
+ *
+ * 3. Cache refreshing from persistence:
+ * - processGroovyActionForCache is used which only stores code sources in tenant map
+ * - No ActionType persistence happens during cache refresh
+ * - Avoids circular persistence operations during refresh
*/
@Component(service = GroovyActionsService.class, configurationPid = "org.apache.unomi.groovy.actions")
@Designate(ocd = GroovyActionsServiceImpl.GroovyActionsServiceConfig.class)
-public class GroovyActionsServiceImpl implements GroovyActionsService {
+public class GroovyActionsServiceImpl extends AbstractMultiTypeCachingService implements GroovyActionsService {
@ObjectClassDefinition(name = "Groovy actions service config", description = "The configuration for the Groovy actions service")
public @interface GroovyActionsServiceConfig {
int services_groovy_actions_refresh_interval() default 1000;
}
- private BundleContext bundleContext;
private GroovyScriptEngine groovyScriptEngine;
- private CompilerConfiguration compilerConfiguration;
- private ScheduledFuture<?> scheduledFuture;
+ // Thread-safe compilation shell for ScriptMetadata
private final Object compilationLock = new Object();
private GroovyShell compilationShell;
- private volatile Map<String, ScriptMetadata> scriptMetadataCache = new ConcurrentHashMap<>();
+ private volatile Map<String, Map<String, ScriptMetadata>> scriptMetadataCacheByTenant = new ConcurrentHashMap<>();
private final Map<String, Set<String>> loggedRefreshErrors = new ConcurrentHashMap<>();
private static final int MAX_LOGGED_ERRORS = 100; // Prevent memory leak
private static final Logger LOGGER = LoggerFactory.getLogger(GroovyActionsServiceImpl.class.getName());
private static final String BASE_SCRIPT_NAME = "BaseScript";
+ // Original path for Groovy actions
+ private static final String ACTIONS_LOCATION = "actions";
private DefinitionsService definitionsService;
- private PersistenceService persistenceService;
- private SchedulerService schedulerService;
+ private ActionExecutorDispatcher actionExecutorDispatcher;
private GroovyActionsServiceConfig config;
+ // Define the cacheable type config for GroovyAction
+ private final CacheableTypeConfig<GroovyAction> groovyActionTypeConfig = CacheableTypeConfig
+ .<GroovyAction>builder(GroovyAction.class, GroovyAction.ITEM_TYPE, ACTIONS_LOCATION)
+ .withInheritFromSystemTenant(true)
+ .withRequiresRefresh(true)
+ .withRefreshInterval(1000) // Will be overridden by config
+ .withIdExtractor(GroovyAction::getName)
+ // Skip saving action types during cache refresh to avoid circular persistence operations
+ .withPostProcessor(this::processGroovyActionForCache)
+ .withStreamProcessor((bundleContext, url, inputStream) -> contextManager.executeAsSystem(() -> processGroovyScript(bundleContext, url, inputStream)))
+ .build();
+
@Reference
public void setDefinitionsService(DefinitionsService definitionsService) {
this.definitionsService = definitionsService;
}
@Reference
- public void setPersistenceService(PersistenceService persistenceService) {
- this.persistenceService = persistenceService;
+ public void setActionExecutorDispatcher(ActionExecutorDispatcher actionExecutorDispatcher) {
+ this.actionExecutorDispatcher = actionExecutorDispatcher;
+ }
+
+ @Reference
+ public void setCacheService(MultiTypeCacheService cacheService) {
+ super.setCacheService(cacheService);
}
@Reference
public void setSchedulerService(SchedulerService schedulerService) {
- this.schedulerService = schedulerService;
+ super.setSchedulerService(schedulerService);
}
+ @Reference
+ public void setTenantService(TenantService tenantService) {
+ super.setTenantService(tenantService);
+ }
+ @Reference
+ public void setContextManager(ExecutionContextManager contextManager) {
+ super.setContextManager(contextManager);
+ }
- @Activate
- public void start(GroovyActionsServiceConfig config, BundleContext bundleContext) {
- LOGGER.debug("postConstruct {}", bundleContext.getBundle());
+ @Reference
+ public void setPersistenceService(PersistenceService persistenceService) {
+ super.setPersistenceService(persistenceService);
+ }
+ @Activate
+ public void activate(GroovyActionsServiceConfig config, BundleContext bundleContext) {
+ LOGGER.debug("Activating Groovy Actions Service {}", bundleContext.getBundle());
this.config = config;
- this.bundleContext = bundleContext;
+ this.setBundleContext(bundleContext);
+
+ // Initialize Groovy-specific components
+ initializeGroovyComponents();
+
+ // Initialize the caching service
+ super.postConstruct();
+ }
+
+ @Deactivate
+ @Override
+ public void preDestroy() {
+ LOGGER.debug("Deactivating Groovy Actions Service");
+ super.preDestroy();
+ }
+
+ /**
+ * Override the loadPredefinedItemsForType method to use our own extension pattern (*.groovy instead of *.json)
+ * while keeping the original path structure
+ */
+ @Override
+ @SuppressWarnings("unchecked")
+ protected <T extends Serializable> void loadPredefinedItemsForType(BundleContext bundleContext, CacheableTypeConfig<T> config) {
+ // Skip if this type doesn't match our GroovyAction type
+ if (!config.getType().equals(GroovyAction.class)) {
+ // Use the parent implementation for other types
+ super.loadPredefinedItemsForType(bundleContext, config);
+ return;
+ }
+
+ // Skip if this type doesn't have predefined items
+ if (!config.hasPredefinedItems()) {
+ return;
+ }
+ // Use *.groovy pattern instead of *.json for Groovy actions
+ Enumeration<URL> entries = bundleContext.getBundle()
+ .findEntries("META-INF/cxs/" + config.getMetaInfPath(), "*.groovy", true);
+
+ if (entries == null) return;
+
+ // Process entries in the same way as the parent class does
+ List<URL> entryList = Collections.list(entries);
+ if (config.hasUrlComparator()) {
+ entryList.sort(config.getUrlComparator());
+ }
+
+ for (URL entryURL : entryList) {
+ logger.debug("Found predefined Groovy action at {}, loading... ", entryURL.getPath());
+
+ try {
+ final long bundleId = bundleContext.getBundle().getBundleId();
+
+ // Use stream processor to process the Groovy script
+ try (InputStream inputStream = entryURL.openStream()) {
+ // During preloading, the processGroovyScript method will extract and register the ActionType
+ T item = config.getStreamProcessor().apply(bundleContext, entryURL, inputStream);
+ if (item == null) {
+ logger.warn("Stream processor returned null for {}", entryURL);
+ continue;
+ }
+
+ // Final item variable for lambda
+ final T finalItem = item;
+
+ // Process in system context to ensure permissions
+ contextManager.executeAsSystem(() -> {
+ try {
+ // We're skipping the post-processor here because:
+ // 1. For GroovyAction, the ActionType is already registered in processGroovyScript
+ // 2. The only other thing postProcessor does is to add the code source to the tenant map
+
+ // Manual handling of what's needed from the post-processor
+ // (just storing the script metadata in tenant map)
+ if (finalItem instanceof GroovyAction) {
+ GroovyAction groovyAction = (GroovyAction) finalItem;
+ String actionName = groovyAction.getName();
+ String script = groovyAction.getScript();
+
+ // Create and store ScriptMetadata for the new interface
+ try {
+ ScriptMetadata metadata = compileAndCreateMetadata(actionName, script);
+ Map<String, ScriptMetadata> scriptMetadataMap = scriptMetadataCacheByTenant
+ .computeIfAbsent(SYSTEM_TENANT, k -> new ConcurrentHashMap<>());
+ scriptMetadataMap.put(actionName, metadata);
+ } catch (Exception e) {
+ logger.error("Failed to create ScriptMetadata for predefined action {}", actionName, e);
+ }
+ }
+
+ // Track contribution
+ addPluginContribution(bundleId, finalItem);
+
+ // Add to cache
+ String id = config.getIdExtractor().apply(finalItem);
+ cacheService.put(config.getItemType(), id, SYSTEM_TENANT, finalItem);
+
+ logger.info("Predefined Groovy action registered: {}", id);
+ } catch (Exception e) {
+ logger.error("Error processing Groovy action {}", entryURL, e);
+ }
+ return null;
+ });
+ } catch (Exception e) {
+ logger.error("Error processing {} with stream processor: {}", entryURL, e.getMessage(), e);
+ }
+ } catch (Exception e) {
+ logger.error("Error loading Groovy action {}", entryURL, e);
+ }
+ }
+ }
+
+ /**
+ * Process a Groovy script from an input stream and create a GroovyAction.
+ * This is used by AbstractMultiTypeCachingService to process .groovy files
+ * instead of expecting JSON files.
+ *
+ * @param bundleContext the bundle context
+ * @param url the URL of the resource
+ * @param inputStream the input stream containing the Groovy script
+ * @return a new GroovyAction instance
+ */
+ private GroovyAction processGroovyScript(BundleContext bundleContext, URL url, InputStream inputStream) {
+ try {
+ String actionName = FilenameUtils.getBaseName(url.getPath());
+ String groovyScript = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
+
+ // Create the GroovyAction instance
+ GroovyAction groovyAction = new GroovyAction(actionName, groovyScript);
+
+ // During preloading, we need to register the ActionType immediately
+ // Create a code source for parsing
+ GroovyCodeSource groovyCodeSource = new GroovyCodeSource(groovyScript, actionName, "/groovy/script");
+
+ // Extract Action annotation and register the ActionType
+ try {
+ synchronized(compilationLock) {
+ Action actionAnnotation = compilationShell.parse(groovyCodeSource).getClass().getMethod("execute").getAnnotation(Action.class);
+ if (actionAnnotation != null) {
+ contextManager.executeAsSystem(() -> {
+ saveActionType(actionAnnotation);
+ });
+ }
+ }
+ } catch (NoSuchMethodException e) {
+ LOGGER.warn("Failed to extract Action annotation from predefined Groovy script {}: {}", actionName, e.getMessage());
+ }
+
+ LOGGER.debug("Processed Groovy script from {}, action name: {}", url.getPath(), actionName);
+ return groovyAction;
+
+ } catch (IOException e) {
+ LOGGER.error("Error processing Groovy script from {}: {}", url.getPath(), e.getMessage(), e);
+ return null;
+ }
+ }
+
+ /**
+ * Initialize the Groovy-specific components like GroovyScriptEngine and GroovyShell
+ */
+ private void initializeGroovyComponents() {
GroovyBundleResourceConnector bundleResourceConnector = new GroovyBundleResourceConnector(bundleContext);
GroovyClassLoader groovyLoader = new GroovyClassLoader(bundleContext.getBundle().adapt(BundleWiring.class).getClassLoader());
this.groovyScriptEngine = new GroovyScriptEngine(bundleResourceConnector, groovyLoader);
- // Initialize Groovy compiler and compilation shell
- initializeGroovyCompiler();
-
+ initializeCompilationShell();
try {
loadBaseScript();
} catch (IOException e) {
LOGGER.error("Failed to load base script", e);
}
-
- // PRE-COMPILE ALL SCRIPTS AT STARTUP (no on-demand compilation)
- preloadAllScripts();
-
- initializeTimers();
- LOGGER.info("Groovy action service initialized with {} scripts", scriptMetadataCache.size());
- }
-
- @Deactivate
- public void onDestroy() {
- LOGGER.debug("onDestroy Method called");
- if (scheduledFuture != null && !scheduledFuture.isCancelled()) {
- scheduledFuture.cancel(true);
- }
}
/**
@@ -147,7 +342,7 @@ public class GroovyActionsServiceImpl implements GroovyActionsService {
* It's a script which provides utility functions that we can use in other groovy script
* The functions added by the base script could be called by the groovy actions executed in
* {@link org.apache.unomi.groovy.actions.GroovyActionDispatcher#execute}
- * The base script would be added in the configuration of the {@link GroovyActionsServiceImpl#compilationShell GroovyShell} , so when a
+ * The base script would be added in the configuration of the {@link GroovyActionsServiceImpl#groovyShell GroovyShell} , so when a
* script will be parsed with the GroovyShell (groovyShell.parse(...)), the action will extends the base script, so the functions
* could be called
*
@@ -164,77 +359,107 @@ public class GroovyActionsServiceImpl implements GroovyActionsService {
}
/**
- * Initializes compiler configuration and shared compilation shell.
+ * Initialize the compilation shell with proper configuration
*/
- private void initializeGroovyCompiler() {
- // Configure the compiler with imports and base script
- compilerConfiguration = new CompilerConfiguration();
+ private void initializeCompilationShell() {
+ CompilerConfiguration compilerConfiguration = new CompilerConfiguration();
compilerConfiguration.addCompilationCustomizers(createImportCustomizer());
+
compilerConfiguration.setScriptBaseClass(BASE_SCRIPT_NAME);
groovyScriptEngine.setConfig(compilerConfiguration);
- // Create single shared shell for compilation only
+ // Initialize the compilation shell for ScriptMetadata
this.compilationShell = new GroovyShell(groovyScriptEngine.getGroovyClassLoader(), compilerConfiguration);
+ compilationShell.setVariable("actionExecutorDispatcher", actionExecutorDispatcher);
+ compilationShell.setVariable("definitionsService", definitionsService);
+ compilationShell.setVariable("logger", LoggerFactory.getLogger("GroovyAction"));
+ }
+
+ private ImportCustomizer createImportCustomizer() {
+ ImportCustomizer importCustomizer = new ImportCustomizer();
+ importCustomizer.addImports("org.apache.unomi.api.services.EventService", "org.apache.unomi.groovy.actions.annotations.Action",
+ "org.apache.unomi.groovy.actions.annotations.Parameter");
+ return importCustomizer;
}
/**
- * Pre-compiles all scripts at startup to eliminate runtime compilation overhead.
+ * Process a GroovyAction for caching purposes, creating ScriptMetadata and storing it in the tenant map.
+ * This method specifically avoids registering ActionTypes to prevent circular persistence operations.
+ *
+ * @param groovyAction the GroovyAction to process
*/
- private void preloadAllScripts() {
- long startTime = System.currentTimeMillis();
- LOGGER.info("Pre-compiling all Groovy scripts at startup...");
-
- int successCount = 0;
- int failureCount = 0;
- long totalCompilationTime = 0;
+ private void processGroovyActionForCache(GroovyAction groovyAction) {
+ try {
+ String actionName = groovyAction.getName();
+ String script = groovyAction.getScript();
- for (GroovyAction groovyAction : persistenceService.getAllItems(GroovyAction.class)) {
+ // Create and store ScriptMetadata for the new interface
try {
- String actionName = groovyAction.getName();
- String scriptContent = groovyAction.getScript();
-
- long scriptStartTime = System.currentTimeMillis();
- ScriptMetadata metadata = compileAndCreateMetadata(actionName, scriptContent);
- long scriptCompilationTime = System.currentTimeMillis() - scriptStartTime;
- totalCompilationTime += scriptCompilationTime;
-
- scriptMetadataCache.put(actionName, metadata);
-
- successCount++;
- LOGGER.debug("Pre-compiled script: {} ({}ms)", actionName, scriptCompilationTime);
-
+ ScriptMetadata metadata = compileAndCreateMetadata(actionName, script);
+ Map<String, ScriptMetadata> scriptMetadataMap = getScriptMetadataMap();
+ scriptMetadataMap.put(actionName, metadata);
} catch (Exception e) {
- failureCount++;
- LOGGER.error("Failed to pre-compile script: {}", groovyAction.getName(), e);
+ logRefreshError(actionName, "Failed to create ScriptMetadata", e);
}
- }
- long totalTime = System.currentTimeMillis() - startTime;
- LOGGER.info("Pre-compilation completed: {} scripts successfully compiled, {} failures. Total time: {}ms",
- successCount, failureCount, totalTime);
- LOGGER.debug("Pre-compilation metrics: Average per script: {}ms, Compilation overhead: {}ms",
- successCount > 0 ? totalCompilationTime / successCount : 0,
- totalTime - totalCompilationTime);
+ // We parse the script to validate it, but intentionally skip saving ActionType
+ // to avoid circular persistence operations during cache refresh
+ try {
+ GroovyCodeSource groovyCodeSource = new GroovyCodeSource(script, actionName, "/groovy/script");
+ synchronized(compilationLock) {
+ compilationShell.parse(groovyCodeSource).getClass().getMethod("execute");
+ }
+ // Note: We don't extract or save the ActionType here
+ } catch (NoSuchMethodException e) {
+ logRefreshError(actionName, "Failed to validate Groovy script", e);
+ }
+ } catch (Exception e) {
+ logRefreshError(groovyAction.getName(), "Error processing Groovy action", e);
+ }
}
/**
- * Thread-safe script compilation using synchronized shared shell.
+ * Logs refresh errors with rate limiting to prevent log spam.
+ * Only logs the first MAX_LOGGED_ERRORS errors per action to prevent memory leaks.
*/
- private Class<? extends Script> compileScript(String actionName, String scriptContent) {
- GroovyCodeSource codeSource = buildClassScript(scriptContent, actionName);
- synchronized(compilationLock) {
- return compilationShell.parse(codeSource).getClass();
+ private void logRefreshError(String actionName, String message, Exception e) {
+ String tenantId = contextManager.getCurrentContext().getTenantId();
+ Set<String> tenantErrors = loggedRefreshErrors.computeIfAbsent(tenantId, k -> ConcurrentHashMap.newKeySet());
+
+ if (tenantErrors.size() < MAX_LOGGED_ERRORS) {
+ tenantErrors.add(actionName);
+ LOGGER.error("{} for action {}: {}", message, actionName, e.getMessage(), e);
+ } else if (tenantErrors.contains(actionName)) {
+ // Already logged this action, just log at debug level
+ LOGGER.debug("{} for action {}: {}", message, actionName, e.getMessage());
+ } else {
+ // Too many errors logged, skip this one
+ LOGGER.debug("Skipping error log for action {} due to error limit ({}): {}",
+ actionName, MAX_LOGGED_ERRORS, e.getMessage());
}
}
- /**
- * Creates import customizer with standard Unomi imports.
- */
- private ImportCustomizer createImportCustomizer() {
- ImportCustomizer importCustomizer = new ImportCustomizer();
- importCustomizer.addImports("org.apache.unomi.api.services.EventService", "org.apache.unomi.groovy.actions.annotations.Action",
- "org.apache.unomi.groovy.actions.annotations.Parameter");
- return importCustomizer;
+ @Override
+ protected Set<CacheableTypeConfig<?>> getTypeConfigs() {
+ // Update refresh interval from config
+ if (config != null) {
+ CacheableTypeConfig<GroovyAction> updatedConfig = CacheableTypeConfig
+ .<GroovyAction>builder(GroovyAction.class, GroovyAction.ITEM_TYPE, ACTIONS_LOCATION)
+ .withInheritFromSystemTenant(true)
+ .withRequiresRefresh(true)
+ .withRefreshInterval(config.services_groovy_actions_refresh_interval())
+ .withIdExtractor(GroovyAction::getName)
+ // We need to skip saving the action type during cache refresh to avoid circular persistence operations.
+ // During cache refresh, we're loading items that already exist in the persistence store,
+ // so calling saveActionType would trigger another persistence.save operation for the same item.
+ .withPostProcessor(this::processGroovyActionForCache)
+ .withStreamProcessor(this::processGroovyScript)
+ .build();
+
+ return Collections.singleton(updatedConfig);
+ }
+
+ return Collections.singleton(groovyActionTypeConfig);
}
/**
@@ -246,6 +471,16 @@ public class GroovyActionsServiceImpl implements GroovyActionsService {
}
}
+ /**
+ * Thread-safe script compilation using synchronized shared shell.
+ */
+ private Class<? extends Script> compileScript(String actionName, String scriptContent) {
+ GroovyCodeSource codeSource = new GroovyCodeSource(scriptContent, actionName, "/groovy/script");
+ synchronized(compilationLock) {
+ return compilationShell.parse(codeSource).getClass();
+ }
+ }
+
/**
* Compiles a script and creates metadata with timing information.
*/
@@ -264,6 +499,10 @@ public class GroovyActionsServiceImpl implements GroovyActionsService {
private Action getActionAnnotation(Class<? extends Script> scriptClass) {
try {
return scriptClass.getMethod("execute").getAnnotation(Action.class);
+ } catch (NoSuchMethodException e) {
+ // Scripts without an execute() method are valid; they simply have no @Action metadata
+ LOGGER.debug("No execute() method found on script class {}, skipping @Action extraction", scriptClass.getName());
+ return null;
} catch (Exception e) {
LOGGER.error("Failed to extract action annotation", e);
return null;
@@ -271,9 +510,13 @@ public class GroovyActionsServiceImpl implements GroovyActionsService {
}
/**
- * {@inheritDoc}
- * Implementation performs hash-based change detection to skip unnecessary recompilation.
+ * Gets the script metadata map for the current tenant.
*/
+ private Map<String, ScriptMetadata> getScriptMetadataMap() {
+ String tenantId = contextManager.getCurrentContext().getTenantId();
+ return scriptMetadataCacheByTenant.computeIfAbsent(tenantId, k -> new ConcurrentHashMap<>());
+ }
+
@Override
public void save(String actionName, String groovyScript) {
validateNotEmpty(actionName, "Action name");
@@ -283,7 +526,9 @@ public class GroovyActionsServiceImpl implements GroovyActionsService {
LOGGER.info("Saving script: {}", actionName);
try {
- ScriptMetadata existingMetadata = scriptMetadataCache.get(actionName);
+ Map<String, ScriptMetadata> scriptMetadataMap = getScriptMetadataMap();
+
+ ScriptMetadata existingMetadata = scriptMetadataMap.get(actionName);
if (existingMetadata != null && !existingMetadata.hasChanged(groovyScript)) {
LOGGER.info("Script {} unchanged, skipping recompilation ({}ms)", actionName,
System.currentTimeMillis() - startTime);
@@ -299,9 +544,12 @@ public class GroovyActionsServiceImpl implements GroovyActionsService {
saveActionType(actionAnnotation);
}
- saveScript(actionName, groovyScript);
+ // Create and save the GroovyAction
+ GroovyAction groovyAction = new GroovyAction(actionName, groovyScript);
+ saveItem(groovyAction, GroovyAction::getName, GroovyAction.ITEM_TYPE);
- scriptMetadataCache.put(actionName, metadata);
+ // Store the new metadata
+ scriptMetadataMap.put(actionName, metadata);
long totalTime = System.currentTimeMillis() - startTime;
LOGGER.info("Script {} saved and compiled successfully (total: {}ms, compilation: {}ms)",
@@ -315,10 +563,12 @@ public class GroovyActionsServiceImpl implements GroovyActionsService {
}
/**
- * Builds and registers ActionType from Action annotation.
+ * Build an action type from the annotation {@link Action}
+ *
+ * @param action Annotation containing the values to save
*/
private void saveActionType(Action action) {
- Metadata metadata = new Metadata(null, action.id(), action.name().isEmpty() ? action.id() : action.name(), action.description());
+ Metadata metadata = new Metadata(null, action.id(), action.name().equals("") ? action.id() : action.name(), action.description());
metadata.setHidden(action.hidden());
metadata.setReadOnly(true);
metadata.setSystemTags(new HashSet<>(asList(action.systemTags())));
@@ -326,25 +576,33 @@ public class GroovyActionsServiceImpl implements GroovyActionsService {
actionType.setActionExecutor(action.actionExecutor());
actionType.setParameters(Stream.of(action.parameters())
- .map(parameter -> new org.apache.unomi.api.Parameter(parameter.id(), parameter.type(), parameter.multivalued()))
+ .map(parameter -> new Parameter(parameter.id(), parameter.type(), parameter.multivalued()))
.collect(Collectors.toList()));
definitionsService.setActionType(actionType);
}
- /**
- * {@inheritDoc}
- */
@Override
public void remove(String actionName) {
validateNotEmpty(actionName, "Action name");
LOGGER.info("Removing script: {}", actionName);
- ScriptMetadata removedMetadata = scriptMetadataCache.remove(actionName);
- persistenceService.remove(actionName, GroovyAction.class);
-
+ Map<String, ScriptMetadata> scriptMetadataMap = getScriptMetadataMap();
+
+ ScriptMetadata removedMetadata = scriptMetadataMap.remove(actionName);
+
// Clean up error tracking to prevent memory leak
- loggedRefreshErrors.remove(actionName);
+ String tenantId = contextManager.getCurrentContext().getTenantId();
+ Set<String> tenantErrors = loggedRefreshErrors.get(tenantId);
+ if (tenantErrors != null) {
+ tenantErrors.remove(actionName);
+ if (tenantErrors.isEmpty()) {
+ loggedRefreshErrors.remove(tenantId);
+ }
+ }
+
+ // Remove from persistent storage and cache
+ removeItem(actionName, GroovyAction.class, GroovyAction.ITEM_TYPE);
if (removedMetadata != null) {
Action actionAnnotation = getActionAnnotation(removedMetadata.getCompiledClass());
@@ -356,153 +614,28 @@ public class GroovyActionsServiceImpl implements GroovyActionsService {
LOGGER.info("Script {} removed successfully", actionName);
}
- /**
- * {@inheritDoc}
- */
@Override
- public Class<? extends Script> getCompiledScript(String id) {
- validateNotEmpty(id, "Script ID");
+ public Class<? extends Script> getCompiledScript(String actionName) {
+ validateNotEmpty(actionName, "Script ID");
+
+ Map<String, ScriptMetadata> scriptMetadataMap = getScriptMetadataMap();
- ScriptMetadata metadata = scriptMetadataCache.get(id);
+ ScriptMetadata metadata = scriptMetadataMap.get(actionName);
if (metadata == null) {
- LOGGER.warn("Script {} not found in cache", id);
+ LOGGER.warn("Script {} not found in cache", actionName);
return null;
}
return metadata.getCompiledClass();
}
- /**
- * {@inheritDoc}
- */
@Override
public ScriptMetadata getScriptMetadata(String actionName) {
validateNotEmpty(actionName, "Action name");
- return scriptMetadataCache.get(actionName);
- }
+ Map<String, ScriptMetadata> scriptMetadataMap = getScriptMetadataMap();
- /**
- * Creates GroovyCodeSource for compilation.
- */
- private GroovyCodeSource buildClassScript(String groovyScript, String actionName) {
- return new GroovyCodeSource(groovyScript, actionName, "/groovy/script");
+ return scriptMetadataMap.get(actionName);
}
- /**
- * Persists script to storage.
- */
- private void saveScript(String actionName, String script) {
- GroovyAction groovyScript = new GroovyAction(actionName, script);
- persistenceService.save(groovyScript);
- LOGGER.info("The script {} has been persisted.", actionName);
- }
- /**
- * Refreshes scripts from persistence with selective recompilation.
- * Uses hash-based change detection and atomic cache updates.
- */
- private void refreshGroovyActions() {
- long startTime = System.currentTimeMillis();
-
- Map<String, ScriptMetadata> newMetadataCache = new ConcurrentHashMap<>();
- int unchangedCount = 0;
- int recompiledCount = 0;
- int errorCount = 0;
- int newErrorCount = 0;
- long totalCompilationTime = 0;
-
- for (GroovyAction groovyAction : persistenceService.getAllItems(GroovyAction.class)) {
- String actionName = groovyAction.getName();
- String scriptContent = groovyAction.getScript();
-
- try {
- ScriptMetadata existingMetadata = scriptMetadataCache.get(actionName);
- if (existingMetadata != null && !existingMetadata.hasChanged(scriptContent)) {
- newMetadataCache.put(actionName, existingMetadata);
- unchangedCount++;
- LOGGER.debug("Script {} unchanged during refresh, keeping cached version", actionName);
- } else {
- if (recompiledCount == 0) {
- LOGGER.info("Refreshing scripts from persistence layer...");
- }
-
- long compilationStartTime = System.currentTimeMillis();
- ScriptMetadata metadata = compileAndCreateMetadata(actionName, scriptContent);
- long compilationTime = System.currentTimeMillis() - compilationStartTime;
- totalCompilationTime += compilationTime;
-
- // Clear error tracking on successful compilation
- loggedRefreshErrors.remove(actionName);
-
- newMetadataCache.put(actionName, metadata);
- recompiledCount++;
- LOGGER.info("Script {} recompiled during refresh ({}ms)", actionName, compilationTime);
- }
-
- } catch (Exception e) {
- if (newErrorCount == 0 && recompiledCount == 0) {
- LOGGER.info("Refreshing scripts from persistence layer...");
- }
-
- errorCount++;
-
- // Prevent log spam for repeated compilation errors during refresh
- String errorMessage = e.getMessage();
- Set<String> scriptErrors = loggedRefreshErrors.get(actionName);
-
- if (scriptErrors == null || !scriptErrors.contains(errorMessage)) {
- newErrorCount++;
- LOGGER.error("Failed to refresh script: {}", actionName, e);
-
- // Prevent memory leak by limiting tracked errors before adding new entries
- if (scriptErrors == null && loggedRefreshErrors.size() >= MAX_LOGGED_ERRORS) {
- // Remove one random entry to make space (simple eviction)
- String firstKey = loggedRefreshErrors.keySet().iterator().next();
- loggedRefreshErrors.remove(firstKey);
- }
-
- // Now safely add the error
- if (scriptErrors == null) {
- scriptErrors = ConcurrentHashMap.newKeySet();
- loggedRefreshErrors.put(actionName, scriptErrors);
- }
- scriptErrors.add(errorMessage);
-
- LOGGER.warn("Keeping existing version of script {} due to compilation error", actionName);
- }
-
- ScriptMetadata existingMetadata = scriptMetadataCache.get(actionName);
- if (existingMetadata != null) {
- newMetadataCache.put(actionName, existingMetadata);
- }
- }
- }
-
- this.scriptMetadataCache = newMetadataCache;
-
- if (recompiledCount > 0 || newErrorCount > 0) {
- long totalTime = System.currentTimeMillis() - startTime;
- LOGGER.info("Script refresh completed: {} unchanged, {} recompiled, {} errors. Total time: {}ms",
- unchangedCount, recompiledCount, errorCount, totalTime);
- LOGGER.debug("Refresh metrics: Recompilation time: {}ms, Cache update overhead: {}ms",
- totalCompilationTime, totalTime - totalCompilationTime);
- } else {
- LOGGER.debug("Script refresh completed: {} scripts checked, no changes detected ({}ms)",
- unchangedCount, System.currentTimeMillis() - startTime);
- }
- }
-
- /**
- * Initializes periodic script refresh timer.
- */
- private void initializeTimers() {
- TimerTask task = new TimerTask() {
- @Override
- public void run() {
- refreshGroovyActions();
- }
- };
- scheduledFuture = schedulerService.getScheduleExecutorService().scheduleWithFixedDelay(task, 0, config.services_groovy_actions_refresh_interval(),
- TimeUnit.MILLISECONDS);
- }
}
diff --git a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
index 08c679b4b..5a6f181ba 100644
--- a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
+++ b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/HealthCheckService.java
@@ -84,8 +84,10 @@ public class HealthCheckService {
new HealthCheckHttpContext(config.get(CONFIG_AUTH_REALM)));
registered = true;
} else {
- httpService.unregister("/health/check");
- registered = false;
+ if (registered) {
+ httpService.unregister("/health/check");
+ registered = false;
+ }
LOGGER.info("Healthcheck service is disabled");
}
}
diff --git a/extensions/json-schema/pom.xml b/extensions/json-schema/pom.xml
index ec244bfb7..fe520c249 100644
--- a/extensions/json-schema/pom.xml
+++ b/extensions/json-schema/pom.xml
@@ -32,6 +32,7 @@
<modules>
<module>services</module>
<module>rest</module>
+ <module>shell-commands</module>
</modules>
</project>
diff --git a/extensions/json-schema/services/pom.xml b/extensions/json-schema/services/pom.xml
index b4ed3dbe0..710a7d1a0 100644
--- a/extensions/json-schema/services/pom.xml
+++ b/extensions/json-schema/services/pom.xml
@@ -42,6 +42,7 @@
<properties>
<version.schema>1.0.86</version.schema>
+ <version.schema.jackson>2.17.1</version.schema.jackson>
<version.schema.itu>1.7.0</version.schema.itu>
</properties>
@@ -56,12 +57,21 @@
<artifactId>unomi-persistence-spi</artifactId>
<scope>provided</scope>
</dependency>
-
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-services-common</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.osgi</groupId>
<artifactId>osgi.core</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.osgi</groupId>
+ <artifactId>org.osgi.service.event</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>commons-io</groupId>
@@ -74,6 +84,11 @@
<artifactId>commons-lang3</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>commons-beanutils</groupId>
+ <artifactId>commons-beanutils</artifactId>
+ <scope>provided</scope>
+ </dependency>
<!-- dependencies required for json-schema framework -->
<dependency>
@@ -121,6 +136,61 @@
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.karaf.jaas</groupId>
+ <artifactId>org.apache.karaf.jaas.boot</artifactId>
+ <version>${karaf.version}</version>
+ <scope>provided</scope>
+ </dependency>
+
+ <!-- Testing dependencies -->
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <version>4.11.0</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-services</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-services</artifactId>
+ <type>test-jar</type>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.datatype</groupId>
+ <artifactId>jackson-datatype-jsr310</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-simple</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.module</groupId>
+ <artifactId>jackson-module-jaxb-annotations</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-metrics</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-common</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git a/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/api/JsonSchemaWrapper.java b/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/api/JsonSchemaWrapper.java
index ca175558f..16ef9443e 100644
--- a/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/api/JsonSchemaWrapper.java
+++ b/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/api/JsonSchemaWrapper.java
@@ -21,6 +21,7 @@ import org.apache.unomi.api.Item;
import org.apache.unomi.api.TimestampedItem;
import java.util.Date;
+import java.util.Objects;
/**
* Object which represents a JSON schema, it's a wrapper because it contains some additional info used by the
@@ -97,4 +98,17 @@ public class JsonSchemaWrapper extends Item implements TimestampedItem {
public Date getTimeStamp() {
return timeStamp;
}
-}
\ No newline at end of file
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof JsonSchemaWrapper)) return false;
+ if (!super.equals(o)) return false;
+ JsonSchemaWrapper that = (JsonSchemaWrapper) o;
+ return Objects.equals(schema, that.schema) && Objects.equals(target, that.target) && Objects.equals(name, that.name) && Objects.equals(extendsSchemaId, that.extendsSchemaId) && Objects.equals(timeStamp, that.timeStamp);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(super.hashCode(), schema, target, name, extendsSchemaId, timeStamp);
+ }
+}
diff --git a/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/api/SchemaService.java b/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/api/SchemaService.java
index f526ebda6..f7ed8bd49 100644
--- a/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/api/SchemaService.java
+++ b/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/api/SchemaService.java
@@ -119,21 +119,6 @@ public interface SchemaService {
*/
boolean deleteSchema(String schemaId);
- /**
- * Load a predefined schema into memory
- *
- * @param schemaStream inputStream of the schema
- */
- void loadPredefinedSchema(InputStream schemaStream) throws IOException;
-
- /**
- * Unload a predefined schema into memory
- *
- * @param schemaStream inputStream of the schema to delete
- * @return true if the schema has been deleted
- */
- boolean unloadPredefinedSchema(InputStream schemaStream);
-
/**
* Refresh the JSON schemas
*/
diff --git a/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/impl/SchemaServiceImpl.java b/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/impl/SchemaServiceImpl.java
index da7efeac2..5a2cd0926 100644
--- a/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/impl/SchemaServiceImpl.java
+++ b/extensions/json-schema/services/src/main/java/org/apache/unomi/schema/impl/SchemaServiceImpl.java
@@ -27,15 +27,24 @@ import com.networknt.schema.*;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.unomi.api.Item;
+import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.security.SecurityServiceConfiguration;
+import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.api.services.ScopeService;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.api.tenants.TenantService;
+import org.apache.unomi.api.services.cache.CacheableTypeConfig;
import org.apache.unomi.persistence.spi.PersistenceService;
import org.apache.unomi.schema.api.JsonSchemaWrapper;
import org.apache.unomi.schema.api.SchemaService;
import org.apache.unomi.schema.api.ValidationError;
import org.apache.unomi.schema.api.ValidationException;
import org.apache.unomi.schema.keyword.ScopeKeyword;
+import org.apache.unomi.services.common.cache.AbstractMultiTypeCachingService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.apache.unomi.api.services.SchedulerService;
+import org.apache.unomi.api.tasks.ScheduledTask;
import java.io.IOException;
import java.io.InputStream;
@@ -43,8 +52,14 @@ import java.net.URI;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
+import java.util.function.Predicate;
-public class SchemaServiceImpl implements SchemaService {
+import static org.apache.unomi.api.tenants.TenantService.SYSTEM_TENANT;
+
+/**
+ * Implementation of the SchemaService using the AbstractMultiTypeCachingService
+ */
+public class SchemaServiceImpl extends AbstractMultiTypeCachingService implements SchemaService {
private static final String URI = "https://json-schema.org/draft/2019-09/schema";
@@ -55,30 +70,86 @@ public class SchemaServiceImpl implements SchemaService {
ObjectMapper objectMapper = new ObjectMapper();
- /**
- * Schemas provided by Unomi runtime bundles in /META-INF/cxs/schemas/...
- */
- private final ConcurrentMap<String, JsonSchemaWrapper> predefinedUnomiJSONSchemaById = new ConcurrentHashMap<>();
- /**
- * All Unomi schemas indexed by URI
- */
- private ConcurrentMap<String, JsonSchemaWrapper> schemasById = new ConcurrentHashMap<>();
- /**
- * Available extensions indexed by key:schema URI to be extended, value: list of schema extension URIs
+ /**
+ * Available extensions indexed by tenant ID, then by schema URI to be extended, then list of schema extension URIs
*/
- private ConcurrentMap<String, Set<String>> extensions = new ConcurrentHashMap<>();
+ private Map<String, Map<String, Set<String>>> extensionsByTenant = new ConcurrentHashMap<>();
private Integer jsonSchemaRefreshInterval = 1000;
- private ScheduledFuture<?> scheduledFuture;
- private PersistenceService persistenceService;
private ScopeService scopeService;
- private JsonSchemaFactory jsonSchemaFactory;
+ // Map to store tenant-specific JsonSchemaFactory instances
+ private final ConcurrentMap<String, JsonSchemaFactory> tenantJsonSchemaFactories = new ConcurrentHashMap<>();
- // TODO UNOMI-572: when fixing UNOMI-572 please remove the usage of the custom ScheduledExecutorService and re-introduce the Unomi Scheduler Service
- private ScheduledExecutorService scheduler;
- //private SchedulerService schedulerService;
+ @Override
+ protected Set<CacheableTypeConfig<?>> getTypeConfigs() {
+ Set<CacheableTypeConfig<?>> configs = new HashSet<>();
+
+ // Track extension changes per tenant for efficient processing
+ ConcurrentMap<String, Boolean> tenantExtensionChanges = new ConcurrentHashMap<>();
+
+ // JsonSchemaWrapper configuration with both tenant-specific and global callbacks
+ configs.add(CacheableTypeConfig.<JsonSchemaWrapper>builder(JsonSchemaWrapper.class,
+ JsonSchemaWrapper.ITEM_TYPE,
+ "schemas")
+ .withInheritFromSystemTenant(true)
+ .withPredefinedItems(true)
+ .withRequiresRefresh(true)
+ .withRefreshInterval(jsonSchemaRefreshInterval)
+ .withIdExtractor(JsonSchemaWrapper::getItemId)
+ // Add stream processor for JsonSchemaWrapper
+ .withStreamProcessor((bundleContext, url, inputStream) -> {
+ try {
+ // Use the same logic as loadPredefinedSchema
+ String schema = IOUtils.toString(inputStream);
+ JsonSchemaWrapper jsonSchemaWrapper = buildJsonSchemaWrapper(schema);
+ jsonSchemaWrapper.setTenantId(SYSTEM_TENANT);
+ return jsonSchemaWrapper;
+ } catch (IOException e) {
+ LOGGER.error("Error processing schema from {}", url, e);
+ return null;
+ }
+ })
+ .withBundleItemProcessor((bundleContext, jsonSchemaWrapper) -> {
+ contextManager.executeAsSystem(() -> {
+ persistenceService.save(jsonSchemaWrapper);
+ });
+ })
+ // Efficient tenant-specific processing
+ .withTenantRefreshCallback((tenantId, oldTenantState, newTenantState) -> {
+ // Process tenant-specific changes efficiently
+ boolean tenantChanges = !oldTenantState.equals(newTenantState);
+
+ if (tenantChanges) {
+ LOGGER.debug("Schema changes detected for tenant: {}", tenantId);
+
+ // Track that this tenant had changes (for global callback)
+ tenantExtensionChanges.put(tenantId, true);
+
+ // Refresh specific tenant JsonSchemaFactory
+ tenantJsonSchemaFactories.put(tenantId, createJsonSchemaFactory());
+ }
+ })
+ // Global callback for cross-tenant operations like extensions
+ .withPostRefreshCallback((oldState, newState) -> {
+ // Only process global changes if any tenant had changes
+ if (!tenantExtensionChanges.isEmpty()) {
+ // Initialize extensions and regenerate factories
+ refreshSchemaExtensionsAndFactories(newState);
+
+ // Log the affected tenants
+ LOGGER.debug("Schema changes processed for tenants: {}",
+ String.join(", ", tenantExtensionChanges.keySet()));
+
+ // Clear the change tracker for next time
+ tenantExtensionChanges.clear();
+ }
+ })
+ .build());
+
+ return configs;
+ }
@Override
public boolean isValid(String data, String schemaId) {
@@ -157,18 +228,24 @@ public class SchemaServiceImpl implements SchemaService {
@Override
public JsonSchemaWrapper getSchema(String schemaId) {
- return schemasById.get(schemaId);
+ return getItem(schemaId, JsonSchemaWrapper.class);
}
@Override
public Set<String> getInstalledJsonSchemaIds() {
- return schemasById.keySet();
+ Set<String> schemaIds = new HashSet<>();
+
+ getAllItems(JsonSchemaWrapper.class, true).forEach(schema -> {
+ schemaIds.add(schema.getItemId());
+ });
+ return schemaIds;
}
@Override
public List<JsonSchemaWrapper> getSchemasByTarget(String target) {
- return schemasById.values().stream()
- .filter(jsonSchemaWrapper -> jsonSchemaWrapper.getTarget() != null && jsonSchemaWrapper.getTarget().equals(target))
+ return getAllItems(JsonSchemaWrapper.class, true).stream().filter(jsonSchemaWrapper ->
+ jsonSchemaWrapper.getTarget() != null &&
+ jsonSchemaWrapper.getTarget().equals(target))
.collect(Collectors.toList());
}
@@ -178,48 +255,113 @@ public class SchemaServiceImpl implements SchemaService {
throw new ValidationException("eventType missing");
}
- return schemasById.values().stream()
- .filter(jsonSchemaWrapper ->
- jsonSchemaWrapper.getTarget() != null &&
- jsonSchemaWrapper.getTarget().equals(TARGET_EVENTS) &&
- jsonSchemaWrapper.getName() != null &&
- jsonSchemaWrapper.getName().equals(eventType))
- .findFirst()
- .orElseThrow(() -> new ValidationException("Schema not found for event type: " + eventType));
+ // Get current tenant ID
+ String currentTenant = contextManager.getCurrentContext().getTenantId();
+
+ // First filter to find schemas that match the event type
+ Predicate<JsonSchemaWrapper> eventTypeFilter = jsonSchemaWrapper ->
+ jsonSchemaWrapper.getTarget() != null &&
+ jsonSchemaWrapper.getTarget().equals(TARGET_EVENTS) &&
+ jsonSchemaWrapper.getName() != null &&
+ jsonSchemaWrapper.getName().equals(eventType);
+
+ // First look in the current tenant
+ Optional<JsonSchemaWrapper> tenantSchema = getAllItems(JsonSchemaWrapper.class, false).stream()
+ .filter(eventTypeFilter)
+ .findFirst();
+
+ // If found in current tenant, return it
+ if (tenantSchema.isPresent()) {
+ return tenantSchema.get();
+ }
+
+ // If not in system tenant, also try system tenant (if current tenant isn't already system)
+ if (!SYSTEM_TENANT.equals(currentTenant)) {
+ // Execute as system tenant to get system tenant schemas
+ try {
+ return contextManager.executeAsSystem(() -> {
+ Optional<JsonSchemaWrapper> systemSchema = getAllItems(JsonSchemaWrapper.class, false).stream()
+ .filter(eventTypeFilter)
+ .findFirst();
+
+ if (systemSchema.isPresent()) {
+ return systemSchema.get();
+ }
+
+ throw new RuntimeException(new ValidationException("Schema not found for event type: " + eventType));
+ });
+ } catch (RuntimeException e) {
+ if (e.getCause() instanceof ValidationException) {
+ throw (ValidationException) e.getCause();
+ }
+ throw e;
+ }
+ }
+
+ throw new ValidationException("Schema not found for event type: " + eventType);
}
@Override
public void saveSchema(String schema) {
+ contextManager.getCurrentContext().validateAccess(SecurityServiceConfiguration.PERMISSION_SCHEMA_WRITE);
JsonSchemaWrapper jsonSchemaWrapper = buildJsonSchemaWrapper(schema);
- if (!predefinedUnomiJSONSchemaById.containsKey(jsonSchemaWrapper.getItemId())) {
- persistenceService.save(jsonSchemaWrapper);
- } else {
- throw new IllegalArgumentException("Trying to save a Json Schema that is using the ID of an existing Json Schema provided by Unomi is forbidden");
- }
+ String currentTenant = contextManager.getCurrentContext().getTenantId();
+ jsonSchemaWrapper.setTenantId(currentTenant);
+
+ // Save the item to persistence and cache
+ saveItem(jsonSchemaWrapper, JsonSchemaWrapper::getItemId, JsonSchemaWrapper.ITEM_TYPE);
+
+ // Refresh schema extensions and factories
+ refreshSchemaExtensionsAndFactories(null);
+
+ LOGGER.debug("Schema saved and factories regenerated for: {}", jsonSchemaWrapper.getItemId());
}
@Override
public boolean deleteSchema(String schemaId) {
- // forbidden to delete predefined Unomi schemas
- if (!predefinedUnomiJSONSchemaById.containsKey(schemaId)) {
- // remove persisted schema
- return persistenceService.remove(schemaId, JsonSchemaWrapper.class);
- }
- return false;
- }
+ contextManager.getCurrentContext().validateAccess(SecurityServiceConfiguration.PERMISSION_SCHEMA_DELETE);
+ final String tenantId = contextManager.getCurrentContext().getTenantId();
- @Override
- public void loadPredefinedSchema(InputStream schemaStream) throws IOException {
- String schema = IOUtils.toString(schemaStream);
- JsonSchemaWrapper jsonSchemaWrapper = buildJsonSchemaWrapper(schema);
- predefinedUnomiJSONSchemaById.put(jsonSchemaWrapper.getItemId(), jsonSchemaWrapper);
+ // Remove the item from persistence and cache
+ removeItem(schemaId, JsonSchemaWrapper.class, JsonSchemaWrapper.ITEM_TYPE);
+
+ // Refresh schema extensions and factories
+ refreshSchemaExtensionsAndFactories(null);
+
+ LOGGER.debug("Schema deleted and factories regenerated for: {}", schemaId);
+ return true;
}
- @Override
- public boolean unloadPredefinedSchema(InputStream schemaStream) {
- JsonNode schemaNode = jsonSchemaFactory.getSchema(schemaStream).getSchemaNode();
- String schemaId = schemaNode.get("$id").asText();
- return predefinedUnomiJSONSchemaById.remove(schemaId) != null;
+ /**
+ * Collects all schemas from all tenants into a map structure needed by initExtensions.
+ *
+ * @return A map of tenant IDs to a map of schema IDs to schemas
+ */
+ private Map<String, Map<String, JsonSchemaWrapper>> collectAllSchemas() {
+ Map<String, Map<String, JsonSchemaWrapper>> allSchemas = new HashMap<>();
+
+ // Get all tenants
+ Set<String> tenants = new HashSet<>();
+ tenantService.getAllTenants().forEach(tenant -> tenants.add(tenant.getItemId()));
+ tenants.add(SYSTEM_TENANT);
+
+ // Collect schemas for each tenant
+ for (String tenantId : tenants) {
+ Map<String, JsonSchemaWrapper> tenantSchemas = new HashMap<>();
+
+ contextManager.executeAsTenant(tenantId, () -> {
+ Collection<JsonSchemaWrapper> schemas = getAllItems(JsonSchemaWrapper.class, false);
+ for (JsonSchemaWrapper schema : schemas) {
+ tenantSchemas.put(schema.getItemId(), schema);
+ }
+ });
+
+ if (!tenantSchemas.isEmpty()) {
+ allSchemas.put(tenantId, tenantSchemas);
+ }
+ }
+
+ return allSchemas;
}
private Set<ValidationError> validate(JsonNode jsonNode, JsonSchema jsonSchema) throws ValidationException {
@@ -239,6 +381,7 @@ public class SchemaServiceImpl implements SchemaService {
.collect(Collectors.toSet()) :
Collections.emptySet();
} catch (Exception e) {
+ LOGGER.debug("Unexpected error while validating schema :", e);
throw new ValidationException("Unexpected error while validating", e);
}
}
@@ -256,7 +399,13 @@ public class SchemaServiceImpl implements SchemaService {
private JsonSchema getJsonSchema(String schemaId) throws ValidationException {
try {
- JsonSchema jsonSchema = jsonSchemaFactory.getSchema(new URI(schemaId));
+ // Get current tenant ID
+ String currentTenant = contextManager.getCurrentContext().getTenantId();
+ // Get or create JsonSchemaFactory for this tenant
+ JsonSchemaFactory factory = tenantJsonSchemaFactories.computeIfAbsent(currentTenant,
+ k -> createJsonSchemaFactory());
+
+ JsonSchema jsonSchema = factory.getSchema(new URI(schemaId));
if (jsonSchema != null) {
return jsonSchema;
} else {
@@ -278,7 +427,12 @@ public class SchemaServiceImpl implements SchemaService {
}
private JsonSchemaWrapper buildJsonSchemaWrapper(String schema) {
- JsonSchema jsonSchema = jsonSchemaFactory.getSchema(schema);
+ // Get current tenant ID and its factory
+ String currentTenant = contextManager.getCurrentContext().getTenantId();
+ JsonSchemaFactory factory = tenantJsonSchemaFactories.computeIfAbsent(currentTenant,
+ k -> createJsonSchemaFactory());
+
+ JsonSchema jsonSchema = factory.getSchema(schema);
JsonNode schemaNode = jsonSchema.getSchemaNode();
... 46603 lines suppressed ...