From 94b561a0a412bb00cf84be34f3ce40176a509ce5 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Mon, 15 Jun 2026 15:05:19 +0200 Subject: [PATCH 01/14] pipeline --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 198ed92..a90f03a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,7 +9,7 @@ permissions: on: workflow_dispatch: pull_request: - branches: [main] + branches: [main, review-fixes] types: [reopened, synchronize, opened] jobs: From ba157d2aa7322ac7c04fa5a6c16e0ca4314448b3 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Mon, 15 Jun 2026 15:05:36 +0200 Subject: [PATCH 02/14] refactor(ai-core): migrate AICoreService to RemoteService (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make cache for entitiesWithoutPredictionsPerTenant tenant specific * refactor(ai-core): make AICoreService tenant-agnostic and DI-friendly - Replace resourceGroupForTenant(String) with resourceGroup() on the public AICoreService interface. The implementation reads the tenant from the current RequestContext internally. - Remove isMultiTenancyEnabled() and getRetry() from the public interface; they remain accessible on AbstractAICoreService for internal callers. - Remove the CDS function 'resourceGroupForTenant' from index.cds and its action handler. - Detect multi-tenancy via standard CAP Java cds.multiTenancy.sidecar.url property and DeploymentService presence instead of custom flag. - Update RecommendationClientResolver to drop tenantId parameter. - Update samples, tests, and javadoc accordingly. Addresses review comments from PR #49 (Issue 2). * feat(ai-core): restrict AICore entity APIs to current tenant - Add tenant ownership verification on ResourceGroupHandler for READ (by-key), UPDATE, and DELETE operations. Returns 404 if the resource group belongs to a different tenant. - Scope list queries (READ without key) to the current tenant's resource groups via the tenant label filter in multi-tenancy mode. - Add ensureResourceGroupAccessible() guard to DeploymentHandler and ConfigurationHandler, validating the addressed resource group belongs to the current tenant before forwarding to AI Core. - Provider/system users are exempt from tenant restrictions and can access all resource groups (useful for ops/debug scenarios). - Add isProviderUser() and currentTenantId() as public helpers on AbstractAICoreService for use by handler classes. Addresses review comments from PR #49 (Issue 3a). * chore(ai-core): rename config namespace to cds.ai.core - Rename all configuration properties from cds.requires.AICore.* to cds.ai.core.* to align with CAP Java property naming conventions. - Rename cds.requires.recommendations.contextRowLimit to cds.ai.recommendations.contextRowLimit. - Drop the cds.requires.AICore.multiTenancy flag entirely; multi- tenancy is now auto-detected from standard CAP Java properties. - Update README with new configuration namespace and examples. Addresses review comments from PR #49 (Issue 3b). * fix(ai-core): handle null tenant in resourceGroupForTenant When resourceGroupForTenant is called with a null tenantId (which happens when currentTenantId() returns null in single-tenant or non-tenant-scoped RequestContexts), fall back to the default resource group instead of passing null to the Caffeine cache (which throws NPE). This fixes integration test failures in the CI pipeline where the ApplicationServiceDelegation and Recommendation tests run without an explicit tenant in the RequestContext. * fix(ci): cleanup all run attempts and cds-itest resource groups The cleanup step previously only deleted resource groups matching the exact current run_id AND run_attempt. When a run failed and was re-run, the previous attempt's resource groups were never cleaned up, eventually hitting the AI Core resource group limit (50). Changes: - Match prefix 'itest-{run_id}-' (all attempts) instead of the exact 'itest-{run_id}-{run_attempt}' string. - Same for 'sonar-{run_id}-' prefix. - Also delete 'cds-itest-' prefixed resource groups which are created by the multi-tenancy integration tests via resourceGroupForTenant() and were never cleaned up by the pipeline. * fix(itest): align config namespace with cds.ai.core rename The source code (commit c30080b) renamed properties from cds.requires.AICore.* to cds.ai.core.*, but the integration test application.yaml files were not updated. This meant the CDS_AICORE_TEST_RESOURCE_GROUP env var set by CI was silently ignored and tests always ran against the literal default resource group. - spring/application.yaml: cds.requires.AICore -> cds.ai.core - mtx-local/application.yaml: remove obsolete cds.requires.AICore.multiTenancy (now auto-detected from cds.multi-tenancy.sidecar.url) * test(ai-core): add unit tests for tenant scoping and mock service Cover new code paths introduced by the tenant-scoping branch: - TenantScopingTest (7 tests): exercises every branch of AbstractCrudHandler.ensureResourceGroupAccessible() — provider bypass, single-tenancy bypass, null tenant, matching/non-matching labels, 404. - MockAICoreServiceImplTest (9 tests): both constructors, MT enabled/disabled, resourceGroupForTenant, cache isolation, clearTenantCache, getRetry, config property reads. - AICoreServiceImplDeploymentIdTest (+2 tests): resourceGroupForTenant(null) returns default even with MT enabled; single-tenancy always returns default. * chore(recommendations): add TODO for model-changed integration test Document the missing E2E coverage for RecommendationModelChangedHandler. The proper test requires an extensibility-enabled sidecar with extension JSON that adds prediction columns — not yet set up in mtx-local. The cache-invalidation logic itself is covered by the existing unit test FioriRecommendationHandlerTest.invalidateTenant_removesOnlyThatTenantsEntries. * update cleanup * fix(ci): scope resource group cleanup to own job only Each parallel CI job (Java 17, Java 21, SonarQube) was using broad prefixes in its cleanup step, deleting resource groups belonging to sibling jobs still in progress. This caused intermittent 403 Forbidden errors when the affected jobs tried to use their now-deleted resource groups. Narrow the cleanup prefixes so each job only deletes its own: - integration-tests: itest-{run_id}-{attempt}-j{version}* - scan-with-sonar: sonar-{run_id}-{attempt}* Both still clean up itest-rg-* (ResourceGroupTest leftovers). * test(ai-core): add unit tests for uncovered code paths - DeploymentHandler: test onCreate (with/without TTL) and onUpdate happy path (targetStatus and configurationId branches) - ResourceGroupHandler: test onUpdate with/without labels, buildTenantLabelSelector branches (tenantId filter, MT non-provider, MT null tenant, single tenancy), ensureOwnedByCurrentTenant branches (provider, single tenant, wrong tenant, matching tenant) - AICoreServiceConfiguration: test eventHandlers() MockAICoreServiceImpl branch (with and without multi-tenancy), test detectMultiTenancy via services() for sidecarUrl branch and no-MT fallback * fix(ci): include cds-itest- prefix in resource group cleanup The MultiTenancyTest creates per-tenant resource groups with names like cds-itest-mt-a-{timestamp} (from resourceGroupPrefix 'cds-' + tenant name 'itest-*'). These are unique per test run (timestamped) and safe to clean up from any job without cross-job interference. * refactor(ai-core): migrate AICoreService from CqnService to RemoteService * refactor(ai-core): delete AICoreApplicationServiceHandler * fix(ai-core): guard service registration on AICore model presence * fix(test): mock CdsModel in unit tests and restore AICore model import * fix(ai-core): extend AbstractCdsDefinedService for proper RemoteService support * fix(recommendations): promote cds-services-impl to compile scope * refactor(test): use real CdsRuntime in AICoreServiceConfigurationTest * refactor(ai-core): remove AICORE_SERVICE_KEY env var check from binding detection * chore: exclude Mock* classes from SonarQube coverage * refactor(test): use real CdsRuntime in AICoreServiceImplDeploymentIdTest * fix: remove duplicate detectMultiTenancy method from merge --------- Co-authored-by: Lisa Julia Nebel --- cds-feature-ai-core/pom.xml | 9 +- .../cds/feature/aicore/api/AICoreService.java | 6 +- .../core/AICoreServiceConfiguration.java | 30 +-- .../aicore/core/AbstractAICoreService.java | 10 +- .../AICoreApplicationServiceHandler.java | 103 ---------- .../core/AICoreServiceConfigurationTest.java | 192 +++++------------- .../AICoreServiceImplDeploymentIdTest.java | 86 ++++---- .../core/MockAICoreServiceImplTest.java | 8 + cds-feature-recommendations/pom.xml | 1 - .../ApplicationServiceDelegationTest.java | 41 ---- integration-tests/spring/test-service.cds | 5 +- pom.xml | 3 + 12 files changed, 139 insertions(+), 355 deletions(-) delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java delete mode 100644 integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java diff --git a/cds-feature-ai-core/pom.xml b/cds-feature-ai-core/pom.xml index c49b701..ac235b8 100644 --- a/cds-feature-ai-core/pom.xml +++ b/cds-feature-ai-core/pom.xml @@ -44,12 +44,19 @@ com.sap.cds cds-services-impl - test ${project.artifactId} + + + src/test/resources + + + src/gen/srv/src/main/resources + + com.sap.cds diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java index 40374bb..2955b33 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java @@ -3,7 +3,7 @@ */ package com.sap.cds.feature.aicore.api; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; /** @@ -25,10 +25,10 @@ *

The implementation is tenant-aware: it reads the current tenant from the {@code * RequestContext}. Callers do not need to pass tenant identifiers explicitly. */ -public interface AICoreService extends CqnService { +public interface AICoreService extends RemoteService { /** Default service name under which an instance is registered in the service catalog. */ - String DEFAULT_NAME = "AICore"; + String DEFAULT_NAME = "AICore$Default"; /** Qualified name of the {@code resourceGroups} entity exposed by this service. */ String RESOURCE_GROUPS = "AICore.resourceGroups"; diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index bfd4c8c..43d9127 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -8,7 +8,6 @@ import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler; import com.sap.cds.feature.aicore.core.handler.DeploymentHandler; @@ -37,19 +36,17 @@ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { private static final Logger logger = LoggerFactory.getLogger(AICoreServiceConfiguration.class); + private static boolean hasAICoreModel(CdsRuntime runtime) { + return runtime.getCdsModel().findService("AICore").isPresent(); + } + private static boolean hasAICoreBinding(CdsRuntime runtime) { - boolean hasServiceBinding = - runtime - .getEnvironment() - .getServiceBindings() - .filter(b -> ServiceBindingUtils.matches(b, "aicore")) - .findFirst() - .isPresent(); - if (hasServiceBinding) { - return true; - } - String envKey = System.getenv("AICORE_SERVICE_KEY"); - return envKey != null && !envKey.isBlank(); + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); } /** @@ -70,6 +67,11 @@ private static boolean detectMultiTenancy(CdsRuntime runtime) { public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); + if (!hasAICoreModel(runtime)) { + logger.debug("AICore CDS model not found in runtime model — skipping service registration."); + return; + } + boolean hasBinding = hasAICoreBinding(runtime); boolean multiTenancyEnabled = detectMultiTenancy(runtime); @@ -106,7 +108,6 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler(new DeploymentHandler(service)); configurer.eventHandler(new ConfigurationHandler(service)); configurer.eventHandler(new ActionHandler(service)); - configurer.eventHandler(new AICoreApplicationServiceHandler(service)); logger.debug("Registered Prod AI-Core Implementation"); if (service.isMultiTenancyEnabled()) { @@ -115,7 +116,6 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { } } else if (registered instanceof MockAICoreServiceImpl mockService) { configurer.eventHandler(new MockEntityHandler()); - configurer.eventHandler(new AICoreApplicationServiceHandler(mockService)); if (mockService.isMultiTenancyEnabled()) { configurer.eventHandler(new MockAICoreSetupHandler(mockService)); logger.debug("Registered Mock AI-Core Setup Handler for MTX subscribe/unsubscribe."); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java index 0e7ec79..2e4fe7a 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java @@ -4,10 +4,10 @@ package com.sap.cds.feature.aicore.core; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.services.impl.cds.AbstractCdsDefinedService; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.request.UserInfo; import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cds.services.utils.services.AbstractCqnService; import io.github.resilience4j.retry.Retry; import java.util.Map; @@ -16,10 +16,14 @@ * cache access, configuration, and resource group resolution. These methods are not part of the * public {@link AICoreService} contract but are shared between the real and mock implementations. */ -public abstract class AbstractAICoreService extends AbstractCqnService implements AICoreService { +public abstract class AbstractAICoreService extends AbstractCdsDefinedService + implements AICoreService { + + /** The qualified CDS service definition name. */ + private static final String CDS_DEFINITION_NAME = "AICore"; protected AbstractAICoreService(String name, CdsRuntime runtime) { - super(name, runtime); + super(name, CDS_DEFINITION_NAME, runtime); } /** Returns the {@link CdsRuntime} that this service was created with. */ diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java deleted file mode 100644 index 1b340cd..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApplicationServiceHandler.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.core.handler; - -import com.sap.cds.ql.CQL; -import com.sap.cds.ql.cqn.CqnAnalyzer; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.ql.cqn.CqnStructuredTypeRef; -import com.sap.cds.ql.cqn.Modifier; -import com.sap.cds.reflect.CdsEntity; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.services.cds.ApplicationService; -import com.sap.cds.services.cds.CdsCreateEventContext; -import com.sap.cds.services.cds.CdsDeleteEventContext; -import com.sap.cds.services.cds.CdsReadEventContext; -import com.sap.cds.services.cds.CdsUpdateEventContext; -import com.sap.cds.services.cds.CqnService; -import com.sap.cds.services.handler.EventHandler; -import com.sap.cds.services.handler.annotations.HandlerOrder; -import com.sap.cds.services.handler.annotations.On; -import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cds.services.utils.OrderConstants; - -/** - * Intercepts CRUD events on application service entities that are projections on AICore entities - * and delegates them to the AICore service. Without this, the framework would try to forward these - * to the PersistenceService, which fails since AICore entities have no database tables. - */ -@ServiceName(value = "*", type = ApplicationService.class) -public class AICoreApplicationServiceHandler implements EventHandler { - - private final CqnService aiCoreService; - - public AICoreApplicationServiceHandler(CqnService aiCoreService) { - this.aiCoreService = aiCoreService; - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onRead(CdsReadEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - CqnSelect rewritten = CQL.copy(context.getCqn(), entityModifier(sourceEntity)); - context.setResult(aiCoreService.run(rewritten)); - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onCreate(CdsCreateEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onUpdate(CdsUpdateEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); - } - - @On - @HandlerOrder(OrderConstants.On.FEATURE) - public void onDelete(CdsDeleteEventContext context) { - String sourceEntity = resolveAICoreSource(context.getTarget(), context.getModel()); - if (sourceEntity == null) { - return; - } - context.setResult(aiCoreService.run(CQL.copy(context.getCqn(), entityModifier(sourceEntity)))); - } - - private String resolveAICoreSource(CdsEntity entity, CdsModel model) { - if (entity == null || !entity.isProjection()) { - return null; - } - return entity - .query() - .filter(q -> q.from().isRef()) - .map(q -> CqnAnalyzer.create(model).analyze(q).targetEntity()) - .map(CdsEntity::getQualifiedName) - .filter(name -> name.startsWith("AICore.")) - .orElse(null); - } - - private static Modifier entityModifier(String targetEntity) { - return new Modifier() { - @Override - public CqnStructuredTypeRef ref(CqnStructuredTypeRef ref) { - var copy = CQL.copy(ref); - copy.rootSegment().id(targetEntity); - return copy.build(); - } - }; - } -} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index d2feaaa..77f7a2c 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -3,175 +3,75 @@ */ package com.sap.cds.feature.aicore.core; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.assertThat; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.handler.AICoreApplicationServiceHandler; -import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; -import com.sap.cds.services.ServiceCatalog; -import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.environment.CdsProperties; -import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; -import java.util.stream.Stream; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) +/** + * Tests {@link AICoreServiceConfiguration} using a real {@link CdsRuntime} booted with the AICore + * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy + * Mockito mocks. + * + *

Since the test runtime has no service bindings, the configuration always registers a {@link + * MockAICoreServiceImpl} regardless of environment variables. + */ class AICoreServiceConfigurationTest { - @Mock private CdsRuntimeConfigurer configurer; - @Mock private CdsRuntime runtime; - @Mock private CdsEnvironment environment; - @Mock private ServiceCatalog serviceCatalog; - - /** - * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl - * (lines 116-123) with multi-tenancy disabled. - */ @Test - void eventHandlers_mockService_noMultiTenancy_registersBasicHandlers() { - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(runtime.getEnvironment()).thenReturn(environment); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, false); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) - .thenReturn(mockService); - - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.eventHandlers(configurer); - - ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); - verify(configurer, atLeastOnce()).eventHandler(captor.capture()); - - var handlers = captor.getAllValues(); - assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); - assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler); - // No setup handler registered when MT is disabled - assert handlers.stream().noneMatch(h -> h instanceof MockAICoreSetupHandler); + void noBinding_noMultiTenancy_registersMockService() { + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + + assertThat(service).isNotNull().isInstanceOf(MockAICoreServiceImpl.class); + assertThat(((MockAICoreServiceImpl) service).isMultiTenancyEnabled()).isFalse(); } - /** - * Tests the eventHandlers() branch where the registered service is a MockAICoreServiceImpl with - * multi-tenancy enabled — verifies that MockAICoreSetupHandler is registered (lines 119-121). - */ @Test - void eventHandlers_mockService_withMultiTenancy_registersSetupHandler() { - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(runtime.getEnvironment()).thenReturn(environment); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, true); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) - .thenReturn(mockService); - - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.eventHandlers(configurer); - - ArgumentCaptor captor = ArgumentCaptor.forClass(EventHandler.class); - verify(configurer, atLeastOnce()).eventHandler(captor.capture()); - - var handlers = captor.getAllValues(); - assert handlers.stream().anyMatch(h -> h instanceof MockEntityHandler); - assert handlers.stream().anyMatch(h -> h instanceof AICoreApplicationServiceHandler); - assert handlers.stream().anyMatch(h -> h instanceof MockAICoreSetupHandler); - } - - /** - * Tests detectMultiTenancy returning true when sidecar URL is set (the - * `if (sidecarUrl != null && !sidecarUrl.isBlank()) { return true; }` branch). - * This is exercised through services() with no AI Core binding present. - */ - @Test - void services_noBinding_sidecarUrlSet_createsMultiTenantMockService() { - String envKey = System.getenv("AICORE_SERVICE_KEY"); - org.junit.jupiter.api.Assumptions.assumeTrue( - envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getEnvironment()).thenReturn(environment); - when(environment.getServiceBindings()).thenReturn(Stream.empty()); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - CdsProperties cdsProperties = new CdsProperties(); + void noBinding_withSidecarUrl_registersMultiTenantMockService() { + CdsProperties props = new CdsProperties(); CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); sidecar.setUrl("http://localhost:4004"); mt.setSidecar(sidecar); - cdsProperties.setMultiTenancy(mt); - when(environment.getCdsProperties()).thenReturn(cdsProperties); + props.setMultiTenancy(mt); + + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.services(configurer); + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - ArgumentCaptor captor = - ArgumentCaptor.forClass(MockAICoreServiceImpl.class); - verify(configurer).service(captor.capture()); - assert captor.getValue().isMultiTenancyEnabled(); + assertThat(service).isNotNull().isInstanceOf(MockAICoreServiceImpl.class); + assertThat(((MockAICoreServiceImpl) service).isMultiTenancyEnabled()).isTrue(); } - /** - * Tests detectMultiTenancy returning false when no sidecar URL and no DeploymentService. - */ @Test - void services_noBinding_noSidecarUrl_noDeploymentService_singleTenant() { - String envKey = System.getenv("AICORE_SERVICE_KEY"); - org.junit.jupiter.api.Assumptions.assumeTrue( - envKey == null || envKey.isBlank(), "Skipped: AICORE_SERVICE_KEY is set"); - when(configurer.getCdsRuntime()).thenReturn(runtime); - when(runtime.getEnvironment()).thenReturn(environment); - when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(environment.getServiceBindings()).thenReturn(Stream.empty()); - lenient().when(environment.getProperty(any(String.class), any(Class.class), any())) - .thenAnswer(invocation -> invocation.getArgument(2)); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - lenient().when(environment.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - CdsProperties cdsProperties = new CdsProperties(); - CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); - CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); - // No URL set - defaults to null - mt.setSidecar(sidecar); - cdsProperties.setMultiTenancy(mt); - when(environment.getCdsProperties()).thenReturn(cdsProperties); - when(serviceCatalog.getService(any(Class.class), any())).thenReturn(null); + void noModel_skipsServiceRegistration() { + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.services(configurer); + AICoreService service = + runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - ArgumentCaptor captor = - ArgumentCaptor.forClass(MockAICoreServiceImpl.class); - verify(configurer).service(captor.capture()); - assert !captor.getValue().isMultiTenancyEnabled(); + assertThat(service).isNull(); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index f5e3396..4dc62c2 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.aicore.core; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -24,10 +25,14 @@ import com.sap.ai.sdk.core.model.AiDeploymentStatus; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; -import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -52,30 +57,28 @@ class AICoreServiceImplDeploymentIdTest { new ModelDeploymentSpec(SCENARIO, "exec", CONFIG_NAME, List.of(), d -> true); private String cacheKey() { - // Derive via the production helper rather than hardcoding RG + "::" + CONFIG_NAME so a - // change to the cache-key format is caught here instead of silently passing wrong-path. return AICoreServiceImpl.deploymentCacheKey(RG, spec); } + /** Boots a real CdsRuntime with the AICore model and fast retry settings. */ + private static CdsRuntime createTestRuntime() { + TestPropertiesProvider props = new TestPropertiesProvider(); + props.setProperty("cds.ai.core.maxRetries", 1); + props.setProperty("cds.ai.core.initialDelayMs", 1L); + + return CdsRuntimeConfigurer.create(props) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .complete(); + } + @BeforeEach void setUp() { deploymentApi = mock(DeploymentApi.class); configurationApi = mock(ConfigurationApi.class); resourceGroupApi = mock(ResourceGroupApi.class); - AiCoreService sdkService = mock(AiCoreService.class); - - CdsRuntime runtime = mock(CdsRuntime.class); - CdsEnvironment env = mock(CdsEnvironment.class); - when(runtime.getEnvironment()).thenReturn(env); - // Use small retry counts so failures don't slow tests. - when(env.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())) - .thenReturn(1); - when(env.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())) - .thenReturn(1L); - when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); + + CdsRuntime runtime = createTestRuntime(); service = new AICoreServiceImpl( @@ -85,7 +88,7 @@ void setUp() { deploymentApi, configurationApi, resourceGroupApi, - sdkService); + mock(AiCoreService.class)); } @Test @@ -131,15 +134,12 @@ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() { @Test void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() { - // Transient 5xx must NOT invalidate a potentially valid cache entry. The exception is - // propagated so the caller's retry/backoff policy can handle it. service.getResourceGroupDeploymentCache().put(cacheKey(), "still-valid-id"); OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); - org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.deploymentId(RG, spec)) - .isSameAs(serverError); + assertThatThrownBy(() -> service.deploymentId(RG, spec)).isSameAs(serverError); assertThat(service.getResourceGroupDeploymentCache()) .containsEntry(cacheKey(), "still-valid-id"); @@ -186,7 +186,6 @@ void secondCallUsesCachedResult_singleQueryToApi() { assertThat(first).isEqualTo(DEPLOYMENT_ID); assertThat(second).isEqualTo(DEPLOYMENT_ID); - // First call queries, second call hits the cache and only verifies via get. verify(deploymentApi, times(1)) .query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any()); verify(deploymentApi, times(1)).get(RG, DEPLOYMENT_ID); @@ -194,21 +193,12 @@ void secondCallUsesCachedResult_singleQueryToApi() { @Test void resourceGroupForTenant_nullTenantId_returnsDefault() { - // Even with MT enabled, a null tenantId should fall back to the default resource group. - CdsRuntime rtMt = mock(CdsRuntime.class); - CdsEnvironment envMt = mock(CdsEnvironment.class); - when(rtMt.getEnvironment()).thenReturn(envMt); - when(envMt.getProperty(eq("cds.ai.core.maxRetries"), eq(Integer.class), any())).thenReturn(1); - when(envMt.getProperty(eq("cds.ai.core.initialDelayMs"), eq(Long.class), any())).thenReturn(1L); - when(envMt.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("my-default"); - when(envMt.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); + CdsRuntime runtime = createTestRuntime(); AICoreServiceImpl mtService = new AICoreServiceImpl( AICoreService.DEFAULT_NAME, - rtMt, + runtime, true, // multi-tenancy enabled deploymentApi, configurationApi, @@ -216,25 +206,22 @@ void resourceGroupForTenant_nullTenantId_returnsDefault() { mock(AiCoreService.class)); String result = mtService.resourceGroupForTenant(null); - assertThat(result).isEqualTo("my-default"); + assertThat(result).isEqualTo("default"); } @Test void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { - // Single-tenancy always returns default regardless of the tenantId passed. String result = service.resourceGroupForTenant("any-tenant"); assertThat(result).isEqualTo("default"); } @Test void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { - // Empty deployment list → falls through to create path. AiDeploymentList emptyList = mock(AiDeploymentList.class); when(emptyList.getResources()).thenReturn(List.of()); when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) .thenReturn(emptyList); - // Existing config with the same name, so createConfiguration is skipped. AiConfigurationList configList = mock(AiConfigurationList.class); var existingConfig = mock(com.sap.ai.sdk.core.model.AiConfiguration.class); when(existingConfig.getId()).thenReturn("cfg-1"); @@ -258,4 +245,27 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { verify(configurationApi, never()).create(any(), any()); verify(deploymentApi).create(eq(RG), any()); } + + /** Properties provider that allows overriding specific keys for test configuration. */ + private static class TestPropertiesProvider extends SimplePropertiesProvider { + private final Map properties = new HashMap<>(); + + TestPropertiesProvider() { + super(new CdsProperties()); + } + + void setProperty(String key, Object value) { + properties.put(key, value); + } + + @Override + @SuppressWarnings("unchecked") + public T getProperty(String key, Class asClazz, T defaultValue) { + Object value = properties.get(key); + if (value != null && asClazz.isInstance(value)) { + return (T) value; + } + return defaultValue; + } + } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java index a8dd214..0dd1660 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -10,6 +10,8 @@ import static org.mockito.Mockito.when; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.reflect.CdsService; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.Test; @@ -20,6 +22,9 @@ private MockAICoreServiceImpl createService(boolean multiTenancyEnabled) { CdsRuntime runtime = mock(CdsRuntime.class); CdsEnvironment env = mock(CdsEnvironment.class); when(runtime.getEnvironment()).thenReturn(env); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) .thenReturn("test-rg"); when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) @@ -32,6 +37,9 @@ void defaultConstructor_setsMultiTenancyFalse() { CdsRuntime runtime = mock(CdsRuntime.class); CdsEnvironment env = mock(CdsEnvironment.class); when(runtime.getEnvironment()).thenReturn(env); + CdsModel cdsModel = mock(CdsModel.class); + when(runtime.getCdsModel()).thenReturn(cdsModel); + when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) .thenReturn("default"); when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) diff --git a/cds-feature-recommendations/pom.xml b/cds-feature-recommendations/pom.xml index 4d7f75e..b86d41b 100644 --- a/cds-feature-recommendations/pom.xml +++ b/cds-feature-recommendations/pom.xml @@ -49,7 +49,6 @@ com.sap.cds cds-services-impl - test diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java deleted file mode 100644 index c8f1296..0000000 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ApplicationServiceDelegationTest.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.itest; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import org.junit.jupiter.api.Test; -import org.springframework.security.test.context.support.WithMockUser; - -class ApplicationServiceDelegationTest extends BaseIntegrationTest { - - @Test - @WithMockUser(username = "test-user") - void readConfigurations_viaApplicationService() throws Exception { - mockMvc - .perform(get("/odata/v4/TestService/Configurations")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value").isArray()); - } - - @Test - @WithMockUser(username = "test-user") - void readDeployments_viaApplicationService() throws Exception { - mockMvc - .perform(get("/odata/v4/TestService/Deployments")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value").isArray()); - } - - @Test - @WithMockUser(username = "test-user") - void readResourceGroups_viaApplicationService() throws Exception { - mockMvc - .perform(get("/odata/v4/TestService/ResourceGroups")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.value").isArray()); - } -} diff --git a/integration-tests/spring/test-service.cds b/integration-tests/spring/test-service.cds index ecf43c6..232f698 100644 --- a/integration-tests/spring/test-service.cds +++ b/integration-tests/spring/test-service.cds @@ -2,10 +2,7 @@ using {itest} from '../db/schema'; using { AICore } from 'com.sap.cds/ai'; service TestService { - entity Products as projection on itest.Products; - entity Configurations as projection on AICore.configurations; - entity Deployments as projection on AICore.deployments; - entity ResourceGroups as projection on AICore.resourceGroups; + entity Products as projection on itest.Products; } service RecommendationTestService @(requires: 'any') { diff --git a/pom.xml b/pom.xml index ac2ecb8..e8de8c6 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,9 @@ 1.0.0-SNAPSHOT + + **/Mock*.java + 17 From f48c554389a2918f0264e5b246c55e3c7533fa9f Mon Sep 17 00:00:00 2001 From: Marvin L Date: Mon, 15 Jun 2026 15:29:50 +0200 Subject: [PATCH 03/14] refactor(ai-core): typesafe handlers decoupled from service impl (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make cache for entitiesWithoutPredictionsPerTenant tenant specific * refactor(ai-core): make AICoreService tenant-agnostic and DI-friendly - Replace resourceGroupForTenant(String) with resourceGroup() on the public AICoreService interface. The implementation reads the tenant from the current RequestContext internally. - Remove isMultiTenancyEnabled() and getRetry() from the public interface; they remain accessible on AbstractAICoreService for internal callers. - Remove the CDS function 'resourceGroupForTenant' from index.cds and its action handler. - Detect multi-tenancy via standard CAP Java cds.multiTenancy.sidecar.url property and DeploymentService presence instead of custom flag. - Update RecommendationClientResolver to drop tenantId parameter. - Update samples, tests, and javadoc accordingly. Addresses review comments from PR #49 (Issue 2). * feat(ai-core): restrict AICore entity APIs to current tenant - Add tenant ownership verification on ResourceGroupHandler for READ (by-key), UPDATE, and DELETE operations. Returns 404 if the resource group belongs to a different tenant. - Scope list queries (READ without key) to the current tenant's resource groups via the tenant label filter in multi-tenancy mode. - Add ensureResourceGroupAccessible() guard to DeploymentHandler and ConfigurationHandler, validating the addressed resource group belongs to the current tenant before forwarding to AI Core. - Provider/system users are exempt from tenant restrictions and can access all resource groups (useful for ops/debug scenarios). - Add isProviderUser() and currentTenantId() as public helpers on AbstractAICoreService for use by handler classes. Addresses review comments from PR #49 (Issue 3a). * chore(ai-core): rename config namespace to cds.ai.core - Rename all configuration properties from cds.requires.AICore.* to cds.ai.core.* to align with CAP Java property naming conventions. - Rename cds.requires.recommendations.contextRowLimit to cds.ai.recommendations.contextRowLimit. - Drop the cds.requires.AICore.multiTenancy flag entirely; multi- tenancy is now auto-detected from standard CAP Java properties. - Update README with new configuration namespace and examples. Addresses review comments from PR #49 (Issue 3b). * fix(ai-core): handle null tenant in resourceGroupForTenant When resourceGroupForTenant is called with a null tenantId (which happens when currentTenantId() returns null in single-tenant or non-tenant-scoped RequestContexts), fall back to the default resource group instead of passing null to the Caffeine cache (which throws NPE). This fixes integration test failures in the CI pipeline where the ApplicationServiceDelegation and Recommendation tests run without an explicit tenant in the RequestContext. * fix(ci): cleanup all run attempts and cds-itest resource groups The cleanup step previously only deleted resource groups matching the exact current run_id AND run_attempt. When a run failed and was re-run, the previous attempt's resource groups were never cleaned up, eventually hitting the AI Core resource group limit (50). Changes: - Match prefix 'itest-{run_id}-' (all attempts) instead of the exact 'itest-{run_id}-{run_attempt}' string. - Same for 'sonar-{run_id}-' prefix. - Also delete 'cds-itest-' prefixed resource groups which are created by the multi-tenancy integration tests via resourceGroupForTenant() and were never cleaned up by the pipeline. * fix(itest): align config namespace with cds.ai.core rename The source code (commit c30080b) renamed properties from cds.requires.AICore.* to cds.ai.core.*, but the integration test application.yaml files were not updated. This meant the CDS_AICORE_TEST_RESOURCE_GROUP env var set by CI was silently ignored and tests always ran against the literal default resource group. - spring/application.yaml: cds.requires.AICore -> cds.ai.core - mtx-local/application.yaml: remove obsolete cds.requires.AICore.multiTenancy (now auto-detected from cds.multi-tenancy.sidecar.url) * test(ai-core): add unit tests for tenant scoping and mock service Cover new code paths introduced by the tenant-scoping branch: - TenantScopingTest (7 tests): exercises every branch of AbstractCrudHandler.ensureResourceGroupAccessible() — provider bypass, single-tenancy bypass, null tenant, matching/non-matching labels, 404. - MockAICoreServiceImplTest (9 tests): both constructors, MT enabled/disabled, resourceGroupForTenant, cache isolation, clearTenantCache, getRetry, config property reads. - AICoreServiceImplDeploymentIdTest (+2 tests): resourceGroupForTenant(null) returns default even with MT enabled; single-tenancy always returns default. * chore(recommendations): add TODO for model-changed integration test Document the missing E2E coverage for RecommendationModelChangedHandler. The proper test requires an extensibility-enabled sidecar with extension JSON that adds prediction columns — not yet set up in mtx-local. The cache-invalidation logic itself is covered by the existing unit test FioriRecommendationHandlerTest.invalidateTenant_removesOnlyThatTenantsEntries. * update cleanup * fix(ci): scope resource group cleanup to own job only Each parallel CI job (Java 17, Java 21, SonarQube) was using broad prefixes in its cleanup step, deleting resource groups belonging to sibling jobs still in progress. This caused intermittent 403 Forbidden errors when the affected jobs tried to use their now-deleted resource groups. Narrow the cleanup prefixes so each job only deletes its own: - integration-tests: itest-{run_id}-{attempt}-j{version}* - scan-with-sonar: sonar-{run_id}-{attempt}* Both still clean up itest-rg-* (ResourceGroupTest leftovers). * test(ai-core): add unit tests for uncovered code paths - DeploymentHandler: test onCreate (with/without TTL) and onUpdate happy path (targetStatus and configurationId branches) - ResourceGroupHandler: test onUpdate with/without labels, buildTenantLabelSelector branches (tenantId filter, MT non-provider, MT null tenant, single tenancy), ensureOwnedByCurrentTenant branches (provider, single tenant, wrong tenant, matching tenant) - AICoreServiceConfiguration: test eventHandlers() MockAICoreServiceImpl branch (with and without multi-tenancy), test detectMultiTenancy via services() for sidecarUrl branch and no-MT fallback * fix(ci): include cds-itest- prefix in resource group cleanup The MultiTenancyTest creates per-tenant resource groups with names like cds-itest-mt-a-{timestamp} (from resourceGroupPrefix 'cds-' + tenant name 'itest-*'). These are unique per test run (timestamped) and safe to clean up from any job without cross-job interference. * refactor(ai-core): migrate AICoreService from CqnService to RemoteService * refactor(ai-core): delete AICoreApplicationServiceHandler * fix(ai-core): guard service registration on AICore model presence * fix(test): mock CdsModel in unit tests and restore AICore model import * fix(ai-core): extend AbstractCdsDefinedService for proper RemoteService support * fix(recommendations): promote cds-services-impl to compile scope * refactor(test): use real CdsRuntime in AICoreServiceConfigurationTest * refactor(ai-core): remove AICORE_SERVICE_KEY env var check from binding detection * chore: exclude Mock* classes from SonarQube coverage * refactor(test): use real CdsRuntime in AICoreServiceImplDeploymentIdTest * fix: remove duplicate detectMultiTenancy method from merge * refactor(ai-core): rename DEFAULT_NAME to AICoreService$Default Follow standard CAP Java naming convention for service instances (ServiceInterface$Default). The CDS definition name stays 'AICore' (matching the CDS model); only the registered instance name changes. * refactor(ai-core): define EventContext subinterfaces for programmatic API Add typed EventContext interfaces in the api package for the three programmatic API methods: - DeploymentIdContext: for deploymentId(resourceGroupId, spec) - InferenceClientContext: for inferenceClient(resourceGroupId, deploymentId) - ResourceGroupContext: for resourceGroup() These enable the idiomatic CAP pattern where service methods emit events and ON handlers provide the implementation, allowing extensibility via @Before/@After hooks. * refactor(ai-core): make service API methods emit events; move logic to handler Apply idiomatic CAP Java pattern: service methods create typed EventContext, emit(), and return result. A separately-registered AICoreApiHandler provides the ON implementation with the actual business logic. - AICoreServiceImpl.deploymentId/inferenceClient/resourceGroup/ resourceGroupForTenant now emit typed contexts instead of doing work directly - New AICoreApiHandler handles DeploymentIdContext, InferenceClientContext, ResourceGroupContext with all caching, retry, and SDK logic - ResourceGroupContext extended with optional tenantId for explicit-tenant path - AICoreServiceConfiguration registers AICoreApiHandler - AICoreServiceImpl retains shared state (caches, config, APIs) accessed by handlers via EventContext.getService() This enables extensibility: apps can register @Before/@After handlers on deploymentId, inferenceClient, and resourceGroup events. * refactor(ai-core): decouple CRUD handlers from service impl; use typed contexts - Remove AICoreServiceImpl field from AbstractCrudHandler; handlers now obtain the service from EventContext.getService() at invocation time. - Pass SDK API clients (DeploymentApi, ResourceGroupApi, ConfigurationApi) directly via constructor injection — stateless clients don't need the service reference. - ActionHandler uses generated DeploymentsStopContext instead of raw EventContext with string-based key extraction. - All handlers use generated entity name constants (Deployments_.CDS_NAME, ResourceGroups_.CDS_NAME, Configurations_.CDS_NAME) instead of hand-written strings. - Update AICoreServiceConfiguration to pass API clients to handler constructors. - Update all handler unit tests for the new constructor signatures and EventContext-based service access pattern. Addresses issue #70: typesafe handlers decoupled from service impl. * refactor(ai-core): remove redundant event params from handler annotations * test(ai-core): rewrite handler tests to use real CdsRuntime Replace heavily-mocked unit tests with integration-style tests that boot a real CdsRuntime, register real handlers, and dispatch CQN through the full handler pipeline. Only SDK API clients remain mocked. - DeploymentHandlerTest: tests CREATE, UPDATE via service.run() - ConfigurationHandlerTest: tests READ, CREATE via service.run() - ResourceGroupHandlerTest: tests CRUD + MT label filtering - TenantScopingTest: tests tenant isolation through actual CQN operations with different RequestContext tenants * refactor(ai-core): extract AICoreConfig and AICoreClients Immutable record for config values and holder for SDK API clients. * refactor(ai-core): extract DeploymentResolver Encapsulates caches, locks, retry, and validation behind resolveResourceGroup, resolveDeployment, invalidateTenant. * refactor(ai-core): inject components into handlers Handlers receive dependencies via constructor. Use context.getUserInfo() for tenant/provider checks directly. * refactor(ai-core): slim AICoreServiceImpl to pure delegation Zero fields, zero accessors. Delete AbstractAICoreService and MockAICoreServiceImpl. Add resourceGroupForTenant to interface. * refactor(ai-core): rewire configuration and setup handlers Configuration creates AICoreConfig, AICoreClients, DeploymentResolver and injects them into handlers at registration time. * refactor(recommendations): RptInferenceClient owns its retry Single-arg constructor, no dependency on service internals. Remove AbstractAICoreService casts from all consumers. * test(ai-core): update tests for new component architecture Adapt all unit and integration tests to use AICoreConfig, AICoreClients, DeploymentResolver instead of service accessors. * for pipeline * fix(ai-core): separate retry boundary to prevent orphaned deployments Only retry the deployment creation call. Polling is handled separately so a poll timeout does not re-create deployments. * refactor(ai-core): remove all service references from handlers Handlers use DeploymentResolver.resolveResourceGroup() directly instead of casting context.getService(). Zero service references. * fix(ai-core): add handler ordering and wire mock cleanup Add @HandlerOrder to setup handlers for DeploymentService events. Wire MockAICoreSetupHandler to actually call clearTenantCache(). Use ServiceException in mock inference handler. * fix(ai-core): validate config at startup, document impl coupling Fail fast on invalid cds.ai.core.* property values. Document AbstractCdsDefinedService dependency rationale. * test(ai-core): update tests for DeploymentResolver expansion Pass ResourceGroupApi to DeploymentResolver constructor. Pass DeploymentResolver to CRUD handler constructors. --------- Co-authored-by: Lisa Julia Nebel --- .../cds/feature/aicore/api/AICoreService.java | 13 +- .../aicore/api/DeploymentIdContext.java | 44 ++ .../aicore/api/InferenceClientContext.java | 44 ++ .../aicore/api/ResourceGroupContext.java | 44 ++ .../feature/aicore/core/AICoreClients.java | 23 + .../cds/feature/aicore/core/AICoreConfig.java | 58 ++ .../core/AICoreServiceConfiguration.java | 86 +-- .../aicore/core/AICoreServiceImpl.java | 432 ++----------- .../aicore/core/AICoreSetupHandler.java | 27 +- .../aicore/core/AbstractAICoreService.java | 98 --- .../aicore/core/DeploymentResolver.java | 252 ++++++++ .../aicore/core/MockAICoreServiceImpl.java | 116 ---- .../aicore/core/MockAICoreSetupHandler.java | 18 +- .../aicore/core/handler/AICoreApiHandler.java | 216 +++++++ .../core/handler/AbstractCrudHandler.java | 50 +- .../aicore/core/handler/ActionHandler.java | 34 +- .../core/handler/ConfigurationHandler.java | 35 +- .../core/handler/DeploymentHandler.java | 49 +- .../core/handler/MockAICoreApiHandler.java | 90 +++ .../core/handler/ResourceGroupHandler.java | 79 ++- .../core/AICoreServiceConfigurationTest.java | 14 +- .../AICoreServiceImplDeploymentIdTest.java | 111 ++-- .../aicore/core/AICoreServiceImplTest.java | 140 ++-- .../aicore/core/AICoreSetupHandlerTest.java | 74 ++- .../core/MockAICoreServiceImplTest.java | 129 ++-- .../handler/ConfigurationHandlerTest.java | 166 +++-- .../core/handler/DeploymentHandlerTest.java | 267 ++++---- .../handler/ResourceGroupHandlerTest.java | 596 ++++++------------ .../core/handler/TenantScopingTest.java | 281 ++++++--- .../FioriRecommendationHandler.java | 4 +- .../RecommendationConfiguration.java | 23 +- .../api/RptInferenceClient.java | 31 +- .../RecommendationConfigurationTest.java | 5 + coverage-report/pom.xml | 26 +- .../aicore/itest/mt/MtxLifecycleTest.java | 15 +- .../itest/mt/SubscribeUnsubscribeTest.java | 25 +- .../aicore/itest/mt/TenantIsolationTest.java | 42 +- .../aicore/itest/AICoreServiceTest.java | 56 +- .../cds/feature/aicore/itest/ActionTest.java | 60 +- .../aicore/itest/BaseIntegrationTest.java | 12 +- .../aicore/itest/ConfigurationTest.java | 9 +- .../feature/aicore/itest/DeploymentTest.java | 15 +- .../aicore/itest/MultiTenancyTest.java | 76 +-- .../aicore/itest/ResourceGroupTest.java | 9 +- .../handlers/AICoreShowcaseHandler.java | 5 +- 45 files changed, 2099 insertions(+), 1900 deletions(-) create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java create mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java index 2955b33..8a2e24c 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java @@ -28,7 +28,7 @@ public interface AICoreService extends RemoteService { /** Default service name under which an instance is registered in the service catalog. */ - String DEFAULT_NAME = "AICore$Default"; + String DEFAULT_NAME = "AICoreService$Default"; /** Qualified name of the {@code resourceGroups} entity exposed by this service. */ String RESOURCE_GROUPS = "AICore.resourceGroups"; @@ -50,6 +50,17 @@ public interface AICoreService extends RemoteService { */ String resourceGroup(); + /** + * Returns the AI Core resource group ID associated with the given tenant. + * + *

This variant is used during subscribe/unsubscribe flows where the tenant ID is explicitly + * available from the context rather than the current request. + * + * @param tenantId the CDS tenant identifier + * @return the AI Core resource group ID for the specified tenant + */ + String resourceGroupForTenant(String tenantId); + /** * Returns the deployment ID for the given model spec inside the given resource group. * diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java new file mode 100644 index 0000000..4fa04d9 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.cds.services.EventContext; +import com.sap.cds.services.EventName; + +/** + * Typed {@link EventContext} for the {@code deploymentId} event. + * + *

Emitted by {@link AICoreService#deploymentId(String, ModelDeploymentSpec)} to resolve (or + * create) a deployment matching the given spec inside the given resource group. The ON handler + * performs cache lookup, retry, configuration creation, deployment creation and polling. + */ +@EventName(DeploymentIdContext.EVENT) +public interface DeploymentIdContext extends EventContext { + + /** Event name constant. */ + String EVENT = "deploymentId"; + + /** Returns the resource group ID to operate in. */ + String getResourceGroupId(); + + /** Sets the resource group ID to operate in. */ + void setResourceGroupId(String resourceGroupId); + + /** Returns the deployment specification. */ + ModelDeploymentSpec getSpec(); + + /** Sets the deployment specification. */ + void setSpec(ModelDeploymentSpec spec); + + /** Returns the resolved deployment ID (set by the ON handler). */ + String getResult(); + + /** Sets the resolved deployment ID. */ + void setResult(String deploymentId); + + /** Creates a new context instance. */ + static DeploymentIdContext create() { + return EventContext.create(DeploymentIdContext.class, null); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java new file mode 100644 index 0000000..1b3095e --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.cds.services.EventContext; +import com.sap.cds.services.EventName; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; + +/** + * Typed {@link EventContext} for the {@code inferenceClient} event. + * + *

Emitted by {@link AICoreService#inferenceClient(String, String)} to build an {@link ApiClient} + * preconfigured with the inference destination for the given deployment. + */ +@EventName(InferenceClientContext.EVENT) +public interface InferenceClientContext extends EventContext { + + /** Event name constant. */ + String EVENT = "inferenceClient"; + + /** Returns the resource group ID containing the deployment. */ + String getResourceGroupId(); + + /** Sets the resource group ID containing the deployment. */ + void setResourceGroupId(String resourceGroupId); + + /** Returns the deployment ID. */ + String getDeploymentId(); + + /** Sets the deployment ID. */ + void setDeploymentId(String deploymentId); + + /** Returns the configured {@link ApiClient} (set by the ON handler). */ + ApiClient getResult(); + + /** Sets the configured {@link ApiClient}. */ + void setResult(ApiClient client); + + /** Creates a new context instance. */ + static InferenceClientContext create() { + return EventContext.create(InferenceClientContext.class, null); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java new file mode 100644 index 0000000..19a2a82 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.api; + +import com.sap.cds.services.EventContext; +import com.sap.cds.services.EventName; + +/** + * Typed {@link EventContext} for the {@code resourceGroup} event. + * + *

Emitted by {@link AICoreService#resourceGroup()} to resolve the AI Core resource group ID for + * the current tenant. In multi-tenancy mode, the resource group is created on-demand if it does not + * exist. In single-tenancy mode, the configured default resource group is returned. + * + *

If {@link #getTenantId()} is non-null, the handler uses the explicit tenant ID. Otherwise, the + * current tenant is read from the {@code RequestContext}. + */ +@EventName(ResourceGroupContext.EVENT) +public interface ResourceGroupContext extends EventContext { + + /** Event name constant. */ + String EVENT = "resourceGroup"; + + /** + * Returns the explicit tenant ID (optional). If {@code null}, the handler reads the tenant from + * the current {@code RequestContext}. + */ + String getTenantId(); + + /** Sets an explicit tenant ID. */ + void setTenantId(String tenantId); + + /** Returns the resolved resource group ID (set by the ON handler). */ + String getResult(); + + /** Sets the resolved resource group ID. */ + void setResult(String resourceGroupId); + + /** Creates a new context instance. */ + static ResourceGroupContext create() { + return EventContext.create(ResourceGroupContext.class, null); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java new file mode 100644 index 0000000..e004b12 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreClients.java @@ -0,0 +1,23 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; + +/** + * Holder for the AI Core SDK API clients, built once from the service binding at startup. + * + * @param deploymentApi client for deployment CRUD operations + * @param configurationApi client for configuration CRUD operations + * @param resourceGroupApi client for resource-group CRUD operations + * @param sdkService the AI Core SDK service for inference destination resolution + */ +public record AICoreClients( + DeploymentApi deploymentApi, + ConfigurationApi configurationApi, + ResourceGroupApi resourceGroupApi, + AiCoreService sdkService) {} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java new file mode 100644 index 0000000..84ded92 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreConfig.java @@ -0,0 +1,58 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.sap.cds.services.environment.CdsEnvironment; + +/** + * Immutable configuration for the AI Core plugin, read once from {@link CdsEnvironment} at startup. + * + * @param defaultResourceGroup the resource group to use when multi-tenancy is disabled + * @param resourceGroupPrefix prefix for tenant-specific resource groups (e.g. "cds-") + * @param maxRetries max retry attempts for transient AI Core errors + * @param initialDelayMs initial backoff delay in milliseconds + * @param multiTenancyEnabled whether multi-tenancy is active + */ +public record AICoreConfig( + String defaultResourceGroup, + String resourceGroupPrefix, + int maxRetries, + long initialDelayMs, + boolean multiTenancyEnabled) { + + /** The AI Core resource-group label key used to associate groups with CDS tenants. */ + public static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; + + private static final String DEFAULT_RESOURCE_GROUP = "default"; + private static final String DEFAULT_RESOURCE_GROUP_PREFIX = "cds-"; + private static final int DEFAULT_MAX_RETRIES = 10; + private static final long DEFAULT_INITIAL_DELAY_MS = 300; + + public AICoreConfig { + if (maxRetries < 1) { + throw new IllegalArgumentException("cds.ai.core.maxRetries must be >= 1, got " + maxRetries); + } + if (initialDelayMs < 1) { + throw new IllegalArgumentException( + "cds.ai.core.initialDelayMs must be >= 1, got " + initialDelayMs); + } + if (defaultResourceGroup == null || defaultResourceGroup.isBlank()) { + throw new IllegalArgumentException("cds.ai.core.resourceGroup must not be blank"); + } + if (resourceGroupPrefix == null) { + throw new IllegalArgumentException("cds.ai.core.resourceGroupPrefix must not be null"); + } + } + + /** Creates an {@code AICoreConfig} from the runtime environment properties. */ + public static AICoreConfig from(CdsEnvironment env, boolean multiTenancyEnabled) { + return new AICoreConfig( + env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP), + env.getProperty( + "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX), + env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES), + env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS), + multiTenancyEnabled); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index 43d9127..2b94030 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -8,9 +8,11 @@ import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler; import com.sap.cds.feature.aicore.core.handler.DeploymentHandler; +import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler; import com.sap.cds.services.environment.CdsProperties; @@ -28,14 +30,17 @@ * *

Detects the presence of an SAP AI Core service binding (either a regular service binding or * the {@code AICORE_SERVICE_KEY} environment variable used for hybrid local testing) and registers - * either {@link AICoreServiceImpl} (when a binding is found) or {@link MockAICoreServiceImpl} - * (no-binding fallback). Picked up automatically through {@code ServiceLoader}; applications do not - * need to instantiate this class directly. + * the appropriate handlers. Picked up automatically through {@code ServiceLoader}; applications do + * not need to instantiate this class directly. */ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { private static final Logger logger = LoggerFactory.getLogger(AICoreServiceConfiguration.class); + private AICoreConfig config; + private AICoreClients clients; + private DeploymentResolver resolver; + private static boolean hasAICoreModel(CdsRuntime runtime) { return runtime.getCdsModel().findService("AICore").isPresent(); } @@ -60,7 +65,10 @@ private static boolean detectMultiTenancy(CdsRuntime runtime) { if (sidecarUrl != null && !sidecarUrl.isBlank()) { return true; } - return runtime.getServiceCatalog().getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) != null; + return runtime + .getServiceCatalog() + .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) + != null; } @Override @@ -73,54 +81,58 @@ public void services(CdsRuntimeConfigurer configurer) { } boolean hasBinding = hasAICoreBinding(runtime); - boolean multiTenancyEnabled = detectMultiTenancy(runtime); + this.config = AICoreConfig.from(runtime.getEnvironment(), multiTenancyEnabled); + if (hasBinding) { - AICoreServiceImpl service = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - multiTenancyEnabled, - new DeploymentApi(), - new ConfigurationApi(), - new ResourceGroupApi(), - new AiCoreService()); - configurer.service(service); + DeploymentApi deploymentApi = new DeploymentApi(); + ConfigurationApi configurationApi = new ConfigurationApi(); + ResourceGroupApi resourceGroupApi = new ResourceGroupApi(); + AiCoreService sdkService = new AiCoreService(); + + this.clients = + new AICoreClients(deploymentApi, configurationApi, resourceGroupApi, sdkService); + this.resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); logger.info("Registered AICoreService backed by AI Core binding."); } else { - MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); - configurer.service(mockService); - logger.info("Registered MockAICoreService (no AI Core binding found)."); + logger.info( + "Registered AICoreService (no AI Core binding found — mock handlers will be used)."); } + + configurer.service(new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime)); } @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { - CdsRuntime runtime = configurer.getCdsRuntime(); - - AICoreService registered = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + if (config == null) { + return; // No AICore model — services() skipped registration + } - if (registered instanceof AICoreServiceImpl service) { - configurer.eventHandler(new ResourceGroupHandler(service)); - configurer.eventHandler(new DeploymentHandler(service)); - configurer.eventHandler(new ConfigurationHandler(service)); - configurer.eventHandler(new ActionHandler(service)); - logger.debug("Registered Prod AI-Core Implementation"); + if (clients != null) { + // Production path: real AI Core binding + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.eventHandler(new ConfigurationHandler(config, clients, resolver)); + configurer.eventHandler(new ActionHandler(config, clients, resolver)); + logger.debug("Registered production AI Core event handlers."); - if (service.isMultiTenancyEnabled()) { - configurer.eventHandler(new AICoreSetupHandler(service)); - logger.debug("Registered AI-Core Setup Handler for MTX subscribe/unsubscribe."); + if (config.multiTenancyEnabled()) { + configurer.eventHandler(new AICoreSetupHandler(clients, resolver)); + logger.debug("Registered AI Core setup handler for MTX subscribe/unsubscribe."); } - } else if (registered instanceof MockAICoreServiceImpl mockService) { + } else { + // Mock path: no AI Core binding + MockAICoreApiHandler mockApiHandler = new MockAICoreApiHandler(config); configurer.eventHandler(new MockEntityHandler()); - if (mockService.isMultiTenancyEnabled()) { - configurer.eventHandler(new MockAICoreSetupHandler(mockService)); - logger.debug("Registered Mock AI-Core Setup Handler for MTX subscribe/unsubscribe."); + configurer.eventHandler(mockApiHandler); + logger.debug("Registered mock AI Core event handlers."); + + if (config.multiTenancyEnabled()) { + configurer.eventHandler(new MockAICoreSetupHandler(mockApiHandler)); + logger.debug("Registered mock AI Core setup handler for MTX subscribe/unsubscribe."); } - logger.debug("Registered Mock AI-Core Implementation"); } } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java index 52eeb4f..b1c226c 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java @@ -3,424 +3,68 @@ */ package com.sap.cds.feature.aicore.core; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import com.sap.ai.sdk.core.AiCoreService; -import com.sap.ai.sdk.core.client.ConfigurationApi; -import com.sap.ai.sdk.core.client.DeploymentApi; -import com.sap.ai.sdk.core.client.ResourceGroupApi; -import com.sap.ai.sdk.core.model.AiConfigurationBaseData; -import com.sap.ai.sdk.core.model.AiConfigurationList; -import com.sap.ai.sdk.core.model.AiDeployment; -import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; -import com.sap.ai.sdk.core.model.AiDeploymentList; -import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; -import com.sap.ai.sdk.core.model.AiDeploymentStatus; -import com.sap.ai.sdk.core.model.BckndResourceGroup; -import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; -import com.sap.ai.sdk.core.model.BckndResourceGroupList; -import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; -import com.sap.cds.services.ErrorStatuses; -import com.sap.cds.services.ServiceException; -import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.services.impl.cds.AbstractCdsDefinedService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; -import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; -import io.github.resilience4j.core.IntervalFunction; -import io.github.resilience4j.retry.Retry; -import io.github.resilience4j.retry.RetryConfig; -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** - * Production implementation of {@link AICoreService} backed by an SAP AI Core service binding. + * Production implementation of {@link AICoreService}. * - *

Provides resource-group, configuration and deployment lifecycle management together with a - * factory for inference {@link ApiClient}s scoped to a specific deployment. Resource group lookup - * results, deployment IDs and per-cache-key locks are cached in bounded {@link Caffeine} caches so - * repeated calls within a tenant or resource group avoid round-trips to AI Core. + *

This class is a pure delegation layer: each API method creates a typed {@link + * com.sap.cds.services.EventContext EventContext}, emits it via the CAP event mechanism, and + * returns the handler's result. All business logic (caching, locking, API calls) lives in the + * registered ON handlers which receive their dependencies via constructor injection. * - *

Most state-changing AI Core calls are wrapped in a Resilience4j {@link Retry} that retries - * known transient errors (HTTP 403/404/412, see {@link #notReadyYet(OpenApiRequestException)}) with - * exponential backoff capped at 30 seconds. + *

Implementation note: This class extends {@code AbstractCdsDefinedService} + * from the CAP Java runtime's internal {@code impl} package. This is necessary because the public + * API ({@code ServiceDelegator}) does not provide CQN execution capabilities or CDS model binding. + * The semi-public {@code AbstractCqnService} (from {@code cds-services-utils}) provides CQN but not + * {@code getDefinition()}. Until a public API alternative exists, this coupling is accepted and + * version-compatibility is verified through integration tests against the CAP Java runtime. */ -public class AICoreServiceImpl extends AbstractAICoreService { +public class AICoreServiceImpl extends AbstractCdsDefinedService implements AICoreService { - private static final Logger logger = LoggerFactory.getLogger(AICoreServiceImpl.class); + private static final String CDS_DEFINITION_NAME = "AICore"; - public static final String TENANT_LABEL_KEY = "ext.ai.sap.com/CDS_TENANT_ID"; - - private static final String DEFAULT_RESOURCE_GROUP = "default"; - private static final String DEFAULT_RESOURCE_GROUP_PREFIX = "cds-"; - private static final int DEFAULT_MAX_RETRIES = 10; - private static final long DEFAULT_INITIAL_DELAY_MS = 300; - private static final Duration DEFAULT_CACHE_EXPIRY = Duration.ofHours(1); - private static final int DEFAULT_CACHE_MAX_SIZE = 10_000; - - private final Cache tenantResourceGroupCache; - private final Cache resourceGroupDeploymentCache; - - /** - * Per-cache-key monitors guarding deployment lookup/creation. Stored in a {@link - * ConcurrentHashMap} (not a Caffeine cache) so that two threads asking for the same key are - * guaranteed to obtain the same monitor instance — locks must never live in a - * size/time-evicting cache, otherwise concurrent callers can synchronize on different objects and - * race to create duplicate AI Core deployments. - */ - private final ConcurrentHashMap deploymentLocks = new ConcurrentHashMap<>(); - - private final int maxRetries; - private final long initialDelayMs; - private final String defaultResourceGroup; - private final String resourceGroupPrefix; - private final boolean multiTenancyEnabled; - private final Retry retry; - private final DeploymentApi deploymentApi; - private final ConfigurationApi configurationApi; - private final ResourceGroupApi resourceGroupApi; - private final AiCoreService sdkService; - - public AICoreServiceImpl( - String name, - CdsRuntime runtime, - boolean multiTenancyEnabled, - DeploymentApi deploymentApi, - ConfigurationApi configurationApi, - ResourceGroupApi resourceGroupApi, - AiCoreService sdkService) { - super(name, runtime); - this.multiTenancyEnabled = multiTenancyEnabled; - CdsEnvironment env = runtime.getEnvironment(); - this.maxRetries = - env.getProperty("cds.ai.core.maxRetries", Integer.class, DEFAULT_MAX_RETRIES); - this.initialDelayMs = - env.getProperty("cds.ai.core.initialDelayMs", Long.class, DEFAULT_INITIAL_DELAY_MS); - this.defaultResourceGroup = - env.getProperty("cds.ai.core.resourceGroup", String.class, DEFAULT_RESOURCE_GROUP); - this.resourceGroupPrefix = - env.getProperty( - "cds.ai.core.resourceGroupPrefix", String.class, DEFAULT_RESOURCE_GROUP_PREFIX); - this.retry = buildRetry(maxRetries, initialDelayMs); - this.tenantResourceGroupCache = newCache(); - this.resourceGroupDeploymentCache = newCache(); - this.deploymentApi = deploymentApi; - this.configurationApi = configurationApi; - this.resourceGroupApi = resourceGroupApi; - this.sdkService = sdkService; + public AICoreServiceImpl(String name, CdsRuntime runtime) { + super(name, CDS_DEFINITION_NAME, runtime); } - private static Cache newCache() { - return Caffeine.newBuilder() - .maximumSize(DEFAULT_CACHE_MAX_SIZE) - .expireAfterAccess(DEFAULT_CACHE_EXPIRY) - .build(); + @Override + public String resourceGroup() { + ResourceGroupContext ctx = ResourceGroupContext.create(); + emit(ctx); + return ctx.getResult(); } @Override public String resourceGroupForTenant(String tenantId) { - if (!multiTenancyEnabled || tenantId == null) { - logger.debug("Using default resource group {}", defaultResourceGroup); - return defaultResourceGroup; - } - return getOrCreateResourceGroupForTenant(tenantId); + ResourceGroupContext ctx = ResourceGroupContext.create(); + ctx.setTenantId(tenantId); + emit(ctx); + return ctx.getResult(); } @Override public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { - String cacheKey = deploymentCacheKey(resourceGroupId, spec); - Object lock = deploymentLocks.computeIfAbsent(cacheKey, k -> new Object()); - synchronized (lock) { - String cached = resourceGroupDeploymentCache.getIfPresent(cacheKey); - if (cached != null) { - try { - var current = deploymentApi.get(resourceGroupId, cached); - if (AiDeploymentStatus.RUNNING.equals(current.getStatus()) - || AiDeploymentStatus.PENDING.equals(current.getStatus())) { - return cached; - } - } catch (OpenApiRequestException e) { - // Only 404 means the cached deployment was deleted out-of-band — drop the stale entry - // and fall through to discover or create a new one. Any other status (5xx, 401, 412, - // network errors, …) is propagated so the caller's retry/backoff policy can handle it - // rather than silently invalidating a potentially valid cache entry and triggering a - // duplicate deployment. - Integer status = e.statusCode(); - if (status == null || status != 404) { - throw e; - } - logger.debug( - "Cached deployment {} in resource group {} no longer exists (404), " - + "invalidating cache entry", - cached, - resourceGroupId); - } - resourceGroupDeploymentCache.invalidate(cacheKey); - } - AiDeploymentList deploymentList = queryDeploymentsUntilReady(resourceGroupId, spec); - Optional existing = - deploymentList.getResources().stream() - .filter( - d -> - spec.configurationName().equals(d.getConfigurationName()) - && spec.matchesExisting().test(d) - && (AiDeploymentStatus.RUNNING.equals(d.getStatus()) - || AiDeploymentStatus.PENDING.equals(d.getStatus()))) - .findFirst() - .map(AiDeployment::getId); - if (existing.isPresent()) { - String deploymentId = existing.get(); - resourceGroupDeploymentCache.put(cacheKey, deploymentId); - return deploymentId; - } - return createDeployment(resourceGroupId, spec, cacheKey); - } + DeploymentIdContext ctx = DeploymentIdContext.create(); + ctx.setResourceGroupId(resourceGroupId); + ctx.setSpec(spec); + emit(ctx); + return ctx.getResult(); } @Override public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { - var destination = - sdkService.getInferenceDestination(resourceGroupId).usingDeploymentId(deploymentId); - logger.debug("Inference destination URI: {}", destination.getUri()); - return ApiClient.create(destination); - } - - @Override - public boolean isMultiTenancyEnabled() { - return multiTenancyEnabled; - } - - @Override - public Retry getRetry() { - return retry; - } - - @Override - public String getDefaultResourceGroup() { - return defaultResourceGroup; - } - - @Override - public String getResourceGroupPrefix() { - return resourceGroupPrefix; - } - - @Override - public Map getTenantResourceGroupCache() { - return tenantResourceGroupCache.asMap(); - } - - @Override - public Map getResourceGroupDeploymentCache() { - return resourceGroupDeploymentCache.asMap(); - } - - public DeploymentApi getDeploymentApi() { - return deploymentApi; - } - - public ConfigurationApi getConfigurationApi() { - return configurationApi; - } - - public ResourceGroupApi getResourceGroupApi() { - return resourceGroupApi; - } - - @Override - public String resolveResourceGroupFromKeys(Map keys) { - if (keys.containsKey("resourceGroup_resourceGroupId")) { - return (String) keys.get("resourceGroup_resourceGroupId"); - } - Object rgObj = keys.get("resourceGroup"); - if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { - return (String) rgMap.get("resourceGroupId"); - } - return resourceGroup(); - } - - @Override - public void clearTenantCache(String tenantId) { - String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId); - if (resourceGroupId != null) { - String prefix = resourceGroupId + "::"; - resourceGroupDeploymentCache - .asMap() - .keySet() - .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); - deploymentLocks.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); - } - } - - /** - * Builds the cache key for the {@code resourceGroupDeploymentCache} and {@code deploymentLocks} - * maps. Package-private so tests can derive the same key the production code uses, instead of - * duplicating the format inline. - */ - static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { - return resourceGroupId + "::" + spec.configurationName(); - } - - private String getOrCreateResourceGroupForTenant(String tenantId) { - return tenantResourceGroupCache.get( - tenantId, - key -> { - List labelSelector = List.of(TENANT_LABEL_KEY + "=" + key); - BckndResourceGroupList result = - resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); - List resources = result.getResources(); - if (resources != null && !resources.isEmpty()) { - return resources.get(0).getResourceGroupId(); - } - String resourceGroupId = resourceGroupPrefix + key; - BckndResourceGroupLabel label = - BckndResourceGroupLabel.create().key(TENANT_LABEL_KEY).value(key); - BckndResourceGroupsPostRequest request = - BckndResourceGroupsPostRequest.create() - .resourceGroupId(resourceGroupId) - .labels(List.of(label)); - try { - resourceGroupApi.create(request); - logger.debug("Created resource group {} for tenant {}", resourceGroupId, key); - } catch (OpenApiRequestException e) { - if (e.statusCode() != null && e.statusCode() == 409) { - logger.debug( - "Resource group {} already exists (409 Conflict), reusing", resourceGroupId); - } else { - throw e; - } - } - return resourceGroupId; - }); - } - - private String createDeployment( - String resourceGroupId, ModelDeploymentSpec spec, String cacheKey) { - AiConfigurationList configList = - configurationApi.query( - resourceGroupId, spec.scenarioId(), null, null, null, null, null, null); - String configId = - configList.getResources().stream() - .filter(c -> spec.configurationName().equals(c.getName())) - .findFirst() - .map( - c -> { - logger.debug( - "Reusing existing configuration {} ({}) in resource group {}", - c.getId(), - spec.configurationName(), - resourceGroupId); - return c.getId(); - }) - .orElseGet(() -> createConfiguration(resourceGroupId, spec)); - - return Retry.decorateSupplier( - retry, - () -> { - var deployRequest = AiDeploymentCreationRequest.create().configurationId(configId); - var deployResponse = deploymentApi.create(resourceGroupId, deployRequest); - String deploymentId = deployResponse.getId(); - logger.debug( - "Created deployment {} ({}) in resource group {}, polling for RUNNING", - deploymentId, - spec.configurationName(), - resourceGroupId); - return pollUntilRunning(resourceGroupId, deploymentId, cacheKey); - }) - .get(); - } - - private String createConfiguration(String resourceGroupId, ModelDeploymentSpec spec) { - AiConfigurationBaseData configRequest = - AiConfigurationBaseData.create() - .name(spec.configurationName()) - .executableId(spec.executableId()) - .scenarioId(spec.scenarioId()) - .parameterBindings(spec.parameterBindings()); - String configId = configurationApi.create(resourceGroupId, configRequest).getId(); - logger.debug( - "Created configuration {} ({}) in resource group {}", - configId, - spec.configurationName(), - resourceGroupId); - return configId; - } - - private String pollUntilRunning(String resourceGroupId, String deploymentId, String cacheKey) { - Retry pollRetry = - Retry.of( - "pollDeployment", - RetryConfig.custom() - .maxAttempts(maxRetries) - .intervalFunction(IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0)) - .retryOnResult( - deployment -> !AiDeploymentStatus.RUNNING.equals(deployment.getStatus())) - .retryOnException(e -> false) - .build()); - - AiDeploymentResponseWithDetails result = - Retry.decorateSupplier( - pollRetry, - () -> { - var current = deploymentApi.get(resourceGroupId, deploymentId); - logger.debug("Deployment {} status: {}", deploymentId, current.getStatus()); - return current; - }) - .get(); - - if (AiDeploymentStatus.RUNNING.equals(result.getStatus())) { - resourceGroupDeploymentCache.put(cacheKey, deploymentId); - return deploymentId; - } - logger.error( - "Deployment {} in resource group {} did not reach RUNNING status after {} retries", - deploymentId, - resourceGroupId, - maxRetries); - throw new ServiceException( - ErrorStatuses.GATEWAY_TIMEOUT, "AI model deployment is not available"); - } - - private AiDeploymentList queryDeploymentsUntilReady( - String resourceGroupId, ModelDeploymentSpec spec) { - return Retry.decorateSupplier( - retry, - () -> - deploymentApi.query( - resourceGroupId, null, null, spec.scenarioId(), null, null, null, null)) - .get(); - } - - static boolean notReadyYet(OpenApiRequestException e) { - Throwable t = e; - while (t != null) { - if (t instanceof OpenApiRequestException oae) { - Integer code = oae.statusCode(); - if (code != null && (code == 403 || code == 404 || code == 412)) { - return true; - } - } - t = t.getCause(); - } - return false; - } - - private static final long MAX_INTERVAL_MS = 30_000L; - - private static Retry buildRetry(int maxAttempts, long initialDelayMs) { - RetryConfig config = - RetryConfig.custom() - .maxAttempts(maxAttempts) - .intervalFunction( - IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0, MAX_INTERVAL_MS)) - .retryOnException(e -> e instanceof OpenApiRequestException oae && notReadyYet(oae)) - .build(); - return Retry.of("aicore", config); + InferenceClientContext ctx = InferenceClientContext.create(); + ctx.setResourceGroupId(resourceGroupId); + ctx.setDeploymentId(deploymentId); + emit(ctx); + return ctx.getResult(); } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java index e4fd983..57a56ac 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java @@ -3,7 +3,6 @@ */ package com.sap.cds.feature.aicore.core; -import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupList; import com.sap.cds.services.ErrorStatuses; @@ -11,6 +10,7 @@ import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.HandlerOrder; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.mt.SubscribeEventContext; @@ -25,19 +25,22 @@ public class AICoreSetupHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(AICoreSetupHandler.class); - private final AICoreServiceImpl service; + private final AICoreClients clients; + private final DeploymentResolver resolver; - public AICoreSetupHandler(AICoreServiceImpl service) { - this.service = service; + public AICoreSetupHandler(AICoreClients clients, DeploymentResolver resolver) { + this.clients = clients; + this.resolver = resolver; } @After(event = DeploymentService.EVENT_SUBSCRIBE) + @HandlerOrder(HandlerOrder.LATE) public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); logger.debug("Creating AI Core resources for tenant {}", tenantId); try { - String resourceGroupId = service.resourceGroupForTenant(tenantId); - logger.info("Created AI Core resource group {} for tenant {}", resourceGroupId, tenantId); + String resourceGroupId = resolver.resolveResourceGroup(tenantId); + logger.info("Ensured AI Core resource group {} for tenant {}", resourceGroupId, tenantId); } catch (Exception e) { throw new ServiceException( ErrorStatuses.SERVER_ERROR, @@ -48,6 +51,7 @@ public void afterSubscribe(SubscribeEventContext context) { } @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) + @HandlerOrder(HandlerOrder.EARLY) public void beforeUnsubscribe(UnsubscribeEventContext context) { String tenantId = context.getTenant(); logger.debug("Deleting AI Core resources for tenant {}", tenantId); @@ -55,7 +59,7 @@ public void beforeUnsubscribe(UnsubscribeEventContext context) { deleteResourceGroupForTenant(tenantId); } finally { // Always evict cache entries so a retry won't reuse stale state. - service.clearTenantCache(tenantId); + resolver.invalidateTenant(tenantId); } } @@ -68,7 +72,7 @@ private void deleteResourceGroupForTenant(String tenantId) { return; } try { - service.getResourceGroupApi().delete(resourceGroupId); + clients.resourceGroupApi().delete(resourceGroupId); logger.info("Deleted AI Core resource group {} for tenant {}", resourceGroupId, tenantId); } catch (OpenApiRequestException e) { if (e.statusCode() != null && e.statusCode() == 404) { @@ -92,17 +96,16 @@ private void deleteResourceGroupForTenant(String tenantId) { * Core API filtered by the tenant label. Returns {@code null} if no resource group is found. */ private String resolveResourceGroupId(String tenantId) { - String cached = service.getTenantResourceGroupCache().get(tenantId); + String cached = resolver.getTenantResourceGroupCacheView().get(tenantId); if (cached != null) { return cached; } logger.debug( "No cached resource group for tenant {}, falling back to AI Core lookup", tenantId); - ResourceGroupApi api = service.getResourceGroupApi(); - List labelSelector = List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); + List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); BckndResourceGroupList result; try { - result = api.getAll(null, null, null, null, null, null, labelSelector); + result = clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector); } catch (OpenApiRequestException e) { throw new ServiceException( ErrorStatuses.SERVER_ERROR, diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java deleted file mode 100644 index 2e4fe7a..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java +++ /dev/null @@ -1,98 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.core; - -import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.services.impl.cds.AbstractCdsDefinedService; -import com.sap.cds.services.request.RequestContext; -import com.sap.cds.services.request.UserInfo; -import com.sap.cds.services.runtime.CdsRuntime; -import io.github.resilience4j.retry.Retry; -import java.util.Map; - -/** - * Abstract base class for AICore service implementations, providing shared internal methods for - * cache access, configuration, and resource group resolution. These methods are not part of the - * public {@link AICoreService} contract but are shared between the real and mock implementations. - */ -public abstract class AbstractAICoreService extends AbstractCdsDefinedService - implements AICoreService { - - /** The qualified CDS service definition name. */ - private static final String CDS_DEFINITION_NAME = "AICore"; - - protected AbstractAICoreService(String name, CdsRuntime runtime) { - super(name, CDS_DEFINITION_NAME, runtime); - } - - /** Returns the {@link CdsRuntime} that this service was created with. */ - public CdsRuntime getRuntime() { - return runtime; - } - - /** - * Returns the tenant ID from the current {@link RequestContext}. May return {@code null} if no - * tenant is set (e.g. in single-tenant mode). - */ - public String currentTenantId() { - return RequestContext.getCurrent(runtime).getUserInfo().getTenant(); - } - - /** - * Returns whether the current request is running as a system/provider user. Provider users are - * allowed to see all tenants' resources. - */ - public boolean isProviderUser() { - UserInfo userInfo = RequestContext.getCurrent(runtime).getUserInfo(); - return userInfo.isSystemUser() || userInfo.isInternalUser(); - } - - /** - * Returns whether multi-tenancy is enabled. Not part of the public {@link AICoreService} - * interface — callers should not need to be aware of multi-tenancy. - */ - public abstract boolean isMultiTenancyEnabled(); - - /** - * Returns the shared {@link Retry} used internally for transient AI Core errors. Not part of the - * public {@link AICoreService} interface but accessible to internal callers (e.g. the - * recommendations module) that need consistent backoff behaviour. - */ - public abstract Retry getRetry(); - - /** - * Returns the resource group for the given tenant ID. This is an internal method used by setup - * handlers where the tenant ID is explicitly available from the subscribe/unsubscribe context. - * - * @param tenantId the CDS tenant identifier - * @return the AI Core resource group ID - */ - public abstract String resourceGroupForTenant(String tenantId); - - @Override - public String resourceGroup() { - return resourceGroupForTenant(currentTenantId()); - } - - /** Returns the configured default resource group identifier. */ - public abstract String getDefaultResourceGroup(); - - /** Returns the configured resource group prefix used for tenant-specific groups. */ - public abstract String getResourceGroupPrefix(); - - /** Returns the tenant-to-resource-group cache as an unmodifiable view. */ - public abstract Map getTenantResourceGroupCache(); - - /** Returns the resource-group-to-deployment cache as an unmodifiable view. */ - public abstract Map getResourceGroupDeploymentCache(); - - /** Evicts all cache entries associated with the given tenant. */ - public abstract void clearTenantCache(String tenantId); - - /** - * Resolves the resource group ID from CQN keys, checking for explicit resource group references - * before falling back to tenant-based resolution. - */ - public abstract String resolveResourceGroupFromKeys(Map keys); -} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java new file mode 100644 index 0000000..1db6136 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/DeploymentResolver.java @@ -0,0 +1,252 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiDeploymentStatus; +import com.sap.ai.sdk.core.model.BckndResourceGroup; +import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Stateful component that manages tenant-to-resource-group and resource-group-to-deployment caches, + * per-key locks, and retry policies for AI Core operations. + * + *

Handlers interact with this class through intention-revealing operations ({@link + * #resolveResourceGroup}, {@link #resolveDeployment}, {@link #invalidateTenant}) instead of + * manipulating caches and locks directly. + */ +public class DeploymentResolver { + + private static final Logger logger = LoggerFactory.getLogger(DeploymentResolver.class); + + private static final Duration DEFAULT_CACHE_EXPIRY = Duration.ofHours(1); + private static final int DEFAULT_CACHE_MAX_SIZE = 10_000; + private static final long MAX_INTERVAL_MS = 30_000L; + + private final Cache tenantResourceGroupCache; + private final Cache deploymentCache; + + /** + * Per-cache-key monitors guarding deployment lookup/creation. Stored in a {@link + * ConcurrentHashMap} (not a Caffeine cache) so that two threads asking for the same key are + * guaranteed to obtain the same monitor instance — locks must never live in a + * size/time-evicting cache, otherwise concurrent callers can synchronize on different objects and + * race to create duplicate AI Core deployments. + */ + private final ConcurrentHashMap deploymentLocks = new ConcurrentHashMap<>(); + + private final AICoreConfig config; + private final DeploymentApi deploymentApi; + private final ResourceGroupApi resourceGroupApi; + private final Retry retry; + + public DeploymentResolver( + AICoreConfig config, DeploymentApi deploymentApi, ResourceGroupApi resourceGroupApi) { + this.config = config; + this.deploymentApi = deploymentApi; + this.resourceGroupApi = resourceGroupApi; + this.retry = buildRetry(config.maxRetries(), config.initialDelayMs()); + this.tenantResourceGroupCache = newCache(); + this.deploymentCache = newCache(); + } + + // ────────────────────────────────────────────────────────────────────────── + // Resource group resolution + // ────────────────────────────────────────────────────────────────────────── + + /** + * Resolves the resource group for a tenant. Returns the configured default resource group if + * multi-tenancy is disabled or tenant is {@code null}. Otherwise looks up (or creates) the + * tenant's resource group via the AI Core API, caching the result. Thread-safe. + * + * @param tenantId the CDS tenant identifier (may be {@code null}) + * @return the AI Core resource group ID + */ + public String resolveResourceGroup(String tenantId) { + if (!config.multiTenancyEnabled() || tenantId == null) { + return config.defaultResourceGroup(); + } + return tenantResourceGroupCache.get(tenantId, this::findOrCreateResourceGroup); + } + + // ────────────────────────────────────────────────────────────────────────── + // Deployment resolution + // ────────────────────────────────────────────────────────────────────────── + + /** + * Resolves a deployment ID for the given spec within a resource group. On cache hit, validates + * via {@link DeploymentApi#get} that the deployment is still RUNNING or PENDING. On cache miss or + * stale entry, acquires a per-key lock and calls the {@code loader} to find or create the + * deployment. The result is cached. + * + * @param resourceGroupId the AI Core resource group + * @param spec the deployment specification + * @param loader supplier that finds an existing or creates a new deployment — called under lock + * on cache miss + * @return the deployment ID + */ + public String resolveDeployment( + String resourceGroupId, ModelDeploymentSpec spec, Supplier loader) { + String cacheKey = deploymentCacheKey(resourceGroupId, spec); + Object lock = deploymentLocks.computeIfAbsent(cacheKey, k -> new Object()); + + synchronized (lock) { + String cached = deploymentCache.getIfPresent(cacheKey); + if (cached != null) { + if (validateCachedDeployment(resourceGroupId, cached)) { + return cached; + } + deploymentCache.invalidate(cacheKey); + } + + String deploymentId = loader.get(); + deploymentCache.put(cacheKey, deploymentId); + return deploymentId; + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Cache management + // ────────────────────────────────────────────────────────────────────────── + + /** + * Evicts all cache entries associated with the given tenant: the resource-group mapping, all + * deployments in that resource group, and their lock entries. + */ + public void invalidateTenant(String tenantId) { + String resourceGroupId = tenantResourceGroupCache.asMap().remove(tenantId); + if (resourceGroupId != null) { + String prefix = resourceGroupId + "::"; + deploymentCache + .asMap() + .keySet() + .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + deploymentLocks.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + } + } + + /** Returns the shared {@link Retry} for wrapping transient AI Core operations. */ + public Retry getRetry() { + return retry; + } + + /** + * Returns an unmodifiable view of the tenant-to-resource-group cache. Primarily for diagnostics + * and the setup handler's unsubscribe logic. + */ + public Map getTenantResourceGroupCacheView() { + return Collections.unmodifiableMap(tenantResourceGroupCache.asMap()); + } + + /** Builds the cache key for deployment lookups. */ + static String deploymentCacheKey(String resourceGroupId, ModelDeploymentSpec spec) { + return resourceGroupId + "::" + spec.configurationName(); + } + + /** Returns whether the given {@link OpenApiRequestException} indicates a transient state. */ + public static boolean notReadyYet(OpenApiRequestException e) { + Throwable t = e; + while (t != null) { + if (t instanceof OpenApiRequestException oae) { + Integer code = oae.statusCode(); + if (code != null && (code == 403 || code == 404 || code == 412)) { + return true; + } + } + t = t.getCause(); + } + return false; + } + + // ────────────────────────────────────────────────────────────────────────── + // Internal + // ────────────────────────────────────────────────────────────────────────── + + private String findOrCreateResourceGroup(String tenantId) { + List labelSelector = List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); + BckndResourceGroupList result = + resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); + List resources = result.getResources(); + if (resources != null && !resources.isEmpty()) { + return resources.get(0).getResourceGroupId(); + } + String resourceGroupId = config.resourceGroupPrefix() + tenantId; + BckndResourceGroupLabel label = + BckndResourceGroupLabel.create().key(AICoreConfig.TENANT_LABEL_KEY).value(tenantId); + BckndResourceGroupsPostRequest request = + BckndResourceGroupsPostRequest.create() + .resourceGroupId(resourceGroupId) + .labels(List.of(label)); + try { + resourceGroupApi.create(request); + logger.debug("Created resource group {} for tenant {}", resourceGroupId, tenantId); + } catch (OpenApiRequestException e) { + if (e.statusCode() != null && e.statusCode() == 409) { + logger.debug("Resource group {} already exists (409 Conflict), reusing", resourceGroupId); + } else { + throw e; + } + } + return resourceGroupId; + } + + /** + * Validates that a cached deployment ID is still active (RUNNING or PENDING). Returns {@code + * true} if valid, {@code false} if stale (404). Throws on unexpected errors so the caller's + * retry/backoff policy can handle them. + */ + private boolean validateCachedDeployment(String resourceGroupId, String deploymentId) { + try { + var current = deploymentApi.get(resourceGroupId, deploymentId); + return AiDeploymentStatus.RUNNING.equals(current.getStatus()) + || AiDeploymentStatus.PENDING.equals(current.getStatus()); + } catch (OpenApiRequestException e) { + Integer status = e.statusCode(); + if (status != null && status == 404) { + logger.debug( + "Cached deployment {} in resource group {} no longer exists (404), invalidating", + deploymentId, + resourceGroupId); + return false; + } + throw e; + } + } + + private static Cache newCache() { + return Caffeine.newBuilder() + .maximumSize(DEFAULT_CACHE_MAX_SIZE) + .expireAfterAccess(DEFAULT_CACHE_EXPIRY) + .build(); + } + + private static Retry buildRetry(int maxAttempts, long initialDelayMs) { + RetryConfig config = + RetryConfig.custom() + .maxAttempts(maxAttempts) + .intervalFunction( + IntervalFunction.ofExponentialBackoff(initialDelayMs, 2.0, MAX_INTERVAL_MS)) + .retryOnException(e -> e instanceof OpenApiRequestException oae && notReadyYet(oae)) + .build(); + return Retry.of("aicore", config); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java deleted file mode 100644 index aaee071..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImpl.java +++ /dev/null @@ -1,116 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.core; - -import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; -import com.sap.cds.services.environment.CdsEnvironment; -import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; -import io.github.resilience4j.retry.Retry; -import io.github.resilience4j.retry.RetryConfig; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class MockAICoreServiceImpl extends AbstractAICoreService { - - private static final Logger logger = LoggerFactory.getLogger(MockAICoreServiceImpl.class); - - private final Map tenantResourceGroupCache = new ConcurrentHashMap<>(); - private final Map resourceGroupDeploymentCache = new ConcurrentHashMap<>(); - private final Retry retry; - private final String defaultResourceGroup; - private final String resourceGroupPrefix; - private final boolean multiTenancyEnabled; - - public MockAICoreServiceImpl(String name, CdsRuntime runtime) { - this(name, runtime, false); - } - - public MockAICoreServiceImpl(String name, CdsRuntime runtime, boolean multiTenancyEnabled) { - super(name, runtime); - logger.info("MockAICoreService initialized - all operations use in-memory storage."); - this.retry = Retry.of("mock-aicore", RetryConfig.custom().maxAttempts(1).build()); - CdsEnvironment env = runtime.getEnvironment(); - this.defaultResourceGroup = - env.getProperty("cds.ai.core.resourceGroup", String.class, "default"); - this.resourceGroupPrefix = - env.getProperty("cds.ai.core.resourceGroupPrefix", String.class, "cds-"); - this.multiTenancyEnabled = multiTenancyEnabled; - } - - @Override - public String resourceGroupForTenant(String tenantId) { - if (!multiTenancyEnabled) { - return defaultResourceGroup; - } - return tenantResourceGroupCache.computeIfAbsent(tenantId, id -> resourceGroupPrefix + id); - } - - @Override - public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { - String key = resourceGroupId + "::" + spec.configurationName(); - return resourceGroupDeploymentCache.computeIfAbsent(key, k -> "mock-deployment-" + k); - } - - @Override - public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { - throw new UnsupportedOperationException( - "MockAICoreServiceImpl does not provide an inference client; tests should stub inference."); - } - - @Override - public boolean isMultiTenancyEnabled() { - return multiTenancyEnabled; - } - - @Override - public Retry getRetry() { - return retry; - } - - @Override - public String getDefaultResourceGroup() { - return defaultResourceGroup; - } - - @Override - public String getResourceGroupPrefix() { - return resourceGroupPrefix; - } - - @Override - public Map getTenantResourceGroupCache() { - return tenantResourceGroupCache; - } - - @Override - public Map getResourceGroupDeploymentCache() { - return resourceGroupDeploymentCache; - } - - @Override - public void clearTenantCache(String tenantId) { - String resourceGroupId = tenantResourceGroupCache.remove(tenantId); - if (resourceGroupId != null) { - String prefix = resourceGroupId + "::"; - resourceGroupDeploymentCache - .keySet() - .removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); - } - } - - @Override - public String resolveResourceGroupFromKeys(Map keys) { - if (keys.containsKey("resourceGroup_resourceGroupId")) { - return (String) keys.get("resourceGroup_resourceGroupId"); - } - Object rgObj = keys.get("resourceGroup"); - if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { - return (String) rgMap.get("resourceGroupId"); - } - return defaultResourceGroup; - } -} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java index fe152fd..77a0983 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java @@ -3,9 +3,11 @@ */ package com.sap.cds.feature.aicore.core; +import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.Before; +import com.sap.cds.services.handler.annotations.HandlerOrder; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.mt.SubscribeEventContext; @@ -18,24 +20,26 @@ public class MockAICoreSetupHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(MockAICoreSetupHandler.class); - private final MockAICoreServiceImpl service; + private final MockAICoreApiHandler mockHandler; - public MockAICoreSetupHandler(MockAICoreServiceImpl service) { - this.service = service; + public MockAICoreSetupHandler(MockAICoreApiHandler mockHandler) { + this.mockHandler = mockHandler; } @After(event = DeploymentService.EVENT_SUBSCRIBE) + @HandlerOrder(HandlerOrder.LATE) public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); - String resourceGroupId = service.resourceGroupForTenant(tenantId); - logger.info( - "Mock created in-memory resource group {} for tenant {}", resourceGroupId, tenantId); + // Trigger resource group creation in mock cache + mockHandler.getTenantResourceGroupCache().computeIfAbsent(tenantId, id -> "cds-" + id); + logger.info("Mock created in-memory resource group for tenant {}", tenantId); } @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) + @HandlerOrder(HandlerOrder.EARLY) public void beforeUnsubscribe(UnsubscribeEventContext context) { String tenantId = context.getTenant(); - service.clearTenantCache(tenantId); + mockHandler.clearTenantCache(tenantId); logger.info("Mock cleared in-memory caches for tenant {}", tenantId); } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java new file mode 100644 index 0000000..3ecf8e7 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java @@ -0,0 +1,216 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.ai.sdk.core.model.AiConfigurationBaseData; +import com.sap.ai.sdk.core.model.AiConfigurationList; +import com.sap.ai.sdk.core.model.AiDeployment; +import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; +import com.sap.ai.sdk.core.model.AiDeploymentList; +import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; +import com.sap.ai.sdk.core.model.AiDeploymentStatus; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * ON handler for the {@link AICoreService} API events ({@code resourceGroup}, {@code deploymentId}, + * {@code inferenceClient}). + * + *

Contains the business logic for deployment discovery/creation and inference client + * construction. Resource-group resolution is delegated to {@link DeploymentResolver}. + */ +@ServiceName(AICoreService.DEFAULT_NAME) +public class AICoreApiHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(AICoreApiHandler.class); + + private final AICoreConfig config; + private final AICoreClients clients; + private final DeploymentResolver resolver; + + public AICoreApiHandler(AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + this.config = config; + this.clients = clients; + this.resolver = resolver; + } + + @On + public void onResourceGroup(ResourceGroupContext context) { + String tenantId = context.getTenantId(); + if (tenantId == null) { + tenantId = context.getUserInfo().getTenant(); + } + context.setResult(resolver.resolveResourceGroup(tenantId)); + } + + @On + public void onDeploymentId(DeploymentIdContext context) { + String resourceGroupId = context.getResourceGroupId(); + ModelDeploymentSpec spec = context.getSpec(); + + String deploymentId = + resolver.resolveDeployment( + resourceGroupId, spec, () -> findOrCreateDeployment(resourceGroupId, spec)); + context.setResult(deploymentId); + } + + @On + public void onInferenceClient(InferenceClientContext context) { + var destination = + clients + .sdkService() + .getInferenceDestination(context.getResourceGroupId()) + .usingDeploymentId(context.getDeploymentId()); + logger.debug("Inference destination URI: {}", destination.getUri()); + context.setResult(ApiClient.create(destination)); + } + + // ────────────────────────────────────────────────────────────────────────── + // Deployment business logic + // ────────────────────────────────────────────────────────────────────────── + + private String findOrCreateDeployment(String resourceGroupId, ModelDeploymentSpec spec) { + AiDeploymentList deploymentList = queryDeploymentsUntilReady(resourceGroupId, spec); + Optional existing = + deploymentList.getResources().stream() + .filter( + d -> + spec.configurationName().equals(d.getConfigurationName()) + && spec.matchesExisting().test(d) + && (AiDeploymentStatus.RUNNING.equals(d.getStatus()) + || AiDeploymentStatus.PENDING.equals(d.getStatus()))) + .findFirst() + .map(AiDeployment::getId); + if (existing.isPresent()) { + return existing.get(); + } + return createDeployment(resourceGroupId, spec); + } + + private String createDeployment(String resourceGroupId, ModelDeploymentSpec spec) { + String configId = findOrCreateConfiguration(resourceGroupId, spec); + + // Retry only the creation call — transient 403/412 on fresh resource groups. + // Once we have a deployment ID, polling is handled separately to avoid + // creating orphaned deployments on poll timeout. + String deploymentId = + Retry.decorateSupplier( + resolver.getRetry(), + () -> { + var deployRequest = + AiDeploymentCreationRequest.create().configurationId(configId); + var response = clients.deploymentApi().create(resourceGroupId, deployRequest); + logger.debug( + "Created deployment {} ({}) in resource group {}", + response.getId(), + spec.configurationName(), + resourceGroupId); + return response.getId(); + }) + .get(); + + return pollUntilRunning(resourceGroupId, deploymentId); + } + + private String findOrCreateConfiguration(String resourceGroupId, ModelDeploymentSpec spec) { + AiConfigurationList configList = + clients + .configurationApi() + .query(resourceGroupId, spec.scenarioId(), null, null, null, null, null, null); + return configList.getResources().stream() + .filter(c -> spec.configurationName().equals(c.getName())) + .findFirst() + .map( + c -> { + logger.debug( + "Reusing existing configuration {} ({}) in resource group {}", + c.getId(), + spec.configurationName(), + resourceGroupId); + return c.getId(); + }) + .orElseGet(() -> createConfiguration(resourceGroupId, spec)); + } + + private String createConfiguration(String resourceGroupId, ModelDeploymentSpec spec) { + AiConfigurationBaseData configRequest = + AiConfigurationBaseData.create() + .name(spec.configurationName()) + .executableId(spec.executableId()) + .scenarioId(spec.scenarioId()) + .parameterBindings(spec.parameterBindings()); + String configId = clients.configurationApi().create(resourceGroupId, configRequest).getId(); + logger.debug( + "Created configuration {} ({}) in resource group {}", + configId, + spec.configurationName(), + resourceGroupId); + return configId; + } + + private String pollUntilRunning(String resourceGroupId, String deploymentId) { + Retry pollRetry = + Retry.of( + "pollDeployment-" + deploymentId, + RetryConfig.custom() + .maxAttempts(config.maxRetries()) + .intervalFunction( + IntervalFunction.ofExponentialBackoff(config.initialDelayMs(), 2.0)) + .retryOnResult( + deployment -> !AiDeploymentStatus.RUNNING.equals(deployment.getStatus())) + .retryOnException(e -> false) + .build()); + + AiDeploymentResponseWithDetails result = + Retry.decorateSupplier( + pollRetry, + () -> { + var current = clients.deploymentApi().get(resourceGroupId, deploymentId); + logger.debug("Deployment {} status: {}", deploymentId, current.getStatus()); + return current; + }) + .get(); + + if (AiDeploymentStatus.RUNNING.equals(result.getStatus())) { + return deploymentId; + } + logger.error( + "Deployment {} in resource group {} did not reach RUNNING status after {} retries", + deploymentId, + resourceGroupId, + config.maxRetries()); + throw new ServiceException( + ErrorStatuses.GATEWAY_TIMEOUT, "AI model deployment is not available"); + } + + private AiDeploymentList queryDeploymentsUntilReady( + String resourceGroupId, ModelDeploymentSpec spec) { + Retry retry = resolver.getRetry(); + return Retry.decorateSupplier( + retry, + () -> + clients + .deploymentApi() + .query(resourceGroupId, null, null, spec.scenarioId(), null, null, null, null)) + .get(); + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java index 0549daa..fc3392d 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AbstractCrudHandler.java @@ -4,10 +4,14 @@ package com.sap.cds.feature.aicore.core.handler; import com.sap.ai.sdk.core.model.BckndResourceGroup; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.EventContext; import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.request.UserInfo; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -15,14 +19,30 @@ abstract class AbstractCrudHandler implements EventHandler { - protected final AICoreServiceImpl service; + protected final AICoreConfig config; + protected final AICoreClients clients; + protected final DeploymentResolver resolver; - protected AbstractCrudHandler(AICoreServiceImpl service) { - this.service = service; + protected AbstractCrudHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + this.config = config; + this.clients = clients; + this.resolver = resolver; } - protected String resolveResourceGroup(Map keys) { - return service.resolveResourceGroupFromKeys(keys); + /** + * Resolves the resource group ID from CQN keys. Checks for an explicit resource-group reference + * in the keys before falling back to tenant-based resolution via the {@link DeploymentResolver}. + */ + protected String resolveResourceGroup(EventContext context, Map keys) { + if (keys.containsKey("resourceGroup_resourceGroupId")) { + return (String) keys.get("resourceGroup_resourceGroupId"); + } + Object rgObj = keys.get("resourceGroup"); + if (rgObj instanceof Map rgMap && rgMap.containsKey("resourceGroupId")) { + return (String) rgMap.get("resourceGroupId"); + } + return resolver.resolveResourceGroup(context.getUserInfo().getTenant()); } /** @@ -30,26 +50,34 @@ protected String resolveResourceGroup(Map keys) { * users may access any resource group. In single-tenancy mode, no restriction is applied. Throws * 404 if the resource group does not belong to the current tenant. */ - protected void ensureResourceGroupAccessible(String resourceGroupId) { - if (service.isProviderUser() || !service.isMultiTenancyEnabled()) { + protected void ensureResourceGroupAccessible(EventContext context, String resourceGroupId) { + if (isProviderUser(context) || !config.multiTenancyEnabled()) { return; } - String currentTenant = service.currentTenantId(); + String currentTenant = context.getUserInfo().getTenant(); if (currentTenant == null) { return; } - BckndResourceGroup rg = service.getResourceGroupApi().get(resourceGroupId); + BckndResourceGroup rg = clients.resourceGroupApi().get(resourceGroupId); if (rg.getLabels() != null && rg.getLabels().stream() .anyMatch( l -> - AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey()) + AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey()) && currentTenant.equals(l.getValue()))) { return; } throw new ServiceException(ErrorStatuses.NOT_FOUND, "Resource not found"); } + /** + * Returns whether the current request user is a system/provider user (bypasses tenant checks). + */ + protected static boolean isProviderUser(EventContext context) { + UserInfo userInfo = context.getUserInfo(); + return userInfo.isSystemUser() || userInfo.isInternalUser(); + } + protected static Map merge(Map keys, Map values) { Map merged = new HashMap<>(values); keys.forEach( diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java index 810068b..2c9d1e7 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -3,13 +3,16 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; -import com.sap.cds.services.EventContext; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.DeploymentsStopContext; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.util.Map; @@ -21,29 +24,22 @@ public class ActionHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class); - public ActionHandler(AICoreServiceImpl service) { - super(service); + public ActionHandler(AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } - @On(event = "stop", entity = AICoreService.DEPLOYMENTS) - public void onStop(EventContext context) { - Map keys = asMap(context.get("keys")); + @On(entity = Deployments_.CDS_NAME) + public void onStop(DeploymentsStopContext context) { + CqnAnalyzer analyzer = CqnAnalyzer.create(context.getModel()); + Map keys = analyzer.analyze(context.getCqn()).targetKeys(); + String deploymentId = (String) keys.get(Deployments.ID); - String resourceGroupId = resolveResourceGroup(keys); + String resourceGroupId = resolveResourceGroup(context, keys); - DeploymentApi api = service.getDeploymentApi(); AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create().targetStatus(AiDeploymentTargetStatus.STOPPED); - api.modify(resourceGroupId, deploymentId, modRequest); + clients.deploymentApi().modify(resourceGroupId, deploymentId, modRequest); logger.debug("Stopped deployment {} in resource group {}", deploymentId, resourceGroupId); context.setCompleted(); } - - @SuppressWarnings("unchecked") - private static Map asMap(Object obj) { - if (obj instanceof Map) { - return (Map) obj; - } - return Map.of(); - } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index bafdbff..d7a60df 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -3,15 +3,17 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.ConfigurationApi; import com.sap.ai.sdk.core.model.AiConfiguration; import com.sap.ai.sdk.core.model.AiConfigurationBaseData; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ArtifactArgumentBinding; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ParameterArgumentBinding; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ParameterArgumentBindingList; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; @@ -21,7 +23,6 @@ import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.cds.CdsCreateEventContext; import com.sap.cds.services.cds.CdsReadEventContext; -import com.sap.cds.services.cds.CqnService; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.util.ArrayList; @@ -36,14 +37,12 @@ public class ConfigurationHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ConfigurationHandler.class); - private final ConfigurationApi configurationApi; - - public ConfigurationHandler(AICoreServiceImpl service) { - super(service); - this.configurationApi = service.getConfigurationApi(); + public ConfigurationHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } - @On(event = CqnService.EVENT_READ, entity = AICoreService.CONFIGURATIONS) + @On(entity = Configurations_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -51,8 +50,8 @@ public void onRead(CdsReadEventContext context) { Map keys = analysis.targetKeys(); Map values = analysis.targetValues(); - String resourceGroupId = resolveResourceGroup(merge(keys, values)); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, merge(keys, values)); + ensureResourceGroupAccessible(context, resourceGroupId); logger.debug( "Reading configurations for resourceGroup={}, keys={}, values={}", resourceGroupId, @@ -61,12 +60,14 @@ public void onRead(CdsReadEventContext context) { String id = (String) keys.get(Configurations.ID); if (id != null) { - AiConfiguration config = configurationApi.get(resourceGroupId, id); + AiConfiguration config = clients.configurationApi().get(resourceGroupId, id); context.setResult(List.of(toConfigurations(config, resourceGroupId))); } else { String scenarioId = (String) values.get(Configurations.SCENARIO_ID); AiConfigurationList result = - configurationApi.query(resourceGroupId, scenarioId, null, null, null, null, null, null); + clients + .configurationApi() + .query(resourceGroupId, scenarioId, null, null, null, null, null, null); List> results = mapResources(result.getResources(), c -> toConfigurations(c, resourceGroupId)); logger.debug("ConfigurationApi.query returned {} resources", results.size()); @@ -74,13 +75,13 @@ public void onRead(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.CONFIGURATIONS) + @On(entity = Configurations_.CDS_NAME) public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); for (Configurations entry : entries) { - String resourceGroupId = resolveResourceGroup(entry); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, entry); + ensureResourceGroupAccessible(context, resourceGroupId); AiConfigurationBaseData request = AiConfigurationBaseData.create() @@ -97,7 +98,7 @@ public void onCreate(CdsCreateEventContext context, List entries request.parameterBindings(sdkBindings); } - var response = configurationApi.create(resourceGroupId, request); + var response = clients.configurationApi().create(resourceGroupId, request); entry.setId(response.getId()); results.add(entry); logger.debug( diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index 0b3df10..6e21697 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -3,7 +3,6 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.model.AiDeployment; import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; import com.sap.ai.sdk.core.model.AiDeploymentList; @@ -11,8 +10,11 @@ import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; @@ -25,7 +27,6 @@ import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; -import com.sap.cds.services.cds.CqnService; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.time.OffsetDateTime; @@ -40,14 +41,12 @@ public class DeploymentHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(DeploymentHandler.class); - private final DeploymentApi deploymentApi; - - public DeploymentHandler(AICoreServiceImpl service) { - super(service); - this.deploymentApi = service.getDeploymentApi(); + public DeploymentHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } - @On(event = CqnService.EVENT_READ, entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -55,28 +54,28 @@ public void onRead(CdsReadEventContext context) { Map keys = analysis.targetKeys(); Map values = analysis.targetValues(); - String resourceGroupId = resolveResourceGroup(merge(keys, values)); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, merge(keys, values)); + ensureResourceGroupAccessible(context, resourceGroupId); String id = (String) keys.get(Deployments.ID); if (id != null) { - AiDeploymentResponseWithDetails deployment = deploymentApi.get(resourceGroupId, id); + AiDeploymentResponseWithDetails deployment = clients.deploymentApi().get(resourceGroupId, id); context.setResult(List.of(toDeployments(deployment, resourceGroupId))); } else { AiDeploymentList result = - deploymentApi.query(resourceGroupId, null, null, null, null, null, null, null); + clients.deploymentApi().query(resourceGroupId, null, null, null, null, null, null, null); context.setResult( mapResources(result.getResources(), d -> toDeployments(d, resourceGroupId))); } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); for (Deployments entry : entries) { - String resourceGroupId = resolveResourceGroup(entry); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, entry); + ensureResourceGroupAccessible(context, resourceGroupId); String configurationId = entry.getConfigurationId(); AiDeploymentCreationRequest request = @@ -86,7 +85,7 @@ public void onCreate(CdsCreateEventContext context, List entries) { request.ttl(entry.getTtl()); } - var response = deploymentApi.create(resourceGroupId, request); + var response = clients.deploymentApi().create(resourceGroupId, request); entry.setId(response.getId()); entry.setStatus(response.getStatus().getValue()); results.add(entry); @@ -95,7 +94,7 @@ public void onCreate(CdsCreateEventContext context, List entries) { context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void onUpdate(CdsUpdateEventContext context, List entries) { if (entries.isEmpty()) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "No update payload provided"); @@ -112,8 +111,8 @@ public void onUpdate(CdsUpdateEventContext context, List entries) { Map keys = analyzer.analyze(context.getCqn()).targetKeys(); String deploymentId = (String) keys.get(Deployments.ID); - String resourceGroupId = resolveResourceGroup(merge(keys, data)); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, merge(keys, data)); + ensureResourceGroupAccessible(context, resourceGroupId); AiDeploymentModificationRequest modRequest = AiDeploymentModificationRequest.create(); @@ -124,12 +123,12 @@ public void onUpdate(CdsUpdateEventContext context, List entries) { modRequest.configurationId(data.getConfigurationId()); } - deploymentApi.modify(resourceGroupId, deploymentId, modRequest); + clients.deploymentApi().modify(resourceGroupId, deploymentId, modRequest); logger.debug("Updated deployment {} in resource group {}", deploymentId, resourceGroupId); context.setResult(List.of(data)); } - @On(event = CqnService.EVENT_DELETE, entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void onDelete(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -137,10 +136,10 @@ public void onDelete(CdsDeleteEventContext context) { Map keys = analyzer.analyze(delete).targetKeys(); String deploymentId = (String) keys.get(Deployments.ID); - String resourceGroupId = resolveResourceGroup(keys); - ensureResourceGroupAccessible(resourceGroupId); + String resourceGroupId = resolveResourceGroup(context, keys); + ensureResourceGroupAccessible(context, resourceGroupId); - deploymentApi.delete(resourceGroupId, deploymentId); + clients.deploymentApi().delete(resourceGroupId, deploymentId); logger.debug("Deleted deployment {} in resource group {}", deploymentId, resourceGroupId); context.setResult(List.of()); } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java new file mode 100644 index 0000000..f5155a2 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java @@ -0,0 +1,90 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Mock ON handler for the {@link AICoreService} API events when no AI Core binding is available. + * Uses in-memory maps instead of real API calls. + */ +@ServiceName(AICoreService.DEFAULT_NAME) +public class MockAICoreApiHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(MockAICoreApiHandler.class); + + private final AICoreConfig config; + private final Map tenantResourceGroupCache = new ConcurrentHashMap<>(); + private final Map deploymentCache = new ConcurrentHashMap<>(); + + public MockAICoreApiHandler(AICoreConfig config) { + this.config = config; + } + + @On + public void onResourceGroup(ResourceGroupContext context) { + String tenantId = context.getTenantId(); + if (tenantId == null) { + tenantId = context.getUserInfo().getTenant(); + } + if (!config.multiTenancyEnabled() || tenantId == null) { + context.setResult(config.defaultResourceGroup()); + return; + } + String finalTenantId = tenantId; + String result = + tenantResourceGroupCache.computeIfAbsent( + tenantId, id -> config.resourceGroupPrefix() + finalTenantId); + context.setResult(result); + } + + @On + public void onDeploymentId(DeploymentIdContext context) { + String resourceGroupId = context.getResourceGroupId(); + ModelDeploymentSpec spec = context.getSpec(); + String key = resourceGroupId + "::" + spec.configurationName(); + String result = deploymentCache.computeIfAbsent(key, k -> "mock-deployment-" + k); + context.setResult(result); + } + + @On + public void onInferenceClient(InferenceClientContext context) { + throw new ServiceException( + ErrorStatuses.NOT_IMPLEMENTED, + "Inference client is not available without an AI Core service binding"); + } + + /** Returns the mock tenant cache for test inspection. */ + public Map getTenantResourceGroupCache() { + return tenantResourceGroupCache; + } + + /** Returns the mock deployment cache for test inspection. */ + public Map getDeploymentCache() { + return deploymentCache; + } + + /** Evicts all entries for the given tenant. */ + public void clearTenantCache(String tenantId) { + String resourceGroupId = tenantResourceGroupCache.remove(tenantId); + if (resourceGroupId != null) { + String prefix = resourceGroupId + "::"; + deploymentCache.keySet().removeIf(k -> k.equals(resourceGroupId) || k.startsWith(prefix)); + } + } +} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index b0e7094..9471662 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -3,7 +3,6 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.ai.sdk.core.model.BckndResourceGroupList; @@ -11,8 +10,11 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.CdsData; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.ql.cqn.CqnDelete; @@ -20,12 +22,12 @@ import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.EventContext; import com.sap.cds.services.ServiceException; import com.sap.cds.services.cds.CdsCreateEventContext; import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; -import com.sap.cds.services.cds.CqnService; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.util.ArrayList; @@ -39,14 +41,12 @@ public class ResourceGroupHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ResourceGroupHandler.class); - private final ResourceGroupApi resourceGroupApi; - - public ResourceGroupHandler(AICoreServiceImpl service) { - super(service); - this.resourceGroupApi = service.getResourceGroupApi(); + public ResourceGroupHandler( + AICoreConfig config, AICoreClients clients, DeploymentResolver resolver) { + super(config, clients, resolver); } - @On(event = CqnService.EVENT_READ, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void onRead(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -61,18 +61,18 @@ public void onRead(CdsReadEventContext context) { } if (resourceGroupId != null) { - BckndResourceGroup rg = resourceGroupApi.get(resourceGroupId); - ensureOwnedByCurrentTenant(rg); + BckndResourceGroup rg = clients.resourceGroupApi().get(resourceGroupId); + ensureOwnedByCurrentTenant(context, rg); context.setResult(List.of(toMap(rg))); } else { - List labelSelector = buildTenantLabelSelector(values); + List labelSelector = buildTenantLabelSelector(context, values); BckndResourceGroupList result = - resourceGroupApi.getAll(null, null, null, null, null, null, labelSelector); + clients.resourceGroupApi().getAll(null, null, null, null, null, null, labelSelector); context.setResult(mapResources(result.getResources(), this::toMap)); } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); @@ -90,13 +90,12 @@ public void onCreate(CdsCreateEventContext context, List entries // the auto-generated one based on the tenantId field. boolean userSuppliedTenantLabel = labels != null - && labels.stream() - .anyMatch(l -> AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.get("key"))); + && labels.stream().anyMatch(l -> AICoreConfig.TENANT_LABEL_KEY.equals(l.get("key"))); if (entry.getTenantId() != null && !userSuppliedTenantLabel) { mergedLabels.add( BckndResourceGroupLabel.create() - .key(AICoreServiceImpl.TENANT_LABEL_KEY) + .key(AICoreConfig.TENANT_LABEL_KEY) .value(entry.getTenantId())); } @@ -108,22 +107,22 @@ public void onCreate(CdsCreateEventContext context, List entries request.labels(mergedLabels); } - resourceGroupApi.create(request); + clients.resourceGroupApi().create(request); logger.debug("Created resource group {}", resourceGroupId); results.add(entry); } context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void onUpdate(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); CqnAnalyzer analyzer = CqnAnalyzer.create(model); Map keys = analyzer.analyze(update).targetKeys(); - String resourceGroupId = resolveResourceGroupId(keys); - ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId)); + String resourceGroupId = resolveResourceGroupId(context, keys); + ensureOwnedByCurrentTenant(context, clients.resourceGroupApi().get(resourceGroupId)); Map data = update.entries().get(0); BckndResourceGroupPatchRequest patchRequest = BckndResourceGroupPatchRequest.create(); @@ -134,51 +133,51 @@ public void onUpdate(CdsUpdateEventContext context) { patchRequest.labels(toSdkLabels(labels)); } - resourceGroupApi.patch(resourceGroupId, patchRequest); + clients.resourceGroupApi().patch(resourceGroupId, patchRequest); logger.debug("Updated resource group {}", resourceGroupId); context.setResult(List.of(CdsData.create(data))); } - @On(event = CqnService.EVENT_DELETE, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void onDelete(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); CqnAnalyzer analyzer = CqnAnalyzer.create(model); Map keys = analyzer.analyze(delete).targetKeys(); - String resourceGroupId = resolveResourceGroupId(keys); - ensureOwnedByCurrentTenant(resourceGroupApi.get(resourceGroupId)); + String resourceGroupId = resolveResourceGroupId(context, keys); + ensureOwnedByCurrentTenant(context, clients.resourceGroupApi().get(resourceGroupId)); - resourceGroupApi.delete(resourceGroupId); + clients.resourceGroupApi().delete(resourceGroupId); logger.debug("Deleted resource group {}", resourceGroupId); context.setResult(List.of()); } - private String resolveResourceGroupId(Map keys) { + private String resolveResourceGroupId(EventContext context, Map keys) { if (keys.containsKey(ResourceGroups.RESOURCE_GROUP_ID)) { return (String) keys.get(ResourceGroups.RESOURCE_GROUP_ID); } if (keys.containsKey(ResourceGroups.TENANT_ID)) { - return service.resourceGroupForTenant((String) keys.get(ResourceGroups.TENANT_ID)); + return resolver.resolveResourceGroup((String) keys.get(ResourceGroups.TENANT_ID)); } - return service.resourceGroup(); + return resolver.resolveResourceGroup(context.getUserInfo().getTenant()); } /** * Builds a tenant-scoped label selector for list queries. In multi-tenancy mode, non-provider * users are restricted to their own tenant's resource groups. */ - private List buildTenantLabelSelector(Map values) { + private List buildTenantLabelSelector(EventContext context, Map values) { // If a specific tenantId is requested in the query, use that if (values.containsKey(ResourceGroups.TENANT_ID)) { String tenantId = (String) values.get(ResourceGroups.TENANT_ID); - return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + tenantId); + return List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + tenantId); } // In MT mode, restrict non-provider users to their own tenant - if (service.isMultiTenancyEnabled() && !service.isProviderUser()) { - String currentTenant = service.currentTenantId(); + if (config.multiTenancyEnabled() && !isProviderUser(context)) { + String currentTenant = context.getUserInfo().getTenant(); if (currentTenant != null) { - return List.of(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + currentTenant); + return List.of(AICoreConfig.TENANT_LABEL_KEY + "=" + currentTenant); } } return null; @@ -189,14 +188,14 @@ private List buildTenantLabelSelector(Map values) { * are allowed to access any resource group. Throws 404 if the resource group belongs to a * different tenant. */ - private void ensureOwnedByCurrentTenant(BckndResourceGroup rg) { - if (service.isProviderUser()) { + private void ensureOwnedByCurrentTenant(EventContext context, BckndResourceGroup rg) { + if (isProviderUser(context)) { return; } - if (!service.isMultiTenancyEnabled()) { + if (!config.multiTenancyEnabled()) { return; } - String currentTenant = service.currentTenantId(); + String currentTenant = context.getUserInfo().getTenant(); if (currentTenant == null) { return; } @@ -204,7 +203,7 @@ private void ensureOwnedByCurrentTenant(BckndResourceGroup rg) { && rg.getLabels().stream() .anyMatch( l -> - AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey()) + AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey()) && currentTenant.equals(l.getValue()))) { return; } @@ -234,7 +233,7 @@ private ResourceGroups toMap(BckndResourceGroup rg) { lm.setKey(l.getKey()); lm.setValue(l.getValue()); labels.add(lm); - if (AICoreServiceImpl.TENANT_LABEL_KEY.equals(l.getKey())) { + if (AICoreConfig.TENANT_LABEL_KEY.equals(l.getKey())) { data.setTenantId(l.getValue()); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index 77f7a2c..c6612a6 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -17,13 +17,13 @@ * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy * Mockito mocks. * - *

Since the test runtime has no service bindings, the configuration always registers a {@link - * MockAICoreServiceImpl} regardless of environment variables. + *

Since the test runtime has no service bindings, the configuration always registers an {@link + * AICoreServiceImpl} with mock handlers regardless of environment variables. */ class AICoreServiceConfigurationTest { @Test - void noBinding_noMultiTenancy_registersMockService() { + void noBinding_noMultiTenancy_registersService() { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) .cdsModel("edmx/csn.json") @@ -34,12 +34,11 @@ void noBinding_noMultiTenancy_registersMockService() { AICoreService service = runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - assertThat(service).isNotNull().isInstanceOf(MockAICoreServiceImpl.class); - assertThat(((MockAICoreServiceImpl) service).isMultiTenancyEnabled()).isFalse(); + assertThat(service).isNotNull().isInstanceOf(AICoreServiceImpl.class); } @Test - void noBinding_withSidecarUrl_registersMultiTenantMockService() { + void noBinding_withSidecarUrl_registersService() { CdsProperties props = new CdsProperties(); CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); @@ -57,8 +56,7 @@ void noBinding_withSidecarUrl_registersMultiTenantMockService() { AICoreService service = runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); - assertThat(service).isNotNull().isInstanceOf(MockAICoreServiceImpl.class); - assertThat(((MockAICoreServiceImpl) service).isMultiTenancyEnabled()).isTrue(); + assertThat(service).isNotNull().isInstanceOf(AICoreServiceImpl.class); } @Test diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index 4dc62c2..9c66431 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -25,11 +25,13 @@ import com.sap.ai.sdk.core.model.AiDeploymentStatus; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import java.lang.reflect.Field; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -37,9 +39,9 @@ import org.junit.jupiter.api.Test; /** - * Unit tests for the happy paths of {@link AICoreServiceImpl#deploymentId(String, - * ModelDeploymentSpec)}: cache hit on a RUNNING deployment, stale-cache invalidation when the - * cached deployment is gone, and reuse of an existing matching deployment found via query. + * Unit tests for the happy paths of deployment ID resolution: cache hit on a RUNNING deployment, + * stale-cache invalidation when the cached deployment is gone, and reuse of an existing matching + * deployment found via query. */ class AICoreServiceImplDeploymentIdTest { @@ -52,24 +54,39 @@ class AICoreServiceImplDeploymentIdTest { private ConfigurationApi configurationApi; private ResourceGroupApi resourceGroupApi; private AICoreServiceImpl service; + private DeploymentResolver resolver; private final ModelDeploymentSpec spec = new ModelDeploymentSpec(SCENARIO, "exec", CONFIG_NAME, List.of(), d -> true); private String cacheKey() { - return AICoreServiceImpl.deploymentCacheKey(RG, spec); + return DeploymentResolver.deploymentCacheKey(RG, spec); } - /** Boots a real CdsRuntime with the AICore model and fast retry settings. */ - private static CdsRuntime createTestRuntime() { + /** + * Creates an {@link AICoreServiceImpl} properly registered with a CDS runtime and the {@link + * AICoreApiHandler} so that {@code emit()} dispatches to the handler. + */ + private AICoreServiceImpl createService(boolean multiTenancy) { TestPropertiesProvider props = new TestPropertiesProvider(); props.setProperty("cds.ai.core.maxRetries", 1); props.setProperty("cds.ai.core.initialDelayMs", 1L); - return CdsRuntimeConfigurer.create(props) - .cdsModel("edmx/csn.json") - .serviceConfigurations() - .complete(); + CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(props); + configurer.cdsModel("edmx/csn.json"); + CdsRuntime runtime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 1, 1L, multiTenancy); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + AICoreServiceImpl svc = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(svc); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.complete(); + return svc; } @BeforeEach @@ -77,23 +94,12 @@ void setUp() { deploymentApi = mock(DeploymentApi.class); configurationApi = mock(ConfigurationApi.class); resourceGroupApi = mock(ResourceGroupApi.class); - - CdsRuntime runtime = createTestRuntime(); - - service = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - false, - deploymentApi, - configurationApi, - resourceGroupApi, - mock(AiCoreService.class)); + service = createService(false); } @Test - void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() { - service.getResourceGroupDeploymentCache().put(cacheKey(), DEPLOYMENT_ID); + void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() throws Exception { + putInDeploymentCache(resolver, cacheKey(), DEPLOYMENT_ID); AiDeploymentResponseWithDetails running = mock(AiDeploymentResponseWithDetails.class); when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); @@ -108,9 +114,9 @@ void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() { } @Test - void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() { + void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() throws Exception { String otherDeployment = "dep-456"; - service.getResourceGroupDeploymentCache().put(cacheKey(), "stale-id"); + putInDeploymentCache(resolver, cacheKey(), "stale-id"); OpenApiRequestException notFound = new OpenApiRequestException("gone").statusCode(404); when(deploymentApi.get(RG, "stale-id")).thenThrow(notFound); @@ -127,28 +133,26 @@ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() { String result = service.deploymentId(RG, spec); assertThat(result).isEqualTo(otherDeployment); - assertThat(service.getResourceGroupDeploymentCache()) - .containsEntry(cacheKey(), otherDeployment); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), otherDeployment); verify(deploymentApi, never()).create(any(), any()); } @Test - void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() { - service.getResourceGroupDeploymentCache().put(cacheKey(), "still-valid-id"); + void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() throws Exception { + putInDeploymentCache(resolver, cacheKey(), "still-valid-id"); OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); - assertThatThrownBy(() -> service.deploymentId(RG, spec)).isSameAs(serverError); + assertThatThrownBy(() -> service.deploymentId(RG, spec)).rootCause().isSameAs(serverError); - assertThat(service.getResourceGroupDeploymentCache()) - .containsEntry(cacheKey(), "still-valid-id"); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), "still-valid-id"); verify(deploymentApi, never()).query(any(), any(), any(), any(), any(), any(), any(), any()); verify(deploymentApi, never()).create(any(), any()); } @Test - void noCache_existingMatchingDeployment_isReusedAndCached() { + void noCache_existingMatchingDeployment_isReusedAndCached() throws Exception { AiDeployment existing = mock(AiDeployment.class); when(existing.getId()).thenReturn(DEPLOYMENT_ID); when(existing.getConfigurationName()).thenReturn(CONFIG_NAME); @@ -161,7 +165,7 @@ void noCache_existingMatchingDeployment_isReusedAndCached() { String result = service.deploymentId(RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); - assertThat(service.getResourceGroupDeploymentCache()).containsEntry(cacheKey(), DEPLOYMENT_ID); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); verify(deploymentApi, never()).create(any(), any()); verify(deploymentApi, never()).get(any(), any()); } @@ -193,17 +197,7 @@ void secondCallUsesCachedResult_singleQueryToApi() { @Test void resourceGroupForTenant_nullTenantId_returnsDefault() { - CdsRuntime runtime = createTestRuntime(); - - AICoreServiceImpl mtService = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - true, // multi-tenancy enabled - deploymentApi, - configurationApi, - resourceGroupApi, - mock(AiCoreService.class)); + AICoreServiceImpl mtService = createService(true); String result = mtService.resourceGroupForTenant(null); assertThat(result).isEqualTo("default"); @@ -216,7 +210,7 @@ void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { } @Test - void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { + void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() throws Exception { AiDeploymentList emptyList = mock(AiDeploymentList.class); when(emptyList.getResources()).thenReturn(List.of()); when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) @@ -241,11 +235,32 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { String result = service.deploymentId(RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); - assertThat(service.getResourceGroupDeploymentCache()).containsEntry(cacheKey(), DEPLOYMENT_ID); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); verify(configurationApi, never()).create(any(), any()); verify(deploymentApi).create(eq(RG), any()); } + // ────────────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); + field.setAccessible(true); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); + } + + @SuppressWarnings("unchecked") + private static Map getDeploymentCache(DeploymentResolver resolver) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); + field.setAccessible(true); + return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap(); + } + /** Properties provider that allows overriding specific keys for test configuration. */ private static class TestPropertiesProvider extends SimplePropertiesProvider { private final Map properties = new HashMap<>(); diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java index 898e1db..78fb35b 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplTest.java @@ -4,12 +4,11 @@ package com.sap.cds.feature.aicore.core; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import java.lang.reflect.Field; import java.util.concurrent.ConcurrentHashMap; @@ -17,12 +16,14 @@ class AICoreServiceImplTest { + private static final AICoreConfig CONFIG = new AICoreConfig("default", "cds-", 10, 300, true); + @Test void notReadyYet_topLevel403_returnsTrue() { OpenApiRequestException e = mock(OpenApiRequestException.class); when(e.statusCode()).thenReturn(403); - assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue(); + assertThat(DeploymentResolver.notReadyYet(e)).isTrue(); } @Test @@ -30,7 +31,7 @@ void notReadyYet_topLevel412_returnsTrue() { OpenApiRequestException e = mock(OpenApiRequestException.class); when(e.statusCode()).thenReturn(412); - assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue(); + assertThat(DeploymentResolver.notReadyYet(e)).isTrue(); } @Test @@ -38,7 +39,7 @@ void notReadyYet_topLevel404_returnsTrue() { OpenApiRequestException e = mock(OpenApiRequestException.class); when(e.statusCode()).thenReturn(404); - assertThat(AICoreServiceImpl.notReadyYet(e)).isTrue(); + assertThat(DeploymentResolver.notReadyYet(e)).isTrue(); } @Test @@ -46,7 +47,7 @@ void notReadyYet_topLevel500_returnsFalse() { OpenApiRequestException e = mock(OpenApiRequestException.class); when(e.statusCode()).thenReturn(500); - assertThat(AICoreServiceImpl.notReadyYet(e)).isFalse(); + assertThat(DeploymentResolver.notReadyYet(e)).isFalse(); } @Test @@ -58,7 +59,7 @@ void notReadyYet_topLevel500WrappingInner403_returnsTrue() { when(outer.statusCode()).thenReturn(500); when(outer.getCause()).thenReturn(inner); - assertThat(AICoreServiceImpl.notReadyYet(outer)).isTrue(); + assertThat(DeploymentResolver.notReadyYet(outer)).isTrue(); } @Test @@ -66,7 +67,7 @@ void notReadyYet_nullStatusCodeOnAllLevels_returnsFalse() { OpenApiRequestException e = mock(OpenApiRequestException.class); when(e.statusCode()).thenReturn(null); - assertThat(AICoreServiceImpl.notReadyYet(e)).isFalse(); + assertThat(DeploymentResolver.notReadyYet(e)).isFalse(); } @Test @@ -74,7 +75,7 @@ void deploymentLocksFieldIsConcurrentHashMap() throws NoSuchFieldException { // Locks must live in a non-evicting map: a Caffeine cache could evict an entry between two // threads' lookups, causing them to synchronize on different objects for the same cache key // and race to create duplicate AI Core deployments. - Field field = AICoreServiceImpl.class.getDeclaredField("deploymentLocks"); + Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks"); assertThat(field.getType()).isEqualTo(ConcurrentHashMap.class); } @@ -91,98 +92,111 @@ void concurrentHashMapComputeIfAbsentReturnsSameLockObjectForSameKey() { } @Test - void clearTenantCacheRemovesAllRelatedEntries() throws Exception { + void invalidateTenantRemovesAllRelatedEntries() throws Exception { String tenantId = "tenant-1"; String resourceGroupId = "cds-tenant-1"; - AICoreServiceImpl service = freshService(); - Cache tenantCache = readCache(service, "tenantResourceGroupCache"); - Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); - ConcurrentHashMap deploymentLocks = readLocks(service); - - tenantCache.put(tenantId, resourceGroupId); - deploymentCache.put(resourceGroupId, "deployment-id"); - deploymentLocks.put(resourceGroupId, new Object()); + DeploymentResolver resolver = freshResolver(); + putInTenantCache(resolver, tenantId, resourceGroupId); + putInDeploymentCache(resolver, resourceGroupId, "deployment-id"); + putInDeploymentLocks(resolver, resourceGroupId); - service.clearTenantCache(tenantId); + resolver.invalidateTenant(tenantId); - assertThat(tenantCache.asMap()).doesNotContainKey(tenantId); - assertThat(deploymentCache.asMap()).doesNotContainKey(resourceGroupId); - assertThat(deploymentLocks).doesNotContainKey(resourceGroupId); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(tenantId); + assertThat(getDeploymentCache(resolver)).doesNotContainKey(resourceGroupId); + assertThat(getDeploymentLocks(resolver)).doesNotContainKey(resourceGroupId); } @Test - void clearTenantCacheLeavesOtherTenantsUntouched() throws Exception { + void invalidateTenantLeavesOtherTenantsUntouched() throws Exception { String tenantA = "tenant-a"; String resourceGroupA = "cds-tenant-a"; String tenantB = "tenant-b"; String resourceGroupB = "cds-tenant-b"; - AICoreServiceImpl service = freshService(); - Cache tenantCache = readCache(service, "tenantResourceGroupCache"); - Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); - ConcurrentHashMap deploymentLocks = readLocks(service); + DeploymentResolver resolver = freshResolver(); + putInTenantCache(resolver, tenantA, resourceGroupA); + putInTenantCache(resolver, tenantB, resourceGroupB); + putInDeploymentCache(resolver, resourceGroupA, "deployment-a"); + putInDeploymentCache(resolver, resourceGroupB, "deployment-b"); + putInDeploymentLocks(resolver, resourceGroupA); + putInDeploymentLocks(resolver, resourceGroupB); - tenantCache.put(tenantA, resourceGroupA); - tenantCache.put(tenantB, resourceGroupB); - deploymentCache.put(resourceGroupA, "deployment-a"); - deploymentCache.put(resourceGroupB, "deployment-b"); - deploymentLocks.put(resourceGroupA, new Object()); - deploymentLocks.put(resourceGroupB, new Object()); + resolver.invalidateTenant(tenantA); - service.clearTenantCache(tenantA); - - assertThat(tenantCache.asMap()).doesNotContainKey(tenantA).containsKey(tenantB); - assertThat(deploymentCache.asMap()) + assertThat(resolver.getTenantResourceGroupCacheView()) + .doesNotContainKey(tenantA) + .containsKey(tenantB); + assertThat(getDeploymentCache(resolver)) + .doesNotContainKey(resourceGroupA) + .containsKey(resourceGroupB); + assertThat(getDeploymentLocks(resolver)) .doesNotContainKey(resourceGroupA) .containsKey(resourceGroupB); - assertThat(deploymentLocks).doesNotContainKey(resourceGroupA).containsKey(resourceGroupB); } @Test - void clearTenantCacheIsNoOpForUnknownTenant() throws Exception { + void invalidateTenantIsNoOpForUnknownTenant() throws Exception { String resourceGroupId = "cds-tenant-1"; - AICoreServiceImpl service = freshService(); - Cache deploymentCache = readCache(service, "resourceGroupDeploymentCache"); - ConcurrentHashMap deploymentLocks = readLocks(service); + DeploymentResolver resolver = freshResolver(); + putInDeploymentCache(resolver, resourceGroupId, "deployment-id"); + putInDeploymentLocks(resolver, resourceGroupId); - deploymentCache.put(resourceGroupId, "deployment-id"); - deploymentLocks.put(resourceGroupId, new Object()); + resolver.invalidateTenant("unknown-tenant"); - service.clearTenantCache("unknown-tenant"); + assertThat(getDeploymentCache(resolver)).containsKey(resourceGroupId); + assertThat(getDeploymentLocks(resolver)).containsKey(resourceGroupId); + } - assertThat(deploymentCache.asMap()).containsKey(resourceGroupId); - assertThat(deploymentLocks).containsKey(resourceGroupId); + private static DeploymentResolver freshResolver() { + DeploymentApi deploymentApi = mock(DeploymentApi.class); + ResourceGroupApi resourceGroupApi = mock(ResourceGroupApi.class); + return new DeploymentResolver(CONFIG, deploymentApi, resourceGroupApi); } - private static AICoreServiceImpl freshService() throws Exception { - AICoreServiceImpl service = mock(AICoreServiceImpl.class, CALLS_REAL_METHODS); - setField(service, "tenantResourceGroupCache", Caffeine.newBuilder().build()); - setField(service, "resourceGroupDeploymentCache", Caffeine.newBuilder().build()); - setField(service, "deploymentLocks", new ConcurrentHashMap<>()); - return service; + @SuppressWarnings("unchecked") + private static void putInTenantCache(DeploymentResolver resolver, String key, String value) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("tenantResourceGroupCache"); + field.setAccessible(true); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); } @SuppressWarnings("unchecked") - private static Cache readCache(AICoreServiceImpl service, String fieldName) + private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value) throws Exception { - Field field = AICoreServiceImpl.class.getDeclaredField(fieldName); + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); field.setAccessible(true); - return (Cache) field.get(service); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); } @SuppressWarnings("unchecked") - private static ConcurrentHashMap readLocks(AICoreServiceImpl service) + private static java.util.Map getDeploymentCache(DeploymentResolver resolver) throws Exception { - Field field = AICoreServiceImpl.class.getDeclaredField("deploymentLocks"); + Field field = DeploymentResolver.class.getDeclaredField("deploymentCache"); field.setAccessible(true); - return (ConcurrentHashMap) field.get(service); + return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap(); } - private static void setField(Object target, String fieldName, Object value) throws Exception { - Field field = AICoreServiceImpl.class.getDeclaredField(fieldName); + private static void putInDeploymentLocks(DeploymentResolver resolver, String key) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentHashMap locks = + (ConcurrentHashMap) field.get(resolver); + locks.put(key, new Object()); + } + + @SuppressWarnings("unchecked") + private static ConcurrentHashMap getDeploymentLocks(DeploymentResolver resolver) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("deploymentLocks"); field.setAccessible(true); - field.set(target, value); + return (ConcurrentHashMap) field.get(resolver); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java index f475e5e..a379857 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java @@ -12,15 +12,17 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupList; import com.sap.cds.services.ServiceException; import com.sap.cds.services.mt.UnsubscribeEventContext; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; -import java.util.HashMap; +import java.lang.reflect.Field; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -34,31 +36,37 @@ class AICoreSetupHandlerTest { private static final String TENANT = "tenant-1"; private static final String RG_ID = "cds-tenant-1"; - @Mock private AICoreServiceImpl service; @Mock private ResourceGroupApi resourceGroupApi; @Mock private UnsubscribeEventContext unsubscribeContext; - private Map tenantCache; + private DeploymentResolver resolver; + private AICoreClients clients; private AICoreSetupHandler cut; @BeforeEach void setUp() { - tenantCache = new HashMap<>(); - when(service.getTenantResourceGroupCache()).thenReturn(tenantCache); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + clients = + new AICoreClients( + deploymentApi, + mock(ConfigurationApi.class), + resourceGroupApi, + mock(AiCoreService.class)); + resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); when(unsubscribeContext.getTenant()).thenReturn(TENANT); - cut = new AICoreSetupHandler(service); + cut = new AICoreSetupHandler(clients, resolver); } @Test - void cacheHit_deletesAndClears() { - tenantCache.put(TENANT, RG_ID); + void cacheHit_deletesAndClears() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); cut.beforeUnsubscribe(unsubscribeContext); verify(resourceGroupApi).delete(RG_ID); verify(resourceGroupApi, never()).getAll(any(), any(), any(), any(), any(), any(), any()); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test @@ -73,9 +81,9 @@ void cacheMiss_fallsBackToApiAndDeletes() { cut.beforeUnsubscribe(unsubscribeContext); assertThat(labelCaptor.getValue()) - .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=" + TENANT); + .containsExactly(AICoreConfig.TENANT_LABEL_KEY + "=" + TENANT); verify(resourceGroupApi).delete(RG_ID); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test @@ -87,7 +95,7 @@ void cacheMissAndApiReturnsEmpty_isNoOp() { cut.beforeUnsubscribe(unsubscribeContext); verify(resourceGroupApi, never()).delete(any()); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test @@ -99,24 +107,24 @@ void cacheMissAndApiReturnsNullResources_isNoOp() { cut.beforeUnsubscribe(unsubscribeContext); verify(resourceGroupApi, never()).delete(any()); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test - void deleteReturns404_treatedAsSuccess() { - tenantCache.put(TENANT, RG_ID); + void deleteReturns404_treatedAsSuccess() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); OpenApiRequestException notFound = new OpenApiRequestException("not found").statusCode(404); when(resourceGroupApi.delete(RG_ID)).thenThrow(notFound); cut.beforeUnsubscribe(unsubscribeContext); verify(resourceGroupApi).delete(RG_ID); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test - void deleteReturnsOther5xx_propagatesAsServiceException() { - tenantCache.put(TENANT, RG_ID); + void deleteReturnsOther5xx_propagatesAsServiceException() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(500); when(resourceGroupApi.delete(RG_ID)).thenThrow(serverError); @@ -124,21 +132,12 @@ void deleteReturnsOther5xx_propagatesAsServiceException() { .isInstanceOf(ServiceException.class) .hasCauseReference(serverError); // Cache still cleared in finally. - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test - void unsubscribeTwice_secondCallIsNoOp() { - tenantCache.put(TENANT, RG_ID); - // First call uses cache, deletes successfully and clears (we simulate clearTenantCache - // by removing from the underlying map). - org.mockito.Mockito.doAnswer( - inv -> { - tenantCache.remove(TENANT); - return null; - }) - .when(service) - .clearTenantCache(TENANT); + void unsubscribeTwice_secondCallIsNoOp() throws Exception { + putInTenantCache(resolver, TENANT, RG_ID); cut.beforeUnsubscribe(unsubscribeContext); @@ -151,7 +150,6 @@ void unsubscribeTwice_secondCallIsNoOp() { verify(resourceGroupApi, times(1)).delete(RG_ID); verify(resourceGroupApi, times(1)).getAll(any(), any(), any(), any(), any(), any(), any()); - verify(service, times(2)).clearTenantCache(TENANT); } @Test @@ -163,7 +161,6 @@ void getAllThrows_wrappedInServiceException() { .isInstanceOf(ServiceException.class) .hasCauseReference(boom); verify(resourceGroupApi, never()).delete(any()); - verify(service).clearTenantCache(TENANT); } @SuppressWarnings({"unchecked", "rawtypes"}) @@ -176,4 +173,13 @@ private static BckndResourceGroupList listOf(List resources) when(list.getResources()).thenReturn(resources); return list; } + + @SuppressWarnings("unchecked") + private static void putInTenantCache(DeploymentResolver resolver, String key, String value) + throws Exception { + Field field = DeploymentResolver.class.getDeclaredField("tenantResourceGroupCache"); + field.setAccessible(true); + ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)) + .put(key, value); + } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java index 0dd1660..c555b58 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -4,111 +4,84 @@ package com.sap.cds.feature.aicore.core; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.reflect.CdsService; -import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.List; import org.junit.jupiter.api.Test; +/** + * Tests for {@link MockAICoreApiHandler} verifying the mock behavior when no AI Core binding is + * present. These tests boot a real CDS runtime with mock handlers to validate end-to-end flow. + */ class MockAICoreServiceImplTest { - private MockAICoreServiceImpl createService(boolean multiTenancyEnabled) { - CdsRuntime runtime = mock(CdsRuntime.class); - CdsEnvironment env = mock(CdsEnvironment.class); - when(runtime.getEnvironment()).thenReturn(env); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("test-rg"); - when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("prefix-"); - return new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); + private AICoreService createMockService(boolean multiTenancy) { + CdsProperties props = new CdsProperties(); + if (multiTenancy) { + CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); + CdsProperties.MultiTenancy.Sidecar sidecar = new CdsProperties.MultiTenancy.Sidecar(); + sidecar.setUrl("http://localhost:4004"); + mt.setSidecar(sidecar); + props.setMultiTenancy(mt); + } + + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } @Test - void defaultConstructor_setsMultiTenancyFalse() { - CdsRuntime runtime = mock(CdsRuntime.class); - CdsEnvironment env = mock(CdsEnvironment.class); - when(runtime.getEnvironment()).thenReturn(env); - CdsModel cdsModel = mock(CdsModel.class); - when(runtime.getCdsModel()).thenReturn(cdsModel); - when(cdsModel.getService("AICore")).thenReturn(mock(CdsService.class)); - when(env.getProperty(eq("cds.ai.core.resourceGroup"), eq(String.class), any())) - .thenReturn("default"); - when(env.getProperty(eq("cds.ai.core.resourceGroupPrefix"), eq(String.class), any())) - .thenReturn("cds-"); - - MockAICoreServiceImpl service = new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - assertThat(service.isMultiTenancyEnabled()).isFalse(); + void noMultiTenancy_resourceGroupReturnsDefault() { + AICoreService service = createMockService(false); + assertThat(service.resourceGroup()).isEqualTo("default"); } @Test - void mtConstructor_setsMultiTenancyTrue() { - MockAICoreServiceImpl service = createService(true); - assertThat(service.isMultiTenancyEnabled()).isTrue(); + void noMultiTenancy_resourceGroupForTenant_returnsDefault() { + AICoreService service = createMockService(false); + assertThat(service.resourceGroupForTenant("any-tenant")).isEqualTo("default"); } @Test - void resourceGroupForTenant_mtDisabled_alwaysReturnsDefault() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.resourceGroupForTenant("tenant-x")).isEqualTo("test-rg"); - assertThat(service.resourceGroupForTenant("tenant-y")).isEqualTo("test-rg"); - } - - @Test - void resourceGroupForTenant_mtEnabled_returnsPrefixedTenantId() { - MockAICoreServiceImpl service = createService(true); + void multiTenancy_resourceGroupForTenant_returnsPrefixed() { + AICoreService service = createMockService(true); String rg = service.resourceGroupForTenant("my-tenant"); - assertThat(rg).isEqualTo("prefix-my-tenant"); + assertThat(rg).isEqualTo("cds-my-tenant"); } @Test - void resourceGroupForTenant_mtEnabled_cachesResult() { - MockAICoreServiceImpl service = createService(true); + void multiTenancy_resourceGroupForTenant_cachesResult() { + AICoreService service = createMockService(true); String first = service.resourceGroupForTenant("t1"); String second = service.resourceGroupForTenant("t1"); - assertThat(first).isSameAs(second); - assertThat(service.getTenantResourceGroupCache()).containsKey("t1"); - } - - @Test - void clearTenantCache_removesCorrectEntries() { - MockAICoreServiceImpl service = createService(true); - service.resourceGroupForTenant("t1"); - service.resourceGroupForTenant("t2"); - var spec = new com.sap.cds.feature.aicore.api.ModelDeploymentSpec( - "scenario", "exec", "cfg1", java.util.List.of(), d -> true); - service.deploymentId("prefix-t1", spec); - - service.clearTenantCache("t1"); - - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("t1"); - assertThat(service.getTenantResourceGroupCache()).containsKey("t2"); - assertThat(service.getResourceGroupDeploymentCache()).doesNotContainKeys("prefix-t1::cfg1"); - } - - @Test - void getRetry_returnsNonNull() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.getRetry()).isNotNull(); + assertThat(first).isEqualTo(second); } @Test - void getDefaultResourceGroup_readsFromConfig() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.getDefaultResourceGroup()).isEqualTo("test-rg"); + void deploymentId_returnsMockId() { + AICoreService service = createMockService(false); + var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); + String id = service.deploymentId("default", spec); + assertThat(id).startsWith("mock-deployment-"); } @Test - void getResourceGroupPrefix_readsFromConfig() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.getResourceGroupPrefix()).isEqualTo("prefix-"); + void deploymentId_cachesSameResult() { + AICoreService service = createMockService(false); + var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); + String first = service.deploymentId("default", spec); + String second = service.deploymentId("default", spec); + assertThat(first).isEqualTo(second); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java index 03e0579..7fdb3e2 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java @@ -5,65 +5,157 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.ai.sdk.core.AiCoreService; import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiConfiguration; +import com.sap.ai.sdk.core.model.AiConfigurationBaseData; +import com.sap.ai.sdk.core.model.AiConfigurationCreationResponse; import com.sap.ai.sdk.core.model.AiConfigurationList; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; -import com.sap.cds.ql.cqn.AnalysisResult; -import com.sap.cds.ql.cqn.CqnAnalyzer; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.services.cds.CdsReadEventContext; -import java.util.HashMap; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.List; import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) +/** + * Integration-style tests for {@link ConfigurationHandler} using a real CDS runtime. Only the SDK + * API clients are mocked since they talk to a remote AI Core service. + */ class ConfigurationHandlerTest { - @Mock private AICoreServiceImpl service; - @Mock private ConfigurationApi configurationApi; - @Mock private CdsReadEventContext context; - @Mock private CqnSelect select; - @Mock private CdsModel model; - @Mock private CqnAnalyzer analyzer; - @Mock private AnalysisResult analysisResult; + private static CdsRuntime runtime; + private static AICoreServiceImpl service; + private static ConfigurationApi configurationApi; + private static ResourceGroupApi resourceGroupApi; + + @BeforeAll + static void bootRuntime() { + configurationApi = mock(ConfigurationApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + runtime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ConfigurationHandler(config, clients, resolver)); + configurer.complete(); + } + + @BeforeEach + void clearMockInvocations() { + clearInvocations(configurationApi, resourceGroupApi); + } @Test - void onRead_nullResources_returnsEmptyListWithoutNpe() { - when(service.getConfigurationApi()).thenReturn(configurationApi); - when(context.getCqn()).thenReturn(select); - when(context.getModel()).thenReturn(model); - when(analyzer.analyze(select)).thenReturn(analysisResult); - when(analysisResult.targetKeys()).thenReturn(new HashMap<>()); - when(analysisResult.targetValues()).thenReturn(new HashMap<>()); - when(service.resolveResourceGroupFromKeys(any())).thenReturn("default"); + void onRead_returnsConfigurationsForResourceGroup() { + AiConfiguration cfg = mock(AiConfiguration.class); + when(cfg.getId()).thenReturn("cfg-1"); + when(cfg.getName()).thenReturn("my-config"); + when(cfg.getExecutableId()).thenReturn("exec-1"); + when(cfg.getScenarioId()).thenReturn("foundation-models"); + + AiConfigurationList list = mock(AiConfigurationList.class); + when(list.getResources()).thenReturn(List.of(cfg)); + when(configurationApi.query(eq("default"), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(list); + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.configurations") + .where(c -> c.get("resourceGroup_resourceGroupId").eq("default")))); + + verify(configurationApi).query(eq("default"), any(), any(), any(), any(), any(), any(), any()); + assertThat(result.list()).hasSize(1); + assertThat(result.single().get("id")).isEqualTo("cfg-1"); + assertThat(result.single().get("name")).isEqualTo("my-config"); + } + + @Test + void onRead_nullResources_returnsEmptyList() { AiConfigurationList listWithNullResources = mock(AiConfigurationList.class); when(listWithNullResources.getResources()).thenReturn(null); - when(configurationApi.query(any(), any(), any(), any(), any(), any(), any(), any())) + when(configurationApi.query(eq("default"), any(), any(), any(), any(), any(), any(), any())) .thenReturn(listWithNullResources); - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.configurations") + .where(c -> c.get("resourceGroup_resourceGroupId").eq("default")))); + + assertThat(result.list()).isEmpty(); + } + + @Test + void onCreate_createsConfiguration() { + AiConfigurationCreationResponse response = mock(AiConfigurationCreationResponse.class); + when(response.getId()).thenReturn("new-cfg-id"); + when(configurationApi.create(eq("default"), any(AiConfigurationBaseData.class))) + .thenReturn(response); - ConfigurationHandler handler = new ConfigurationHandler(service); - handler.onRead(context); - } + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.configurations") + .entry( + Map.of( + "name", "test-config", + "executableId", "exec-1", + "scenarioId", "foundation-models", + "resourceGroup_resourceGroupId", "default")))); - @SuppressWarnings("unchecked") - ArgumentCaptor>> captor = ArgumentCaptor.forClass(List.class); - verify(context).setResult(captor.capture()); - assertThat(captor.getValue()).isEmpty(); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiConfigurationBaseData.class); + verify(configurationApi).create(eq("default"), captor.capture()); + assertThat(captor.getValue().getName()).isEqualTo("test-config"); + assertThat(captor.getValue().getExecutableId()).isEqualTo("exec-1"); + assertThat(captor.getValue().getScenarioId()).isEqualTo("foundation-models"); + assertThat(result.single().get("id")).isEqualTo("new-cfg-id"); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index cc26a3c..0e47302 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -7,187 +7,190 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; import com.sap.ai.sdk.core.client.DeploymentApi; +import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.AiDeploymentCreationRequest; import com.sap.ai.sdk.core.model.AiDeploymentCreationResponse; import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiExecutionStatus; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; -import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; -import com.sap.cds.ql.cqn.AnalysisResult; -import com.sap.cds.ql.cqn.CqnAnalyzer; -import com.sap.cds.ql.cqn.CqnUpdate; -import com.sap.cds.reflect.CdsModel; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Update; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; -import com.sap.cds.services.cds.CdsCreateEventContext; -import com.sap.cds.services.cds.CdsUpdateEventContext; -import java.util.HashMap; -import java.util.List; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) +/** + * Integration-style tests for {@link DeploymentHandler} using a real CDS runtime. Only the SDK API + * clients (DeploymentApi, ResourceGroupApi, ConfigurationApi) are mocked since they talk to a + * remote AI Core service. + */ class DeploymentHandlerTest { - @Mock private AICoreServiceImpl service; - @Mock private DeploymentApi deploymentApi; - @Mock private CdsUpdateEventContext updateContext; - @Mock private CdsCreateEventContext createContext; - - private DeploymentHandler cut; + private static CdsRuntime runtime; + private static AICoreServiceImpl service; + private static DeploymentApi deploymentApi; + private static ResourceGroupApi resourceGroupApi; + private static ConfigurationApi configurationApi; + + @BeforeAll + static void bootRuntime() { + deploymentApi = mock(DeploymentApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + configurationApi = mock(ConfigurationApi.class); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + runtime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.complete(); + } @BeforeEach - void setup() { - when(service.getDeploymentApi()).thenReturn(deploymentApi); - cut = new DeploymentHandler(service); + void clearMockInvocations() { + clearInvocations(deploymentApi, resourceGroupApi, configurationApi); } @Test - void onUpdate_emptyEntries_throwsBadRequest() { - List entries = List.of(); + void onCreate_createsDeploymentWithConfigurationId() { + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("new-dep-id"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(deploymentApi.create(eq("default"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); - assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) - .isInstanceOfSatisfying( - ServiceException.class, - e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) - .hasMessageContaining("No update payload provided"); + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.deployments") + .entry( + Map.of( + "configurationId", "cfg-1", + "resourceGroup_resourceGroupId", "default")))); - verifyNoInteractions(deploymentApi); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("default"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("cfg-1"); + assertThat(result.single().get("id")).isEqualTo("new-dep-id"); } @Test - void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { - List entries = List.of(Deployments.of(Map.of("ttl", "1d"))); + void onCreate_withTtl_setsTtlOnRequest() { + AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); + when(response.getId()).thenReturn("dep-ttl"); + when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); + when(deploymentApi.create(eq("default"), any(AiDeploymentCreationRequest.class))) + .thenReturn(response); - assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) - .isInstanceOfSatisfying( - ServiceException.class, - e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) - .hasMessageContaining("targetStatus") - .hasMessageContaining("configurationId"); + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.deployments") + .entry( + Map.of( + "configurationId", "cfg-2", + "ttl", "PT24H", + "resourceGroup_resourceGroupId", "default")))); - verifyNoInteractions(deploymentApi); + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); + verify(deploymentApi).create(eq("default"), captor.capture()); + assertThat(captor.getValue().getTtl()).isEqualTo("PT24H"); } @Test void onUpdate_withTargetStatus_callsModifyWithTargetStatus() { - Deployments data = Deployments.create(); - data.setTargetStatus("STOPPED"); - List entries = List.of(data); - - CqnUpdate cqnUpdate = mock(CqnUpdate.class); - CdsModel model = mock(CdsModel.class); - CqnAnalyzer analyzer = mock(CqnAnalyzer.class); - AnalysisResult analysisResult = mock(AnalysisResult.class); - - when(updateContext.getCqn()).thenReturn(cqnUpdate); - when(updateContext.getModel()).thenReturn(model); - Map keys = new HashMap<>(); - keys.put(Deployments.ID, "dep-123"); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); - when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-1"); - when(service.isProviderUser()).thenReturn(true); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - cut.onUpdate(updateContext, entries); - } + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity("AICore.deployments") + .where(d -> d.get("id").eq("dep-123")) + .data("targetStatus", "STOPPED"))); ArgumentCaptor captor = ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); - verify(deploymentApi).modify(eq("rg-1"), eq("dep-123"), captor.capture()); + verify(deploymentApi).modify(eq("default"), eq("dep-123"), captor.capture()); assertThat(captor.getValue().getTargetStatus().getValue()).isEqualTo("STOPPED"); } @Test void onUpdate_withConfigurationId_callsModifyWithConfigurationId() { - Deployments data = Deployments.create(); - data.setConfigurationId("config-456"); - List entries = List.of(data); - - CqnUpdate cqnUpdate = mock(CqnUpdate.class); - CdsModel model = mock(CdsModel.class); - CqnAnalyzer analyzer = mock(CqnAnalyzer.class); - AnalysisResult analysisResult = mock(AnalysisResult.class); - - when(updateContext.getCqn()).thenReturn(cqnUpdate); - when(updateContext.getModel()).thenReturn(model); - Map keys = new HashMap<>(); - keys.put(Deployments.ID, "dep-789"); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); - when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-2"); - when(service.isProviderUser()).thenReturn(true); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - cut.onUpdate(updateContext, entries); - } + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity("AICore.deployments") + .where(d -> d.get("id").eq("dep-789")) + .data("configurationId", "config-456"))); ArgumentCaptor captor = ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); - verify(deploymentApi).modify(eq("rg-2"), eq("dep-789"), captor.capture()); + verify(deploymentApi).modify(eq("default"), eq("dep-789"), captor.capture()); assertThat(captor.getValue().getConfigurationId()).isEqualTo("config-456"); } @Test - void onCreate_createsDeploymentWithConfigurationId() { - Deployments entry = Deployments.create(); - entry.setConfigurationId("cfg-1"); - entry.put(Deployments.RESOURCE_GROUP, Map.of("resourceGroupId", "rg-test")); - List entries = List.of(entry); - - AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); - when(response.getId()).thenReturn("new-dep-id"); - when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); - when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-test"); - when(service.isProviderUser()).thenReturn(true); - when(deploymentApi.create(eq("rg-test"), any(AiDeploymentCreationRequest.class))) - .thenReturn(response); - - cut.onCreate(createContext, entries); - - verify(createContext).setResult(any(List.class)); - ArgumentCaptor captor = - ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); - verify(deploymentApi).create(eq("rg-test"), captor.capture()); - assertThat(captor.getValue().getConfigurationId()).isEqualTo("cfg-1"); - } - - @Test - void onCreate_withTtl_setsTtlOnRequest() { - Deployments entry = Deployments.create(); - entry.setConfigurationId("cfg-2"); - entry.setTtl("PT24H"); - List entries = List.of(entry); - - AiDeploymentCreationResponse response = mock(AiDeploymentCreationResponse.class); - when(response.getId()).thenReturn("dep-ttl"); - when(response.getStatus()).thenReturn(AiExecutionStatus.UNKNOWN); - when(service.resolveResourceGroupFromKeys(any())).thenReturn("rg-default"); - when(service.isProviderUser()).thenReturn(true); - when(deploymentApi.create(eq("rg-default"), any(AiDeploymentCreationRequest.class))) - .thenReturn(response); - - cut.onCreate(createContext, entries); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); - verify(deploymentApi).create(eq("rg-default"), captor.capture()); - assertThat(captor.getValue().getTtl()).isEqualTo("PT24H"); + void onUpdate_withoutTargetStatusOrConfigurationId_throwsBadRequest() { + assertThatThrownBy( + () -> + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity("AICore.deployments") + .where(d -> d.get("id").eq("dep-x")) + .data("ttl", "1d")))) + .isInstanceOfSatisfying( + ServiceException.class, + e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) + .hasMessageContaining("targetStatus") + .hasMessageContaining("configurationId"); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java index 14a828b..4dfc443 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -4,454 +4,260 @@ package com.sap.cds.feature.aicore.core.handler; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.ai.sdk.core.model.BckndResourceGroupList; import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; -import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; -import com.sap.cds.ql.cqn.AnalysisResult; -import com.sap.cds.ql.cqn.CqnAnalyzer; -import com.sap.cds.ql.cqn.CqnSelect; -import com.sap.cds.ql.cqn.CqnUpdate; -import com.sap.cds.reflect.CdsModel; -import com.sap.cds.services.ServiceException; -import com.sap.cds.services.cds.CdsCreateEventContext; -import com.sap.cds.services.cds.CdsReadEventContext; -import com.sap.cds.services.cds.CdsUpdateEventContext; -import java.util.HashMap; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.List; import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) +/** + * Integration-style tests for {@link ResourceGroupHandler} using a real CDS runtime. Only the SDK + * API clients are mocked since they talk to a remote AI Core service. + */ class ResourceGroupHandlerTest { - @Mock private AICoreServiceImpl service; - @Mock private ResourceGroupApi resourceGroupApi; - @Mock private CdsCreateEventContext createContext; - - private ResourceGroupHandler handler; + private static CdsRuntime runtime; + private static AICoreServiceImpl service; + private static ResourceGroupApi resourceGroupApi; + + @BeforeAll + static void bootRuntime() { + resourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + runtime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); + configurer.complete(); + } @BeforeEach - void setUp() { - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); - handler = new ResourceGroupHandler(service); + void clearMockInvocations() { + clearInvocations(resourceGroupApi); } @Test - void onCreate_withTenantIdOnly_setsOnlyTenantLabel() { - Map entry = Map.of("resourceGroupId", "rg-1", "tenantId", "tenant-a"); - List entries = List.of(ResourceGroups.of(entry)); + void onRead_returnsAllResourceGroups() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-1"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + + BckndResourceGroupList list = mock(BckndResourceGroupList.class); + when(list.getResources()).thenReturn(List.of(rg)); + when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())).thenReturn(list); + + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> service.run(Select.from("AICore.resourceGroups"))); + + verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), any()); + assertThat(result.list()).hasSize(1); + assertThat(result.single().get("resourceGroupId")).isEqualTo("rg-1"); + } - handler.onCreate(createContext, entries); + @Test + void onCreate_createsResourceGroup() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.resourceGroups") + .entry(Map.of("resourceGroupId", "rg-new")))); - BckndResourceGroupsPostRequest request = captureCreateRequest(); - assertThat(request.getResourceGroupId()).isEqualTo("rg-1"); - assertThat(request.getLabels()) - .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-a")); + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); + verify(resourceGroupApi).create(captor.capture()); + assertThat(captor.getValue().getResourceGroupId()).isEqualTo("rg-new"); } @Test - void onCreate_withLabelsOnly_setsOnlyUserLabels() { - Map entry = - Map.of( - "resourceGroupId", - "rg-2", - "labels", - List.of(Map.of("key", "env", "value", "prod"), Map.of("key", "team", "value", "ai"))); - List entries = List.of(ResourceGroups.of(entry)); - - handler.onCreate(createContext, entries); - - BckndResourceGroupsPostRequest request = captureCreateRequest(); - assertThat(request.getResourceGroupId()).isEqualTo("rg-2"); - assertThat(request.getLabels()) + void onCreate_withTenantId_setsTenantLabel() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into("AICore.resourceGroups") + .entry( + Map.of( + "resourceGroupId", "rg-tenant", + "tenantId", "tenant-a")))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); + verify(resourceGroupApi).create(captor.capture()); + assertThat(captor.getValue().getLabels()) .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple("env", "prod"), tuple("team", "ai")); + .containsExactly(tuple(AICoreConfig.TENANT_LABEL_KEY, "tenant-a")); } @Test - void onCreate_withTenantIdAndLabels_keepsTenantLabelAndUserLabels() { - Map entry = - Map.of( - "resourceGroupId", - "rg-3", - "tenantId", - "tenant-b", - "labels", - List.of(Map.of("key", "env", "value", "prod"))); - List entries = List.of(ResourceGroups.of(entry)); - - handler.onCreate(createContext, entries); - - BckndResourceGroupsPostRequest request = captureCreateRequest(); - // Tenant label first, then user-supplied labels — and tenant label is NOT lost. - assertThat(request.getLabels()) + void onUpdate_withLabels_callsPatchWithLabels() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-upd"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(resourceGroupApi.get("rg-upd")).thenReturn(rg); + + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity("AICore.resourceGroups") + .where(d -> d.get("resourceGroupId").eq("rg-upd")) + .data("labels", List.of(Map.of("key", "env", "value", "staging"))))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-upd"), captor.capture()); + assertThat(captor.getValue().getLabels()) .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly( - tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-b"), tuple("env", "prod")); + .containsExactly(tuple("env", "staging")); } @Test - void onCreate_userSuppliedTenantLabelTakesPrecedence() { - Map entry = - Map.of( - "resourceGroupId", - "rg-4", - "tenantId", - "tenant-auto", - "labels", - List.of(Map.of("key", AICoreServiceImpl.TENANT_LABEL_KEY, "value", "tenant-user"))); - List entries = List.of(ResourceGroups.of(entry)); - - handler.onCreate(createContext, entries); - - BckndResourceGroupsPostRequest request = captureCreateRequest(); - assertThat(request.getLabels()) - .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple(AICoreServiceImpl.TENANT_LABEL_KEY, "tenant-user")); + void onUpdate_withoutLabels_callsPatchWithoutLabels() { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + when(rg.getResourceGroupId()).thenReturn("rg-nolabel"); + when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); + when(resourceGroupApi.get("rg-nolabel")).thenReturn(rg); + + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity("AICore.resourceGroups") + .where(d -> d.get("resourceGroupId").eq("rg-nolabel")) + .data("statusMessage", "updated"))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); + verify(resourceGroupApi).patch(eq("rg-nolabel"), captor.capture()); + assertThat(captor.getValue().getLabels()).isNullOrEmpty(); } + /** + * Multi-tenancy tests use a separate runtime with MT enabled to verify tenant-scoped label + * selectors. + */ @Nested - @ExtendWith(MockitoExtension.class) - class OnUpdateTests { - - @Mock private CdsUpdateEventContext updateContext; - @Mock private CqnUpdate cqnUpdate; - @Mock private CdsModel model; - @Mock private CqnAnalyzer analyzer; - @Mock private AnalysisResult analysisResult; - - @Test - void onUpdate_withLabels_callsPatchWithLabels() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-upd"); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); - when(updateContext.getCqn()).thenReturn(cqnUpdate); - when(updateContext.getModel()).thenReturn(model); - - Map data = new HashMap<>(); - data.put(ResourceGroups.LABELS, List.of(Map.of("key", "env", "value", "staging"))); - when(cqnUpdate.entries()).thenReturn(List.of(data)); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); - when(label.getValue()).thenReturn("my-tenant"); - when(rg.getLabels()).thenReturn(List.of(label)); - when(resourceGroupApi.get("rg-upd")).thenReturn(rg); - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("my-tenant"); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onUpdate(updateContext); - } - - ArgumentCaptor captor = - ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); - verify(resourceGroupApi).patch(eq("rg-upd"), captor.capture()); - assertThat(captor.getValue().getLabels()) - .extracting(BckndResourceGroupLabel::getKey, BckndResourceGroupLabel::getValue) - .containsExactly(tuple("env", "staging")); + class MultiTenancyTests { + + private static CdsRuntime mtRuntime; + private static AICoreServiceImpl mtService; + private static ResourceGroupApi mtResourceGroupApi; + + @BeforeAll + static void bootMtRuntime() { + mtResourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); + + var configurer = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + mtRuntime = configurer.getCdsRuntime(); + + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, mtResourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = + new DeploymentResolver(config, deploymentApi, mtResourceGroupApi); + + mtService = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, mtRuntime); + configurer.service(mtService); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); + configurer.complete(); } - @Test - void onUpdate_withoutLabels_callsPatchWithoutLabels() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-nolabel"); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analyzer.analyze(cqnUpdate)).thenReturn(analysisResult); - when(updateContext.getCqn()).thenReturn(cqnUpdate); - when(updateContext.getModel()).thenReturn(model); - - Map data = new HashMap<>(); - // no labels in update payload - when(cqnUpdate.entries()).thenReturn(List.of(data)); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - when(resourceGroupApi.get("rg-nolabel")).thenReturn(rg); - when(service.isProviderUser()).thenReturn(true); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onUpdate(updateContext); - } - - ArgumentCaptor captor = - ArgumentCaptor.forClass(BckndResourceGroupPatchRequest.class); - verify(resourceGroupApi).patch(eq("rg-nolabel"), captor.capture()); - assertThat(captor.getValue().getLabels()).isNullOrEmpty(); - } - } - - @Nested - @ExtendWith(MockitoExtension.class) - class BuildTenantLabelSelectorTests { - - @Mock private CdsReadEventContext readContext; - @Mock private CqnSelect cqnSelect; - @Mock private CdsModel model; - @Mock private CqnAnalyzer analyzer; - @Mock private AnalysisResult analysisResult; - - @Test - void readAll_withTenantIdFilter_usesLabelSelector() { - Map keys = new HashMap<>(); - Map values = new HashMap<>(); - values.put(ResourceGroups.TENANT_ID, "tenant-x"); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - - BckndResourceGroupList result = mock(BckndResourceGroupList.class); - when(result.getResources()).thenReturn(List.of()); - when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(result); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onRead(readContext); - } - - @SuppressWarnings("unchecked") - ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); - verify(resourceGroupApi) - .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); - assertThat(selectorCaptor.getValue()) - .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=tenant-x"); + @BeforeEach + void clearMtMockInvocations() { + clearInvocations(mtResourceGroupApi); } @Test + @SuppressWarnings("unchecked") void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() { - Map keys = new HashMap<>(); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.isProviderUser()).thenReturn(false); - when(service.currentTenantId()).thenReturn("current-tenant"); - - BckndResourceGroupList result = mock(BckndResourceGroupList.class); - when(result.getResources()).thenReturn(List.of()); - when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(result); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onRead(readContext); - } - - @SuppressWarnings("unchecked") + BckndResourceGroupList list = mock(BckndResourceGroupList.class); + when(list.getResources()).thenReturn(List.of()); + when(mtResourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(list); + + mtRuntime + .requestContext() + .modifyUser( + u -> + u.setTenant("current-tenant") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("test-user") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> mtService.run(Select.from("AICore.resourceGroups"))); + ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); - verify(resourceGroupApi) + verify(mtResourceGroupApi) .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); assertThat(selectorCaptor.getValue()) - .containsExactly(AICoreServiceImpl.TENANT_LABEL_KEY + "=current-tenant"); - } - - @Test - void readAll_multiTenancy_nullTenant_noLabelSelector() { - Map keys = new HashMap<>(); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.isProviderUser()).thenReturn(false); - when(service.currentTenantId()).thenReturn(null); - - BckndResourceGroupList result = mock(BckndResourceGroupList.class); - when(result.getResources()).thenReturn(List.of()); - when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(result); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onRead(readContext); - } - - verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null)); - } - - @Test - void readAll_singleTenancy_noLabelSelector() { - Map keys = new HashMap<>(); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(service.isMultiTenancyEnabled()).thenReturn(false); - - BckndResourceGroupList result = mock(BckndResourceGroupList.class); - when(result.getResources()).thenReturn(List.of()); - when(resourceGroupApi.getAll(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(result); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - handler.onRead(readContext); - } - - verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), eq(null)); - } - } - - @Nested - @ExtendWith(MockitoExtension.class) - class EnsureOwnedByCurrentTenantTests { - - @Mock private CdsReadEventContext readContext; - @Mock private CqnSelect cqnSelect; - @Mock private CdsModel model; - @Mock private CqnAnalyzer analyzer; - @Mock private AnalysisResult analysisResult; - - @Test - void readById_providerUser_allowsAccessToAnyRg() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-any"); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(service.isProviderUser()).thenReturn(true); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - when(rg.getResourceGroupId()).thenReturn("rg-any"); - when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); - when(rg.getLabels()).thenReturn(null); - when(resourceGroupApi.get("rg-any")).thenReturn(rg); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); - } + .containsExactly(AICoreConfig.TENANT_LABEL_KEY + "=current-tenant"); } - - @Test - void readById_singleTenancy_allowsAccess() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-single"); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(false); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - when(rg.getResourceGroupId()).thenReturn("rg-single"); - when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); - when(rg.getLabels()).thenReturn(null); - when(resourceGroupApi.get("rg-single")).thenReturn(rg); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); - } - } - - @Test - void readById_multiTenancy_wrongTenant_throws404() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-other"); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); - when(label.getValue()).thenReturn("tenant-b"); - when(rg.getLabels()).thenReturn(List.of(label)); - when(resourceGroupApi.get("rg-other")).thenReturn(rg); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - assertThatThrownBy(() -> handler.onRead(readContext)) - .isInstanceOf(ServiceException.class) - .hasMessageContaining("not found"); - } - } - - @Test - void readById_multiTenancy_matchingTenant_allowsAccess() { - Map keys = new HashMap<>(); - keys.put(ResourceGroups.RESOURCE_GROUP_ID, "rg-mine"); - Map values = new HashMap<>(); - when(analysisResult.targetKeys()).thenReturn(keys); - when(analysisResult.targetValues()).thenReturn(values); - when(analyzer.analyze(cqnSelect)).thenReturn(analysisResult); - when(readContext.getCqn()).thenReturn(cqnSelect); - when(readContext.getModel()).thenReturn(model); - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); - when(label.getValue()).thenReturn("tenant-a"); - when(rg.getLabels()).thenReturn(List.of(label)); - when(rg.getResourceGroupId()).thenReturn("rg-mine"); - when(rg.getStatus()).thenReturn(BckndResourceGroup.StatusEnum.PROVISIONED); - when(resourceGroupApi.get("rg-mine")).thenReturn(rg); - - try (MockedStatic staticAnalyzer = mockStatic(CqnAnalyzer.class)) { - staticAnalyzer.when(() -> CqnAnalyzer.create(model)).thenReturn(analyzer); - assertThatCode(() -> handler.onRead(readContext)).doesNotThrowAnyException(); - } - } - } - - private BckndResourceGroupsPostRequest captureCreateRequest() { - ArgumentCaptor captor = - ArgumentCaptor.forClass(BckndResourceGroupsPostRequest.class); - verify(resourceGroupApi).create(captor.capture()); - return captor.getValue(); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java index d1171ec..078779c 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -5,150 +5,257 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.ai.sdk.core.AiCoreService; +import com.sap.ai.sdk.core.client.ConfigurationApi; +import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; +import com.sap.ai.sdk.core.model.AiDeploymentList; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; +import com.sap.cds.Result; +import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.ql.Select; import com.sap.cds.services.ServiceException; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; +import com.sap.cds.services.request.RequestContext; +import com.sap.cds.services.runtime.CdsRuntime; +import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.List; +import java.util.function.Function; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; /** - * Unit tests for the tenant-scoping guard methods in {@link AbstractCrudHandler}. - * Tests the {@code ensureResourceGroupAccessible} logic which is used by - * ConfigurationHandler and DeploymentHandler. + * Integration-style tests for tenant-scoping logic through actual CQN READ operations. Verifies + * that {@code ensureResourceGroupAccessible} (used by DeploymentHandler and ConfigurationHandler) + * correctly enforces tenant isolation when multi-tenancy is enabled. */ -@ExtendWith(MockitoExtension.class) class TenantScopingTest { - @Mock private AICoreServiceImpl service; - @Mock private ResourceGroupApi resourceGroupApi; + private static CdsRuntime runtime; + private static AICoreServiceImpl service; + private static DeploymentApi deploymentApi; + private static ResourceGroupApi resourceGroupApi; - /** Concrete subclass to expose the protected method for testing. */ - private static class TestableHandler extends AbstractCrudHandler { - TestableHandler(AICoreServiceImpl service) { - super(service); - } + @BeforeAll + static void bootRuntime() { + deploymentApi = mock(DeploymentApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); - void callEnsureResourceGroupAccessible(String resourceGroupId) { - ensureResourceGroupAccessible(resourceGroupId); - } - } + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + configurer.cdsModel("edmx/csn.json"); + runtime = configurer.getCdsRuntime(); - private TestableHandler handler; + AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - @BeforeEach - void setUp() { - handler = new TestableHandler(service); + service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); + configurer.service(service); + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.complete(); } - // ── ensureResourceGroupAccessible ────────────────────────────────────────── - - @Test - void providerUser_allowsAccessToAnyResourceGroup() { - when(service.isProviderUser()).thenReturn(true); - - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) - .doesNotThrowAnyException(); - verify(resourceGroupApi, never()).get("any-rg"); + @BeforeEach + void clearMockInvocations() { + clearInvocations(deploymentApi, resourceGroupApi); } @Test - void singleTenancy_allowsAccessToAnyResourceGroup() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(false); + void matchingTenant_allowsAccess() { + stubResourceGroupWithTenant("rg-a", "tenant-A"); + stubDeploymentQuery(); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) + assertThatCode( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-A") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-a") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-a"))))) .doesNotThrowAnyException(); - verify(resourceGroupApi, never()).get("any-rg"); } @Test - void multiTenancy_nullTenant_allowsAccess() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn(null); + void nonMatchingTenant_throws404() { + stubResourceGroupWithTenant("rg-b", "tenant-A"); - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("any-rg")) - .doesNotThrowAnyException(); - verify(resourceGroupApi, never()).get("any-rg"); + assertThatThrownBy( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-B") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-b") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-b"))))) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("not found"); } @Test - void multiTenancy_matchingTenantLabel_allowsAccess() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); + void providerUser_bypassesTenantCheck() { + stubResourceGroupWithTenant("rg-c", "tenant-X"); + stubDeploymentQuery(); - BckndResourceGroup rg = mock(BckndResourceGroup.class); - BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); - when(label.getValue()).thenReturn("tenant-a"); - when(rg.getLabels()).thenReturn(List.of(label)); - when(resourceGroupApi.get("rg-for-a")).thenReturn(rg); - - assertThatCode(() -> handler.callEnsureResourceGroupAccessible("rg-for-a")) + // System user (provider) should bypass tenant check regardless of tenant label + assertThatCode( + () -> + runtime + .requestContext() + .systemUser() + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-c"))))) .doesNotThrowAnyException(); } @Test - void multiTenancy_nonMatchingTenantLabel_throws404() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); - - BckndResourceGroup rg = mock(BckndResourceGroup.class); - BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); - when(label.getKey()).thenReturn(AICoreServiceImpl.TENANT_LABEL_KEY); - when(label.getValue()).thenReturn("tenant-b"); - when(rg.getLabels()).thenReturn(List.of(label)); - when(resourceGroupApi.get("rg-for-b")).thenReturn(rg); + void nullTenantUser_bypassesTenantCheck() { + stubDeploymentQuery(); - assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-for-b")) - .isInstanceOf(ServiceException.class) - .hasMessageContaining("not found"); + // Non-system user with null tenant bypasses check (currentTenantId() returns null) + assertThatCode( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant(null) + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("no-tenant-user") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-d"))))) + .doesNotThrowAnyException(); } @Test - void multiTenancy_noLabels_throws404() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); - + void noLabelsOnResourceGroup_throws404() { BckndResourceGroup rg = mock(BckndResourceGroup.class); when(rg.getLabels()).thenReturn(null); when(resourceGroupApi.get("rg-no-labels")).thenReturn(rg); - assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-no-labels")) + assertThatThrownBy( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-A") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-labels") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-no-labels"))))) .isInstanceOf(ServiceException.class) .hasMessageContaining("not found"); } @Test - void multiTenancy_emptyLabels_throws404() { - when(service.isProviderUser()).thenReturn(false); - when(service.isMultiTenancyEnabled()).thenReturn(true); - when(service.currentTenantId()).thenReturn("tenant-a"); - when(service.getResourceGroupApi()).thenReturn(resourceGroupApi); - + void emptyLabelsOnResourceGroup_throws404() { BckndResourceGroup rg = mock(BckndResourceGroup.class); when(rg.getLabels()).thenReturn(List.of()); when(resourceGroupApi.get("rg-empty-labels")).thenReturn(rg); - assertThatThrownBy(() -> handler.callEnsureResourceGroupAccessible("rg-empty-labels")) + assertThatThrownBy( + () -> + runtime + .requestContext() + .modifyUser( + u -> + u.setTenant("tenant-A") + .setIsSystemUser(false) + .setIsInternalUser(false) + .setName("user-empty") + .setIsAuthenticated(true)) + .run( + (Function) + ctx -> + service.run( + Select.from("AICore.deployments") + .where( + d -> + d.get("resourceGroup_resourceGroupId") + .eq("rg-empty-labels"))))) .isInstanceOf(ServiceException.class) .hasMessageContaining("not found"); } + + private void stubResourceGroupWithTenant(String rgId, String tenantId) { + BckndResourceGroup rg = mock(BckndResourceGroup.class); + BckndResourceGroupLabel label = mock(BckndResourceGroupLabel.class); + when(label.getKey()).thenReturn(AICoreConfig.TENANT_LABEL_KEY); + when(label.getValue()).thenReturn(tenantId); + when(rg.getLabels()).thenReturn(List.of(label)); + when(resourceGroupApi.get(rgId)).thenReturn(rg); + } + + private void stubDeploymentQuery() { + AiDeploymentList emptyList = mock(AiDeploymentList.class); + when(emptyList.getResources()).thenReturn(List.of()); + when(deploymentApi.query(any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(emptyList); + } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index e7ea5d5..36e9634 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -91,9 +91,7 @@ public void afterRead(CdsReadEventContext context, List dataList) { .getCdsRuntime() .getEnvironment() .getProperty( - "cds.ai.recommendations.contextRowLimit", - Integer.class, - DEFAULT_CONTEXT_ROW_LIMIT); + "cds.ai.recommendations.contextRowLimit", Integer.class, DEFAULT_CONTEXT_ROW_LIMIT); var builder = new RecommendationContextBuilder(target, rowType, limit); diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index 879f0fe..afde35c 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -4,8 +4,6 @@ package com.sap.cds.feature.recommendation; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; -import com.sap.cds.feature.aicore.core.MockAICoreServiceImpl; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; import com.sap.cds.feature.recommendation.api.RptInferenceClient; @@ -14,6 +12,7 @@ import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import com.sap.cds.services.utils.environment.ServiceBindingUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,21 +33,29 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { return; } + boolean hasBind = hasAICoreBinding(runtime); RecommendationClientResolver resolver = - aiCoreService instanceof MockAICoreServiceImpl - ? service -> new MockRecommendationClient() - : RecommendationConfiguration::resolveRptClient; + hasBind + ? RecommendationConfiguration::resolveRptClient + : service -> new MockRecommendationClient(); FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); configurer.eventHandler(handler); configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } + private static boolean hasAICoreBinding(CdsRuntime runtime) { + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); + } + private static RecommendationClient resolveRptClient(AICoreService service) { String resourceGroup = service.resourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - return new RptInferenceClient( - service.inferenceClient(resourceGroup, deploymentId), - ((AbstractAICoreService) service).getRetry()); + return new RptInferenceClient(service.inferenceClient(resourceGroup, deploymentId)); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index 2865dd3..c6e4c3a 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -14,7 +14,10 @@ import com.sap.cds.CdsData; import com.sap.cds.services.draft.Drafts; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; +import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; +import io.github.resilience4j.core.IntervalFunction; import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -33,9 +36,7 @@ * AICoreService service = ...; * String rg = service.resourceGroup(); * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); - * RptInferenceClient client = - * new RptInferenceClient(service.inferenceClient(rg, deploymentId), - * ((AbstractAICoreService) service).getRetry()); + * RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId)); * List predictions = client.predict(rows, List.of("targetColumn"), "ID"); * } */ @@ -46,13 +47,13 @@ public class RptInferenceClient implements RecommendationClient { private static final Set MANAGED_FIELDS = Set.of("createdBy", "modifiedBy", "createdAt", "modifiedAt"); + private static final Retry INFERENCE_RETRY = buildInferenceRetry(); + private final DefaultApi api; - private final Retry retry; - public RptInferenceClient(ApiClient apiClient, Retry retry) { + public RptInferenceClient(ApiClient apiClient) { this.api = new DefaultApi(apiClient.withObjectMapper(JacksonConfiguration.getDefaultObjectMapper())); - this.retry = retry; } @Override @@ -64,7 +65,7 @@ public List predict( rows.size(), predictionColumns.size()); return Retry.decorateSupplier( - retry, + INFERENCE_RETRY, () -> { var response = api.predict(request); logger.debug("Prediction response id: {}", response.getId()); @@ -115,4 +116,20 @@ private static PredictRequestPayload buildRequest( .rows(sdkRows) .indexColumn(indexColumn); } + + private static Retry buildInferenceRetry() { + RetryConfig config = + RetryConfig.custom() + .maxAttempts(3) + .intervalFunction(IntervalFunction.ofExponentialBackoff(500, 2.0, 5000)) + .retryOnException( + e -> + e instanceof OpenApiRequestException oae + && oae.statusCode() != null + && (oae.statusCode() == 403 + || oae.statusCode() == 404 + || oae.statusCode() == 412)) + .build(); + return Retry.of("rpt-inference", config); + } } diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java index a4173e3..69b5062 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java @@ -7,8 +7,10 @@ import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -20,12 +22,15 @@ class RecommendationConfigurationTest { @Mock private CdsRuntimeConfigurer configurer; @Mock private CdsRuntime runtime; @Mock private ServiceCatalog serviceCatalog; + @Mock private CdsEnvironment environment; @Mock private AICoreService aiCoreService; @Test void aiCoreServiceFound_registersHandler() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); + when(runtime.getEnvironment()).thenReturn(environment); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) .thenReturn(aiCoreService); diff --git a/coverage-report/pom.xml b/coverage-report/pom.xml index e32cb96..8eca6c5 100644 --- a/coverage-report/pom.xml +++ b/coverage-report/pom.xml @@ -34,19 +34,6 @@ - - - mtx-integration-tests - - - com.sap.cds - cds-feature-ai-integration-tests-mtx-local - test - - - - - @@ -215,4 +202,17 @@ + + + mtx-integration-tests + + + com.sap.cds + cds-feature-ai-integration-tests-mtx-local + test + + + + + diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java index 01a8d83..6e88c40 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; @@ -49,30 +48,28 @@ void tearDown() { @Test void unsubscribe_isIdempotent() throws Exception { - AbstractAICoreService service = getService(); - subscriptionEndpointClient.subscribeTenant(TENANT); subscriptionEndpointClient.unsubscribeTenant(TENANT); assertThatCode(() -> subscriptionEndpointClient.unsubscribeTenant(TENANT)) .doesNotThrowAnyException(); - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(TENANT); } @Test void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { - AbstractAICoreService service = getService(); + AICoreService service = getService(); for (int i = 0; i < 2; i++) { subscriptionEndpointClient.subscribeTenant(TENANT); - assertThat(service.getTenantResourceGroupCache()).containsKey(TENANT); + // After subscribe, the service should resolve a resource group for this tenant + String rg = service.resourceGroupForTenant(TENANT); + assertThat(rg).isNotNull().isNotBlank(); subscriptionEndpointClient.unsubscribeTenant(TENANT); - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(TENANT); } } - private AbstractAICoreService getService() { - return (AbstractAICoreService) runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private AICoreService getService() { + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java index a38467a..eb1b9d2 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java @@ -10,7 +10,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; @@ -51,25 +50,13 @@ void subscribeTenant_thenServiceIsReachable() throws Exception { @Test void subscribeTenant_createsResourceGroup() throws Exception { - AbstractAICoreService service = getService(); + AICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-3"); - assertThat(service.isMultiTenancyEnabled()).isTrue(); - assertThat(service.getTenantResourceGroupCache()).containsKey("tenant-3"); - } - - @Test - void unsubscribeTenant_clearsCaches() throws Exception { - AbstractAICoreService service = getService(); - - subscriptionEndpointClient.subscribeTenant("tenant-3"); - - assertThat(service.getTenantResourceGroupCache()).containsKey("tenant-3"); - - subscriptionEndpointClient.unsubscribeTenant("tenant-3"); - - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("tenant-3"); + // After subscription, the service should be able to resolve a resource group for the tenant + String resourceGroup = service.resourceGroupForTenant("tenant-3"); + assertThat(resourceGroup).isNotNull().isNotBlank(); } @Test @@ -95,7 +82,7 @@ void tearDown() { } } - private AbstractAICoreService getService() { - return (AbstractAICoreService) runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private AICoreService getService() { + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java index 9b1c771..7aa06b8 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java @@ -7,8 +7,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -37,19 +38,19 @@ void setup() { @Test void multiTenancyEnabled() { - AbstractAICoreService service = getService(); - assertThat(service.isMultiTenancyEnabled()).isTrue(); + AICoreConfig config = getConfig(); + assertThat(config.multiTenancyEnabled()).isTrue(); } @Test void differentTenants_getDifferentResourceGroups() throws Exception { - AbstractAICoreService service = getService(); + AICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); subscriptionEndpointClient.subscribeTenant("tenant-2"); - String rg1 = service.getTenantResourceGroupCache().get("tenant-1"); - String rg2 = service.getTenantResourceGroupCache().get("tenant-2"); + String rg1 = service.resourceGroupForTenant("tenant-1"); + String rg2 = service.resourceGroupForTenant("tenant-2"); assertThat(rg1).isNotNull(); assertThat(rg2).isNotNull(); @@ -58,31 +59,24 @@ void differentTenants_getDifferentResourceGroups() throws Exception { @Test void resourceGroupPrefix_applied() throws Exception { - AbstractAICoreService service = getService(); + AICoreConfig config = getConfig(); + AICoreService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); - String rg = service.getTenantResourceGroupCache().get("tenant-1"); + String rg = service.resourceGroupForTenant("tenant-1"); - assertThat(rg).startsWith(service.getResourceGroupPrefix()); + assertThat(rg).startsWith(config.resourceGroupPrefix()); } - @Test - void clearTenantCache_onlyAffectsTarget() throws Exception { - AbstractAICoreService service = getService(); - - subscriptionEndpointClient.subscribeTenant("tenant-1"); - subscriptionEndpointClient.subscribeTenant("tenant-2"); - - String rg2 = service.getTenantResourceGroupCache().get("tenant-2"); - - service.clearTenantCache("tenant-1"); - - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey("tenant-1"); - assertThat(service.getTenantResourceGroupCache()).containsEntry("tenant-2", rg2); + private AICoreService getService() { + return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } - private AbstractAICoreService getService() { - return (AbstractAICoreService) runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private AICoreConfig getConfig() { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + boolean mt = sidecarUrl != null && !sidecarUrl.isBlank(); + return AICoreConfig.from(runtime.getEnvironment(), mt); } @AfterEach diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java index 955364e..ce0c89b 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java @@ -6,7 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -28,36 +28,34 @@ void service_isRegistered() { @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { - AbstractAICoreService service = getAICoreServiceImpl(); - if (!service.isMultiTenancyEnabled()) { + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + if (!config.multiTenancyEnabled()) { String result = service.resourceGroupForTenant("any-tenant"); - assertThat(result).isEqualTo(service.getDefaultResourceGroup()); + assertThat(result).isEqualTo(config.defaultResourceGroup()); } } @Test void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { - AbstractAICoreService service = getAICoreServiceImpl(); - if (service.isMultiTenancyEnabled()) { + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + if (config.multiTenancyEnabled()) { String tenantId = "itest-svc-tenant-" + System.currentTimeMillis(); - try { - String resourceGroupId = service.resourceGroupForTenant(tenantId); - assertThat(resourceGroupId).startsWith(service.getResourceGroupPrefix()); - assertThat(resourceGroupId).contains(tenantId); + String resourceGroupId = service.resourceGroupForTenant(tenantId); + assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); + assertThat(resourceGroupId).contains(tenantId); - // Second call should return cached value - String cached = service.resourceGroupForTenant(tenantId); - assertThat(cached).isEqualTo(resourceGroupId); - } finally { - service.clearTenantCache(tenantId); - } + // Second call should return cached value + String cached = service.resourceGroupForTenant(tenantId); + assertThat(cached).isEqualTo(resourceGroupId); } } @Test void deploymentId_returnsDeploymentId() { - AbstractAICoreService service = getAICoreServiceImpl(); - String resourceGroup = service.getDefaultResourceGroup(); + AICoreService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); assertThat(deploymentId).isNotNull().isNotBlank(); @@ -67,26 +65,10 @@ void deploymentId_returnsDeploymentId() { assertThat(cached).isEqualTo(deploymentId); } - @Test - void clearTenantCache_removesEntries() { - AbstractAICoreService service = getAICoreServiceImpl(); - String tenantId = "itest-cache-tenant"; - String fakeRg = "fake-rg"; - String fakeKey = fakeRg + "::" + RptModelSpec.CONFIG_NAME; - service.getTenantResourceGroupCache().put(tenantId, fakeRg); - service.getResourceGroupDeploymentCache().put(fakeKey, "fake-deployment"); - - service.clearTenantCache(tenantId); - - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(tenantId); - assertThat(service.getResourceGroupDeploymentCache()).doesNotContainKey(fakeKey); - } - @Test void configProperties_areApplied() { - AbstractAICoreService service = getAICoreServiceImpl(); - assertThat(service.getRetry()).isNotNull(); - assertThat(service.getDefaultResourceGroup()).isNotBlank(); - assertThat(service.getResourceGroupPrefix()).isNotBlank(); + AICoreConfig config = getAICoreConfig(); + assertThat(config.defaultResourceGroup()).isNotBlank(); + assertThat(config.resourceGroupPrefix()).isNotBlank(); } } diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java index cc7e592..0e7bf34 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java @@ -10,7 +10,7 @@ import com.sap.cds.Result; import com.sap.cds.Row; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; @@ -28,35 +28,33 @@ class ActionTest extends BaseIntegrationTest { @BeforeAll void ensureResourceGroupReady() { - ensureResourceGroupProvisioned(getAICoreCqnService(), getAICoreServiceImpl().getDefaultResourceGroup()); + ensureResourceGroupProvisioned(getAICoreCqnService(), getAICoreConfig().defaultResourceGroup()); } @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeFalse(service.isMultiTenancyEnabled(), "Multi-tenancy is enabled"); + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); String result = service.resourceGroupForTenant("any-tenant-id"); - assertThat(result).isEqualTo(service.getDefaultResourceGroup()); + assertThat(result).isEqualTo(config.defaultResourceGroup()); } @Test void resourceGroupForTenant_multiTenancy_createsGroup() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantId = "itest-action-tenant-" + System.currentTimeMillis(); - try { - String resourceGroupId = service.resourceGroupForTenant(tenantId); - assertThat(resourceGroupId).startsWith(service.getResourceGroupPrefix()); - assertThat(resourceGroupId).contains(tenantId); - } finally { - service.clearTenantCache(tenantId); - } + String resourceGroupId = service.resourceGroupForTenant(tenantId); + assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); + assertThat(resourceGroupId).contains(tenantId); } @Test void deploymentId_returnsValidDeployment() { - AbstractAICoreService service = getAICoreServiceImpl(); - String resourceGroup = service.getDefaultResourceGroup(); + AICoreService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); assertThat(deploymentId).isNotNull().isNotBlank(); @@ -64,20 +62,21 @@ void deploymentId_returnsValidDeployment() { @Test void deploymentId_cachedOnSecondCall() { - AbstractAICoreService service = getAICoreServiceImpl(); - String resourceGroup = service.getDefaultResourceGroup(); + AICoreService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); String first = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); String second = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); assertThat(second).isEqualTo(first); } - @Disabled("Stops the shared RPT deployment needed by subsequent Recommendation tests; " - + "re-enable once test creates its own isolated deployment") + @Disabled( + "Stops the shared RPT deployment needed by subsequent Recommendation tests; " + + "re-enable once test creates its own isolated deployment") @Test void stop_deployment_changesTargetStatus() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result deployments = service.run( @@ -99,7 +98,8 @@ void stop_deployment_changesTargetStatus() { service.run( Update.entity("AICore.deployments") .where(d -> d.get("id").eq(targetId)) - .data(Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); + .data( + Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); Result readResult = service.run( @@ -114,20 +114,4 @@ void stop_deployment_changesTargetStatus() { Row row = readResult.single(); assertThat(row.get("targetStatus")).isIn("STOPPED", "STOPPING"); } - - @Test - void resolveResourceGroupFromKeys_directKey() { - AbstractAICoreService service = getAICoreServiceImpl(); - Map keys = Map.of("resourceGroup_resourceGroupId", "my-rg"); - String resolved = service.resolveResourceGroupFromKeys(keys); - assertThat(resolved).isEqualTo("my-rg"); - } - - @Test - void resolveResourceGroupFromKeys_nestedMap() { - AbstractAICoreService service = getAICoreServiceImpl(); - Map keys = Map.of("resourceGroup", Map.of("resourceGroupId", "nested-rg")); - String resolved = service.resolveResourceGroupFromKeys(keys); - assertThat(resolved).isEqualTo("nested-rg"); - } } diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java index b474950..06a09af 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java @@ -5,12 +5,13 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.runtime.CdsRuntime; import java.util.List; import java.util.Map; @@ -42,8 +43,11 @@ protected AICoreService getAICoreService() { return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); } - protected AbstractAICoreService getAICoreServiceImpl() { - return (AbstractAICoreService) getAICoreService(); + protected AICoreConfig getAICoreConfig() { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + boolean mt = sidecarUrl != null && !sidecarUrl.isBlank(); + return AICoreConfig.from(runtime.getEnvironment(), mt); } protected CqnService getAICoreCqnService() { @@ -51,7 +55,7 @@ protected CqnService getAICoreCqnService() { } protected String ensureRptDeploymentReady() { - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); return CACHED_DEPLOYMENT_IDS.computeIfAbsent( resourceGroup, rg -> { diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java index ff45c9e..2b1e5b5 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java @@ -7,7 +7,6 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.services.cds.CqnService; @@ -20,7 +19,7 @@ class ConfigurationTest extends BaseIntegrationTest { @Test void readAll_returnsConfigurations() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( Select.from("AICore.configurations") @@ -32,7 +31,7 @@ void readAll_returnsConfigurations() { @Test void readAll_filterByScenario() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( Select.from("AICore.configurations") @@ -48,7 +47,7 @@ void readAll_filterByScenario() { @Test void create_andReadById() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); String configName = "itest-config-" + System.currentTimeMillis(); Result created = @@ -93,7 +92,7 @@ void create_andReadById() { @Test void create_withParameterBindings_mapsCorrectly() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); String configName = "itest-params-" + System.currentTimeMillis(); Result created = diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java index 77f5502..d73fd50 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java @@ -8,7 +8,6 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; import com.sap.cds.services.cds.CqnService; @@ -21,7 +20,7 @@ class DeploymentTest extends BaseIntegrationTest { @Test void readAll_returnsDeployments() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( Select.from("AICore.deployments") @@ -33,7 +32,7 @@ void readAll_returnsDeployments() { @Test void readSingle_returnsDeploymentDetails() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result all = service.run( Select.from("AICore.deployments") @@ -58,12 +57,13 @@ void readSingle_returnsDeploymentDetails() { assertThat(row.get("status")).isNotNull(); } - @Disabled("Stops the shared RPT deployment needed by subsequent Recommendation tests; " - + "re-enable once test creates its own isolated deployment") + @Disabled( + "Stops the shared RPT deployment needed by subsequent Recommendation tests; " + + "re-enable once test creates its own isolated deployment") @Test void update_targetStatus_stopsRunningDeployment() { CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result deployments = service.run( @@ -85,7 +85,8 @@ void update_targetStatus_stopsRunningDeployment() { service.run( Update.entity("AICore.deployments") .where(d -> d.get("id").eq(targetId)) - .data(Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); + .data( + Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); Result readResult = service.run( diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java index c845f13..2716d06 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java @@ -8,34 +8,18 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; -import org.junit.jupiter.api.AfterEach; +import com.sap.cds.feature.aicore.core.AICoreConfig; import org.junit.jupiter.api.Test; class MultiTenancyTest extends BaseIntegrationTest { - private String tenantA; - private String tenantB; - - @AfterEach - void cleanup() { - AbstractAICoreService service = getAICoreServiceImpl(); - if (tenantA != null) { - service.clearTenantCache(tenantA); - tenantA = null; - } - if (tenantB != null) { - service.clearTenantCache(tenantB); - tenantB = null; - } - } - @Test void differentTenants_getDifferentResourceGroups() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); - tenantA = "itest-mt-a-" + System.currentTimeMillis(); - tenantB = "itest-mt-b-" + System.currentTimeMillis(); + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); + String tenantA = "itest-mt-a-" + System.currentTimeMillis(); + String tenantB = "itest-mt-b-" + System.currentTimeMillis(); String rgA = service.resourceGroupForTenant(tenantA); String rgB = service.resourceGroupForTenant(tenantB); @@ -47,52 +31,24 @@ void differentTenants_getDifferentResourceGroups() { @Test void resourceGroupPrefix_appliedCorrectly() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); - tenantA = "itest-prefix-" + System.currentTimeMillis(); + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); + String tenantA = "itest-prefix-" + System.currentTimeMillis(); String rg = service.resourceGroupForTenant(tenantA); - assertThat(rg).startsWith(service.getResourceGroupPrefix()); - } - - @Test - void cacheIsolation_perTenant() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); - tenantA = "itest-cache-a-" + System.currentTimeMillis(); - tenantB = "itest-cache-b-" + System.currentTimeMillis(); - - String rgA = service.resourceGroupForTenant(tenantA); - String rgB = service.resourceGroupForTenant(tenantB); - - assertThat(service.getTenantResourceGroupCache()).containsEntry(tenantA, rgA); - assertThat(service.getTenantResourceGroupCache()).containsEntry(tenantB, rgB); - } - - @Test - void clearTenantCache_onlyAffectsTargetTenant() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); - tenantA = "itest-clear-a-" + System.currentTimeMillis(); - tenantB = "itest-clear-b-" + System.currentTimeMillis(); - - service.resourceGroupForTenant(tenantA); - String rgB = service.resourceGroupForTenant(tenantB); - - service.clearTenantCache(tenantA); - - assertThat(service.getTenantResourceGroupCache()).doesNotContainKey(tenantA); - assertThat(service.getTenantResourceGroupCache()).containsEntry(tenantB, rgB); + assertThat(rg).startsWith(config.resourceGroupPrefix()); } @Test void singleTenancy_alwaysReturnsDefault() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeFalse(service.isMultiTenancyEnabled(), "Multi-tenancy is enabled"); + AICoreConfig config = getAICoreConfig(); + AICoreService service = getAICoreService(); + assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); String rg1 = service.resourceGroupForTenant("tenant-x"); String rg2 = service.resourceGroupForTenant("tenant-y"); - assertThat(rg1).isEqualTo(service.getDefaultResourceGroup()); - assertThat(rg2).isEqualTo(service.getDefaultResourceGroup()); + assertThat(rg1).isEqualTo(config.defaultResourceGroup()); + assertThat(rg2).isEqualTo(config.defaultResourceGroup()); } } diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java index 2d32455..e6ca394 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java @@ -125,9 +125,12 @@ void delete_resourceGroup() throws InterruptedException { waitForResourceGroupProvisioned(service, rgId); - assertThatCode(() -> - service.run(Delete.from("AICore.resourceGroups").where(r -> r.get("resourceGroupId").eq(rgId))) - ).doesNotThrowAnyException(); + assertThatCode( + () -> + service.run( + Delete.from("AICore.resourceGroups") + .where(r -> r.get("resourceGroupId").eq(rgId)))) + .doesNotThrowAnyException(); createdResourceGroupId = null; // already deleted } diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java index 8023ca2..ebaa19f 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -3,7 +3,6 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; @@ -140,9 +139,7 @@ public void onPredictCategory(EventContext context) { String rg = service.resourceGroup(); String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); RptInferenceClient client = - new RptInferenceClient( - service.inferenceClient(rg, deploymentId), - ((AbstractAICoreService) service).getRetry()); + new RptInferenceClient(service.inferenceClient(rg, deploymentId)); List predictions = client.predict(rows, List.of("category"), "ID"); List> results = new ArrayList<>(); From b392cff5c8ab86c00293a7b1a11b8985395508a8 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Tue, 16 Jun 2026 13:41:12 +0200 Subject: [PATCH 04/14] chore(ai-core): cleanup (#76) * move handler to proper package * cleanup not relevant params in Before/After annotation * fix local test --- .../core/AICoreServiceConfiguration.java | 2 + .../{ => handler}/AICoreSetupHandler.java | 9 +++-- .../core/handler/ConfigurationHandler.java | 2 +- .../core/handler/DeploymentHandler.java | 4 +- .../{ => handler}/MockAICoreSetupHandler.java | 7 ++-- .../core/handler/MockEntityHandler.java | 37 ++++++++++++------- .../aicore/core/AICoreSetupHandlerTest.java | 1 + 7 files changed, 39 insertions(+), 23 deletions(-) rename cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/{ => handler}/AICoreSetupHandler.java (95%) rename cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/{ => handler}/MockAICoreSetupHandler.java (87%) diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index 2b94030..202ed8d 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -9,10 +9,12 @@ import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; +import com.sap.cds.feature.aicore.core.handler.AICoreSetupHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; import com.sap.cds.feature.aicore.core.handler.ConfigurationHandler; import com.sap.cds.feature.aicore.core.handler.DeploymentHandler; import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; +import com.sap.cds.feature.aicore.core.handler.MockAICoreSetupHandler; import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler; import com.sap.cds.services.environment.CdsProperties; diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreSetupHandler.java similarity index 95% rename from cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java rename to cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreSetupHandler.java index 57a56ac..1c07e5f 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreSetupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreSetupHandler.java @@ -1,10 +1,13 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ -package com.sap.cds.feature.aicore.core; +package com.sap.cds.feature.aicore.core.handler; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.cds.feature.aicore.core.AICoreClients; +import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.core.DeploymentResolver; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; @@ -33,7 +36,7 @@ public AICoreSetupHandler(AICoreClients clients, DeploymentResolver resolver) { this.resolver = resolver; } - @After(event = DeploymentService.EVENT_SUBSCRIBE) + @After @HandlerOrder(HandlerOrder.LATE) public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); @@ -50,7 +53,7 @@ public void afterSubscribe(SubscribeEventContext context) { } } - @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) + @Before @HandlerOrder(HandlerOrder.EARLY) public void beforeUnsubscribe(UnsubscribeEventContext context) { String tenantId = context.getTenant(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index d7a60df..bf4e1d8 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -75,7 +75,7 @@ public void onRead(CdsReadEventContext context) { } } - @On(entity = Configurations_.CDS_NAME) + @On public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index 6e21697..4657bc4 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -69,7 +69,7 @@ public void onRead(CdsReadEventContext context) { } } - @On(entity = Deployments_.CDS_NAME) + @On public void onCreate(CdsCreateEventContext context, List entries) { List> results = new ArrayList<>(); @@ -94,7 +94,7 @@ public void onCreate(CdsCreateEventContext context, List entries) { context.setResult(results); } - @On(entity = Deployments_.CDS_NAME) + @On public void onUpdate(CdsUpdateEventContext context, List entries) { if (entries.isEmpty()) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "No update payload provided"); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreSetupHandler.java similarity index 87% rename from cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java rename to cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreSetupHandler.java index 77a0983..bdcf880 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/MockAICoreSetupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreSetupHandler.java @@ -1,9 +1,8 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. */ -package com.sap.cds.feature.aicore.core; +package com.sap.cds.feature.aicore.core.handler; -import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.Before; @@ -26,7 +25,7 @@ public MockAICoreSetupHandler(MockAICoreApiHandler mockHandler) { this.mockHandler = mockHandler; } - @After(event = DeploymentService.EVENT_SUBSCRIBE) + @After @HandlerOrder(HandlerOrder.LATE) public void afterSubscribe(SubscribeEventContext context) { String tenantId = context.getTenant(); @@ -35,7 +34,7 @@ public void afterSubscribe(SubscribeEventContext context) { logger.info("Mock created in-memory resource group for tenant {}", tenantId); } - @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) + @Before @HandlerOrder(HandlerOrder.EARLY) public void beforeUnsubscribe(UnsubscribeEventContext context) { String tenantId = context.getTenant(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java index cef5666..2ac76ab 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java @@ -5,6 +5,7 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.ql.cqn.CqnDelete; import com.sap.cds.ql.cqn.CqnInsert; @@ -15,7 +16,6 @@ import com.sap.cds.services.cds.CdsDeleteEventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CdsUpdateEventContext; -import com.sap.cds.services.cds.CqnService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; @@ -34,23 +34,34 @@ public class MockEntityHandler implements EventHandler { // --- Resource Groups --- - @On(event = CqnService.EVENT_READ, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICoreService.RESOURCE_GROUPS) public void readResourceGroups(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); CqnAnalyzer analyzer = CqnAnalyzer.create(model); - Map keys = analyzer.analyze(select).targetKeys(); + AnalysisResult analysis = analyzer.analyze(select); + Map keys = analysis.targetKeys(); String id = (String) keys.get("resourceGroupId"); if (id != null) { Map rg = resourceGroups.get(id); context.setResult(rg != null ? List.of(rg) : List.of()); } else { - context.setResult(List.copyOf(resourceGroups.values())); + Map values = analysis.targetValues(); + String tenantId = (String) values.get("tenantId"); + if (tenantId != null) { + List> filtered = + resourceGroups.values().stream() + .filter(rg -> tenantId.equals(rg.get("tenantId"))) + .toList(); + context.setResult(filtered); + } else { + context.setResult(List.copyOf(resourceGroups.values())); + } } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICoreService.RESOURCE_GROUPS) public void createResourceGroups(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); @@ -65,7 +76,7 @@ public void createResourceGroups(CdsCreateEventContext context) { context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICoreService.RESOURCE_GROUPS) public void updateResourceGroups(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -81,7 +92,7 @@ public void updateResourceGroups(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(existing))); } - @On(event = CqnService.EVENT_DELETE, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = AICoreService.RESOURCE_GROUPS) public void deleteResourceGroups(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -94,7 +105,7 @@ public void deleteResourceGroups(CdsDeleteEventContext context) { // --- Deployments --- - @On(event = CqnService.EVENT_READ, entity = AICoreService.DEPLOYMENTS) + @On(entity = AICoreService.DEPLOYMENTS) public void readDeployments(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -110,7 +121,7 @@ public void readDeployments(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS) + @On(entity = AICoreService.DEPLOYMENTS) public void createDeployments(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); @@ -125,7 +136,7 @@ public void createDeployments(CdsCreateEventContext context) { context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.DEPLOYMENTS) + @On(entity = AICoreService.DEPLOYMENTS) public void updateDeployments(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -141,7 +152,7 @@ public void updateDeployments(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(existing))); } - @On(event = CqnService.EVENT_DELETE, entity = AICoreService.DEPLOYMENTS) + @On(entity = AICoreService.DEPLOYMENTS) public void deleteDeployments(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -154,7 +165,7 @@ public void deleteDeployments(CdsDeleteEventContext context) { // --- Configurations --- - @On(event = CqnService.EVENT_READ, entity = AICoreService.CONFIGURATIONS) + @On(entity = AICoreService.CONFIGURATIONS) public void readConfigurations(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -170,7 +181,7 @@ public void readConfigurations(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.CONFIGURATIONS) + @On(entity = AICoreService.CONFIGURATIONS) public void createConfigurations(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java index a379857..9233690 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreSetupHandlerTest.java @@ -18,6 +18,7 @@ import com.sap.ai.sdk.core.client.ResourceGroupApi; import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupList; +import com.sap.cds.feature.aicore.core.handler.AICoreSetupHandler; import com.sap.cds.services.ServiceException; import com.sap.cds.services.mt.UnsubscribeEventContext; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; From 0d81f68c92c6e6e548c45e0b110016137bfa90ab Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Wed, 17 Jun 2026 15:44:49 +0200 Subject: [PATCH 05/14] Refactor cds-feature-recommendations (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Separate the predictionRow from the contextRows and add a constant for [PREDICT] * Add comment on why we the @FunctionalInterface annotation is useful * Add comment to FioriRecommendationHandler * Moved everthing RPT-1 specific into RptInferenceClient * Rename RptInferenceClient.api -> RptInferenceClient.rpt and extract code that creates sdkRow to separate method * Moved everthing RPT-1 specific into RptInferenceClient * Replace MANAGED_FIELDS set with annotation-driven exclusion: @Core.Computed, @readonly and remove logic from 'toSdkRow' method that was already executed elsewhere * Change level of log statement when no suitable context columns are found and recommendations are therefore skipped * Move 'return early if predictRow == null' to earlier in afterRead of FioriRecommendationHandler * Extract 'SAP_Recommendations' into a constant * Get Persistence Service db via Dependency Injection * Move missingPredictionElementNames to earlier in the afterRead of the FioriRecommendationHandler * Add comment about conversion from List to List * Change check for active Entity: only return early if isActiveEntity is selected and false * Add comments to MockRecommendationClient * Change type of slectcolumns to Set * Add test: row for which we want to do predictions is automatically excluded in the select query returned by buildContextQuery * Add comment about ordering in the select query returned by buildContextQuery * Add comment computeSyntheticKey method and add test * Make sure we dont react on @cds.odata.valuelist : false and add a test for that * Simplify filters in computeContextColumn * Use any single key as RPT-1 index column, not just fields called ID and add test * Adjust sample to changes in cds-feature-recomendations * Add comment about books entity in test model * Update javadoc * Do not register FioriRecommendationHandler if no PersistenceService is found * In RptInferenceClient:resolveIndexColumn: fall back to synthetic index column for non-string single keys * Add checks for the presence of keyNames * Minor changes * Remove reflection tests for resolveIndexColumn The index column resolution is also tested by nonIdKey_usesSyntheticKeyColumn and composedKeys_usesSyntheticKeyColumn in FioriRecommendationHandlerTest. * Extract RptIndexColumns utility to share index column logic between RptInferenceClient and MockRecommendationClient * Add synthetic Key (if needed) in the sdkRow creation * Refactor: remove keyNames from RecommendationClient interface — it is now an argument of the Resolver via RecommendationClientResolver --- .../FioriRecommendationHandler.java | 46 ++++--- .../MockRecommendationClient.java | 57 +++++---- .../RecommendationConfiguration.java | 27 +++- .../RecommendationContextBuilder.java | 89 +++++-------- .../recommendation/RptIndexColumns.java | 25 ++++ .../api/RecommendationClient.java | 22 +++- .../api/RecommendationClientResolver.java | 10 +- .../api/RptInferenceClient.java | 100 ++++++++++----- .../FioriRecommendationHandlerTest.java | 121 ++++++++++-------- .../RecommendationConfigurationTest.java | 4 + .../api/RptInferenceClientTest.java | 48 +++++++ .../resources/model/recommendations-test.cds | 13 ++ .../handlers/AICoreShowcaseHandler.java | 58 ++++----- 13 files changed, 391 insertions(+), 229 deletions(-) create mode 100644 cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java create mode 100644 cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java index 36e9634..1498d0f 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/FioriRecommendationHandler.java @@ -6,7 +6,6 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; import com.sap.cds.reflect.CdsStructuredType; @@ -29,9 +28,10 @@ class FioriRecommendationHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(FioriRecommendationHandler.class); private static final int DEFAULT_CONTEXT_ROW_LIMIT = 2000; + private static final String SAP_RECOMMENDATIONS = "SAP_Recommendations"; - private final AICoreService aiCoreService; - private final RecommendationClientResolver clientResolver; + private final RecommendationClientResolver> clientResolver; + private final PersistenceService db; private final RecommendationResultParser resultParser = new RecommendationResultParser(); // Avoids re-evaluating the CDS model on every read to check whether an entity has prediction // columns. Keys are ":" because if an entity needs a prediction can be @@ -40,9 +40,9 @@ class FioriRecommendationHandler implements EventHandler { Caffeine.newBuilder().maximumSize(10_000).build(); FioriRecommendationHandler( - AICoreService aiCoreService, RecommendationClientResolver clientResolver) { - this.aiCoreService = aiCoreService; + RecommendationClientResolver> clientResolver, PersistenceService db) { this.clientResolver = clientResolver; + this.db = db; } void invalidateTenant(String tenantId) { @@ -77,10 +77,13 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - if (!Boolean.FALSE.equals(row.get(Drafts.IS_ACTIVE_ENTITY))) { + if (row.containsKey(Drafts.IS_ACTIVE_ENTITY) + && !Boolean.FALSE.equals(row.get(Drafts.IS_ACTIVE_ENTITY))) { return; } + // rowType reflects the projected shape (columns actually selected); target is the full entity. + // Fall back to target when rowType is absent, e.g. when the result carries no type metadata. CdsStructuredType rowType = context.getResult().rowType(); if (rowType == null) { rowType = target; @@ -100,18 +103,13 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - if (builder.contextColumns().isEmpty()) { - logger.debug("No suitable context columns found, skipping predictions."); + if (builder.keyNames().isEmpty()) { + logger.debug("Entity has no key elements, skipping predictions."); return; } - PersistenceService db = - context - .getServiceCatalog() - .getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); - if (contextRows.size() < 2) { - logger.debug("Not enough context rows (minimum 2), skipping predictions."); + if (builder.contextColumns().isEmpty()) { + logger.trace("No suitable context columns found, skipping predictions."); return; } @@ -121,11 +119,19 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - List allRows = builder.assembleRows(contextRows, predictRow, row); + // Result.list() returns List; the ArrayList copy also converts it to List. + List contextRows = new ArrayList<>(db.run(builder.buildContextQuery()).list()); + if (contextRows.size() < 2) { + logger.debug("Not enough context rows (minimum 2), skipping predictions."); + return; + } + + List missingPredictionElementNames = + builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); - RecommendationClient client = clientResolver.resolve(aiCoreService); + RecommendationClient client = clientResolver.resolve(builder.keyNames()); List predictions = - client.predict(allRows, builder.predictionElementNames(), builder.indexColumn()); + client.predict(predictRow, contextRows, missingPredictionElementNames); if (predictions.isEmpty()) { logger.warn("No predictions returned from AI client."); @@ -136,11 +142,9 @@ public void afterRead(CdsReadEventContext context, List dataList) { return; } - List missingPredictionElementNames = - builder.predictionElementNames().stream().filter(c -> row.get(c) == null).toList(); Map recommendations = resultParser.buildRecommendations( db, predictions.get(0), missingPredictionElementNames, context, rowType); - row.put("SAP_Recommendations", recommendations); + row.put(SAP_RECOMMENDATIONS, recommendations); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java index 27498bb..c2c9157 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/MockRecommendationClient.java @@ -5,45 +5,48 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.recommendation.api.RecommendationClient; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Random; +// Mock implementation used when no AI Core binding is present. For each prediction column that +// is null in the predict row, it picks a random non-null value from the same column across the +// context rows and returns it as the prediction. Columns already filled are left unchanged. class MockRecommendationClient implements RecommendationClient { + // We use random here so you can see a difference in the UI. The actual value returned here is not + // relevant for tests. private final Random random = new Random(); + private final List keyNames; + + MockRecommendationClient(List keyNames) { + this.keyNames = keyNames; + } @Override public List predict( - List rows, List predictionColumns, String indexColumn) { - List predictions = new ArrayList<>(); - for (CdsData row : rows) { - Map prediction = new HashMap<>(); - boolean addPrediction = false; - for (String col : predictionColumns) { - if ("[PREDICT]".equals(row.get(col))) { - addPrediction = true; - List availableValues = - rows.stream() - .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) - .map(r -> r.get(col)) - .toList(); - Object contextValue = - availableValues.isEmpty() - ? null - : availableValues.get(random.nextInt(availableValues.size())); - Map predictionEntry = new HashMap<>(); - predictionEntry.put("prediction", contextValue); - prediction.put(col, List.of(predictionEntry)); - } - } - if (addPrediction) { - prediction.put(indexColumn, row.get(indexColumn)); - predictions.add(CdsData.create(prediction)); + CdsData predictionRow, List contextRows, List predictionColumns) { + String indexColumn = RptIndexColumns.resolveIndexColumn(keyNames, predictionRow); + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + if (predictionRow.get(col) == null) { + List availableValues = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object contextValue = + availableValues.isEmpty() + ? null + : availableValues.get(random.nextInt(availableValues.size())); + Map predictionEntry = new HashMap<>(); + // Replace the empty entry in col with a randomly picked value of entries in the + // contextRows. + predictionEntry.put("prediction", contextValue); + prediction.put(col, List.of(predictionEntry)); } } - return predictions; + if (!keyNames.isEmpty()) { + prediction.put(indexColumn, predictionRow.get(keyNames.get(0))); + } + return List.of(CdsData.create(prediction)); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index afde35c..529c7cf 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -9,10 +9,12 @@ import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cds.services.utils.environment.ServiceBindingUtils; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,13 +35,25 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { return; } + PersistenceService db = + serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); + + if (db == null) { + logger.info( + "No PersistenceService found, skipping Fiori recommendation handler registration."); + return; + } + boolean hasBind = hasAICoreBinding(runtime); - RecommendationClientResolver resolver = + // The real resolver is a lambda resolved at prediction time. That's necessary because + // resource group and deployment ID are tenant-specific and are only available at + // prediction time from the request context. AICoreService is captured in the closure. + RecommendationClientResolver> clientResolver = hasBind - ? RecommendationConfiguration::resolveRptClient - : service -> new MockRecommendationClient(); + ? keyNames -> resolveRptClient(aiCoreService, keyNames) + : keyNames -> new MockRecommendationClient(keyNames); - FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); + FioriRecommendationHandler handler = new FioriRecommendationHandler(clientResolver, db); configurer.eventHandler(handler); configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } @@ -53,9 +67,10 @@ private static boolean hasAICoreBinding(CdsRuntime runtime) { .isPresent(); } - private static RecommendationClient resolveRptClient(AICoreService service) { + private static RecommendationClient resolveRptClient( + AICoreService service, List keyNames) { String resourceGroup = service.resourceGroup(); String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - return new RptInferenceClient(service.inferenceClient(resourceGroup, deploymentId)); + return new RptInferenceClient(service.inferenceClient(resourceGroup, deploymentId), keyNames); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index a158123..049d6e5 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -14,23 +14,26 @@ import com.sap.cds.reflect.CdsSimpleType; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.Drafts; -import java.util.ArrayList; import java.util.EnumSet; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; /** * Builds the context data needed for prediction: determines which elements to predict, which - * columns provide context, builds the context query, and prepares rows for the AI model. + * columns provide context and builds the context query. This class is cds-model aware, but does not + * know about which client will be used for the predictions. */ class RecommendationContextBuilder { private static final String VALUE_LIST_ANNOTATION = "@Common.ValueList"; private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = "@Common.ValueListWithFixedValues"; - private static final String SYNTHETIC_KEY_COLUMN = "SAP_RECOMMENDATIONS_ID"; + private static final String ODATA_VALUE_LIST_ANNOTATION = "@cds.odata.valuelist"; + private static final String COMPUTED_ANNOTATION = "@Core.Computed"; + private static final String READONLY_ANNOTATION = "@readonly"; private static final Set SUPPORTED_CONTEXT_TYPES = EnumSet.of( CdsBaseType.STRING, @@ -64,8 +67,6 @@ class RecommendationContextBuilder { private final List predictionElementNames; private final List contextColumns; private final List keyNames; - private final boolean syntheticKeyNeeded; - private final String indexColumn; RecommendationContextBuilder(CdsStructuredType target, CdsStructuredType rowType, int limit) { this.target = target; @@ -74,10 +75,6 @@ class RecommendationContextBuilder { this.predictionElementNames = computePredictionElements(); this.contextColumns = computeContextColumns(); this.keyNames = target.keyElements().map(CdsElement::getName).toList(); - this.syntheticKeyNeeded = - keyNames.size() > 1 || (keyNames.size() == 1 && !"ID".equals(keyNames.get(0))); - this.indexColumn = - syntheticKeyNeeded ? SYNTHETIC_KEY_COLUMN : keyNames.stream().findFirst().orElse("ID"); } List predictionElementNames() { @@ -88,30 +85,27 @@ List contextColumns() { return contextColumns; } - String indexColumn() { - return indexColumn; - } - - boolean syntheticKeyNeeded() { - return syntheticKeyNeeded; + List keyNames() { + return keyNames; } CqnSelect buildContextQuery() { - List selectColumns = new ArrayList<>(contextColumns); - for (String key : keyNames) { - if (!selectColumns.contains(key)) { - selectColumns.add(key); - } - } + Set selectColumns = new HashSet<>(contextColumns); + selectColumns.addAll(keyNames); + var select = Select.from(target.getQualifiedName()) .columns(selectColumns.toArray(String[]::new)) .where( predictionElementNames.stream() + // the row for which we want to do predictions is automatically + // excluded by this isNotNull check .map(col -> CQL.get(col).isNotNull()) .collect(CQL.withAnd())) .limit(contextRowLimit); target + // ensure there is some stable ordering of the contextRows, if possible order by + // "most recently changed" so the model gets the most up-to-date data .concreteNonAssociationElements() .filter(byAnnotation("cds.on.update")) .map(CdsElement::getName) @@ -121,49 +115,22 @@ CqnSelect buildContextQuery() { return select; } + // Builds the predict row from only the allowed columns (same set used in buildContextQuery), + // so draft, computed, and readonly fields are excluded by construction rather than explicit + // removal. CdsData buildPredictRow(CdsData row) { if (predictionElementNames.stream().noneMatch(c -> row.get(c) == null)) { return null; } - Map predictRow = new HashMap<>(row); - Drafts.ELEMENTS.forEach(predictRow::remove); - for (String col : predictionElementNames) { - predictRow.putIfAbsent(col, "[PREDICT]"); - } + Set allowed = new HashSet<>(contextColumns); + allowed.addAll(keyNames); + Map predictRow = + allowed.stream() + .filter(row::containsKey) + .collect(HashMap::new, (m, col) -> m.put(col, row.get(col)), HashMap::putAll); return CdsData.create(predictRow); } - private String computeSyntheticKey(Map row) { - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < keyNames.size(); i++) { - if (i > 0) { - sb.append('\0'); - } - sb.append(keyNames.get(i)); - sb.append('\0'); - Object value = row.get(keyNames.get(i)); - if (value != null) { - sb.append(value); - } - } - return sb.toString(); - } - - List assembleRows(List contextRows, CdsData predictRow, CdsData currentRow) { - List allRows = new ArrayList<>(); - if (syntheticKeyNeeded) { - for (CdsData contextRow : contextRows) { - contextRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(contextRow)); - allRows.add(contextRow); - } - predictRow.put(SYNTHETIC_KEY_COLUMN, computeSyntheticKey(currentRow)); - } else { - allRows.addAll(contextRows); - } - allRows.add(predictRow); - return allRows; - } - private List computePredictionElements() { return rowType .elements() @@ -171,6 +138,7 @@ private List computePredictionElements() { byAnnotation(VALUE_LIST_ANNOTATION) .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) .filter(e -> !e.getType().isAssociation()) + .filter(e -> !Boolean.FALSE.equals(e.getAnnotationValue(ODATA_VALUE_LIST_ANNOTATION, null))) .map(CdsElement::getName) .toList(); } @@ -178,10 +146,13 @@ private List computePredictionElements() { private List computeContextColumns() { return rowType .concreteNonAssociationElements() - .filter(e -> e.getType().isSimple()) .filter( - e -> SUPPORTED_CONTEXT_TYPES.contains(e.getType().as(CdsSimpleType.class).getType())) + e -> + e.getType() instanceof CdsSimpleType st + && SUPPORTED_CONTEXT_TYPES.contains(st.getType())) .filter(e -> !Drafts.ELEMENTS.contains(e.getName())) + .filter(byAnnotation(COMPUTED_ANNOTATION).negate()) + .filter(byAnnotation(READONLY_ANNOTATION).negate()) .map(CdsElement::getName) .toList(); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java new file mode 100644 index 0000000..26bc7a4 --- /dev/null +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RptIndexColumns.java @@ -0,0 +1,25 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation; + +import com.sap.cds.CdsData; +import java.util.List; + +public class RptIndexColumns { + + // RPT-1 requires a single string index column to identify rows in the request/response. + // When the entity has a composite or non-string key, a synthetic string column is used instead. + public static final String SYNTHETIC_INDEX_COLUMN = "SAP_RECOMMENDATIONS_ID"; + + // Returns the column name to use as the RPT-1 index column. Uses the single key directly if + // it holds a String value; falls back to the synthetic column for composite or non-string keys. + public static String resolveIndexColumn(List keyNames, CdsData sampleRow) { + if (keyNames.size() == 1 && sampleRow.get(keyNames.get(0)) instanceof String) { + return keyNames.get(0); + } + return SYNTHETIC_INDEX_COLUMN; + } + + private RptIndexColumns() {} +} diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java index 8694745..4558ec8 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClient.java @@ -8,5 +8,25 @@ public interface RecommendationClient { - List predict(List rows, List predictionColumns, String indexColumn); + /** + * Predicts values for the missing columns of a single entity row. + * + *

Currently limited to a single prediction row. Multiple prediction rows may be supported in + * the future via a separate overload, but are ruled out at two points for now: + * + *

    + *
  1. {@code FioriRecommendationHandler} bails out when the read returns more than one entity, + * so predictions only fire on single-entity reads. + *
  2. {@code FioriRecommendationHandler} also rejects responses with more than one prediction + * back from the model, treating it as an unexpected state. + *
+ * + * @param predictionRow the single entity row to predict values for; prediction columns contain + * null for missing values that the model should fill + * @param contextRows historical rows from the same entity used as training context + * @param predictionColumns names of the columns the model should predict + * @return the predicted values as a list of result rows + */ + List predict( + CdsData predictionRow, List contextRows, List predictionColumns); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java index ecc6837..82b2eca 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RecommendationClientResolver.java @@ -3,10 +3,12 @@ */ package com.sap.cds.feature.recommendation.api; -import com.sap.cds.feature.aicore.api.AICoreService; - +// A single-method interface so callers can supply a custom client via lambda. +// @FunctionalInterface enforces this and causes a compile error if a second method is ever added. +// The type parameter T allows the resolver to receive any context the client might need (e.g. key +// names). @FunctionalInterface -public interface RecommendationClientResolver { +public interface RecommendationClientResolver { - RecommendationClient resolve(AICoreService aiCoreService); + RecommendationClient resolve(T context); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index c6e4c3a..c8058e6 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -12,16 +12,16 @@ import com.sap.ai.sdk.foundationmodels.rpt.generated.model.RowsInnerValue; import com.sap.ai.sdk.foundationmodels.rpt.generated.model.TargetColumnConfig; import com.sap.cds.CdsData; -import com.sap.cds.services.draft.Drafts; +import com.sap.cds.feature.recommendation.RptIndexColumns; import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import io.github.resilience4j.core.IntervalFunction; import io.github.resilience4j.retry.Retry; import io.github.resilience4j.retry.RetryConfig; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,38 +36,45 @@ * AICoreService service = ...; * String rg = service.resourceGroup(); * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); - * RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId)); - * List predictions = client.predict(rows, List.of("targetColumn"), "ID"); + * RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId), keyNames); + * List predictions = client.predict(predictionRow, contextRows, List.of("targetColumn")); * } */ public class RptInferenceClient implements RecommendationClient { private static final Logger logger = LoggerFactory.getLogger(RptInferenceClient.class); - private static final Set MANAGED_FIELDS = - Set.of("createdBy", "modifiedBy", "createdAt", "modifiedAt"); + // RPT-1 specific: the placeholder value that marks a column as a prediction target in the request + public static final String PREDICT = "[PREDICT]"; private static final Retry INFERENCE_RETRY = buildInferenceRetry(); - private final DefaultApi api; + private final DefaultApi rpt; + private final List keyNames; - public RptInferenceClient(ApiClient apiClient) { - this.api = + public RptInferenceClient(ApiClient apiClient, List keyNames) { + this.rpt = new DefaultApi(apiClient.withObjectMapper(JacksonConfiguration.getDefaultObjectMapper())); + this.keyNames = keyNames; } @Override public List predict( - List rows, List predictionColumns, String indexColumn) { - PredictRequestPayload request = buildRequest(rows, predictionColumns, indexColumn); + CdsData predictionRow, List contextRows, List predictionColumns) { + String indexColumn = RptIndexColumns.resolveIndexColumn(keyNames, predictionRow); + CdsData preparedPredictRow = preparePredictRow(predictionRow, predictionColumns); + List allRows = new ArrayList<>(contextRows); + allRows.add(preparedPredictRow); + + PredictRequestPayload request = buildRequest(allRows, predictionColumns, indexColumn, keyNames); logger.debug( - "Sending prediction request for {} rows, {} target columns", - rows.size(), + "Sending prediction request for one row with {} context rows, {} target columns", + contextRows.size(), predictionColumns.size()); return Retry.decorateSupplier( INFERENCE_RETRY, () -> { - var response = api.predict(request); + var response = rpt.predict(request); logger.debug("Prediction response id: {}", response.getId()); List> raw = JacksonConfiguration.getDefaultObjectMapper() @@ -77,35 +84,58 @@ public List predict( .get(); } + // '\0' is used as separator because it cannot appear in database string values + // (VARCHAR/NVARCHAR), so concatenation of any composite key values is guaranteed collision-free. + static String computeSyntheticKey(Map row, List keyNames) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < keyNames.size(); i++) { + if (i > 0) sb.append('\0'); + sb.append(keyNames.get(i)).append('\0'); + Object value = row.get(keyNames.get(i)); + if (value != null) sb.append(value); + } + return sb.toString(); + } + + // Returns a copy of the predictRow with a prediction placeholder replacing empty values + // in the predictionColumns - these will get filled by the predict method. + private static CdsData preparePredictRow(CdsData predictRow, List predictionColumns) { + Map preparedPredictRowMap = new HashMap<>(predictRow); + for (String col : predictionColumns) { + preparedPredictRowMap.putIfAbsent(col, PREDICT); + } + return CdsData.create(preparedPredictRowMap); + } + private static PredictRequestPayload buildRequest( - List rows, List predictionColumns, String indexColumn) { + List rows, + List predictionColumns, + String indexColumn, + List keyNames) { var targetColumns = predictionColumns.stream() .map( col -> TargetColumnConfig.create() .name(col) - .predictionPlaceholder(PredictionPlaceholder.create("[PREDICT]")) + .predictionPlaceholder(PredictionPlaceholder.create(PREDICT)) .taskType(TargetColumnConfig.TaskTypeEnum.CLASSIFICATION)) .toList(); + // RPT-1 requires exactly one string-typed index column per row to identify predictions. + // When the entity key is composite or non-string, then the index column is + // RptIndexColumns.SYNTHETIC_INDEX_COLUMN and we need to compute the sytheticKey for all rows + // before sending them to RPT-1. + boolean syntheticKeyNeeded = RptIndexColumns.SYNTHETIC_INDEX_COLUMN.equals(indexColumn); var sdkRows = rows.stream() .map( row -> { - Map sdkRow = new HashMap<>(); - row.forEach( - (k, v) -> { - if (v != null - && !Drafts.ELEMENTS.contains(k) - && !MANAGED_FIELDS.contains(k)) { - sdkRow.put(k, RowsInnerValue.create(v.toString())); - } - }); - for (String target : predictionColumns) { - if (!row.containsKey(target) || row.get(target) == null) { - sdkRow.put(target, RowsInnerValue.create("[PREDICT]")); - } + Map sdkRow = toSdkRow(row); + if (syntheticKeyNeeded) { + sdkRow.put( + RptIndexColumns.SYNTHETIC_INDEX_COLUMN, + RowsInnerValue.create(computeSyntheticKey(row, keyNames))); } return sdkRow; }) @@ -117,6 +147,18 @@ private static PredictRequestPayload buildRequest( .indexColumn(indexColumn); } + // Converts a CdsData row to the RPT SDK row format, i.e., into Map + private static Map toSdkRow(CdsData row) { + Map sdkRow = new HashMap<>(); + row.forEach( + (k, v) -> { + if (v != null) { + sdkRow.put(k, RowsInnerValue.create(v.toString())); + } + }); + return sdkRow; + } + private static Retry buildInferenceRetry() { RetryConfig config = RetryConfig.custom() diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index c7f1a22..c21c823 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -13,7 +13,6 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; import com.sap.cds.ResultBuilder; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.services.Service; @@ -33,9 +32,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Answers; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -44,9 +41,6 @@ class FioriRecommendationHandlerTest { private static CdsRuntime runtime; private static PersistenceService db; - @Mock(answer = Answers.CALLS_REAL_METHODS) - private AICoreService aiCoreService; - private FioriRecommendationHandler cut; private RecommendationClient predictionClient; @@ -67,7 +61,7 @@ void setup() { reset(db); when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); predictionClient = randomPickClient(); - cut = new FioriRecommendationHandler(aiCoreService, (service) -> predictionClient); + cut = new FioriRecommendationHandler(keyNames -> predictionClient, db); } // ── tests ────────────────────────────────────────────────────────────────── @@ -152,7 +146,7 @@ void emptyPredictions_returnsEarlyWithoutRecommendations() { Map row = draftRow("genre_ID", null); CdsReadEventContext ctx = readContext("test.Books", List.of(row)); when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); - predictionClient = (rows, cols, idx) -> List.of(); + predictionClient = (predictionRow, contextRows, cols) -> List.of(); cut.afterRead(ctx, dataList(row)); assertThat(row).doesNotContainKey("SAP_Recommendations"); }); @@ -166,7 +160,7 @@ void multiplePredictions_returnsEarlyWithoutRecommendations() { CdsReadEventContext ctx = readContext("test.Books", List.of(row)); when(db.run(any(CqnSelect.class))).thenReturn(twoContextRows()); predictionClient = - (rows, cols, idx) -> + (predictionRow, contextRows, cols) -> List.of( CdsData.create(Map.of("ID", "id-1")), CdsData.create(Map.of("ID", "id-2"))); cut.afterRead(ctx, dataList(row)); @@ -203,6 +197,55 @@ void draftRow_withGenreAndCurrency_addsSapRecommendations() { }); } + @Test + void contextQuery_excludesPredictionRowByRequiringNonNullPredictionColumns() { + runIn( + () -> { + Map row = draftRow("genre_ID", null); + CdsReadEventContext ctx = readContext("test.Books", List.of(row)); + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); + when(db.run(selectCaptor.capture())).thenReturn(twoContextRows()); + cut.afterRead(ctx, dataList(row)); + // The WHERE clause requires all prediction columns to be non-null, so the current row + // (which has genre_ID = null) is automatically excluded from the context. + String selectSql = selectCaptor.getAllValues().get(0).toString(); + assertThat(selectSql).contains("\"is not\",\"null\""); + assertThat(selectSql).contains("genre_ID"); + }); + } + + @Test + void cdsoDataValueListFalse_fieldIsExcludedFromPredictions() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", null); + row.put("suppressed_ID", null); + CdsReadEventContext ctx = readContext("test.BooksWithDisabledValueList", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>( + Map.of("ID", "x1", "genre_ID", 1, "suppressed_ID", 10)), + new HashMap<>( + Map.of("ID", "x2", "genre_ID", 2, "suppressed_ID", 20))))) + .result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + // genre_ID has @Common.ValueListWithFixedValues → predicted + // suppressed_ID has @cds.odata.valuelist: false → excluded + assertThat(row).containsKey("SAP_Recommendations"); + @SuppressWarnings("unchecked") + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat(recs).containsKey("genre_ID"); + assertThat(recs).doesNotContainKey("suppressed_ID"); + }); + } + @Test void blobAndVectorFields_areExcludedFromContextSelect() { runIn( @@ -393,55 +436,33 @@ private static Result twoContextRows() { private static RecommendationClient rptStyleClient() { Random random = new Random(42); - return (rows, predictionColumns, indexColumn) -> { - List predictions = new ArrayList<>(); - for (CdsData row : rows) { - if (predictionColumns.stream().noneMatch(col -> "[PREDICT]".equals(row.get(col)))) { - continue; - } - Map prediction = new HashMap<>(); - for (String col : predictionColumns) { - List available = - rows.stream() - .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) - .map(r -> r.get(col)) - .toList(); - Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); - prediction.put(col, List.of(Map.of("prediction", val))); - } - prediction.put(indexColumn, row.get(indexColumn)); - predictions.add(CdsData.create(prediction)); + return (predictionRow, contextRows, predictionColumns) -> { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + List available = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); + prediction.put(col, List.of(Map.of("prediction", val))); } - return predictions; + prediction.put("ID", predictionRow.get("ID")); + return List.of(CdsData.create(prediction)); }; } private static RecommendationClient randomPickClient() { Random random = new Random(42); - return (rows, predictionColumns, indexColumn) -> { - List predictions = new ArrayList<>(); - for (CdsData row : rows) { - Map prediction = new HashMap<>(); - boolean addPrediction = false; - for (String col : predictionColumns) { - if ("[PREDICT]".equals(row.get(col))) { - addPrediction = true; - List available = - rows.stream() - .filter(r -> r.get(col) != null && !"[PREDICT]".equals(r.get(col))) - .map(r -> r.get(col)) - .toList(); - Object val = - available.isEmpty() ? null : available.get(random.nextInt(available.size())); - prediction.put(col, List.of(Map.of("prediction", val))); - } - } - if (addPrediction) { - prediction.put(indexColumn, row.get(indexColumn)); - predictions.add(CdsData.create(prediction)); + return (predictionRow, contextRows, predictionColumns) -> { + Map prediction = new HashMap<>(); + for (String col : predictionColumns) { + if (predictionRow.get(col) == null) { + List available = + contextRows.stream().filter(r -> r.get(col) != null).map(r -> r.get(col)).toList(); + Object val = available.isEmpty() ? null : available.get(random.nextInt(available.size())); + prediction.put(col, List.of(Map.of("prediction", val))); } } - return predictions; + prediction.put("ID", predictionRow.get("ID")); + return List.of(CdsData.create(prediction)); }; } } diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java index 69b5062..8b3ebce 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java @@ -8,6 +8,7 @@ import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.services.ServiceCatalog; import com.sap.cds.services.environment.CdsEnvironment; +import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import java.util.stream.Stream; @@ -24,6 +25,7 @@ class RecommendationConfigurationTest { @Mock private ServiceCatalog serviceCatalog; @Mock private CdsEnvironment environment; @Mock private AICoreService aiCoreService; + @Mock private PersistenceService persistenceService; @Test void aiCoreServiceFound_registersHandler() { @@ -33,6 +35,8 @@ void aiCoreServiceFound_registersHandler() { when(environment.getServiceBindings()).thenReturn(Stream.empty()); when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) .thenReturn(aiCoreService); + when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) + .thenReturn(persistenceService); new RecommendationConfiguration().eventHandlers(configurer); diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java new file mode 100644 index 0000000..8abce82 --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -0,0 +1,48 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.recommendation.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RptInferenceClientTest { + + @Test + void computeSyntheticKey_singleKey() { + String key = RptInferenceClient.computeSyntheticKey(Map.of("ID", "abc"), List.of("ID")); + assertThat(key).isEqualTo("ID" + '\0' + "abc"); + } + + @Test + void computeSyntheticKey_compositeKey() { + String key = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", 1, "item_no", 10), List.of("order_ID", "item_no")); + assertThat(key).isEqualTo("order_ID" + '\0' + "1" + '\0' + "item_no" + '\0' + "10"); + } + + @Test + void computeSyntheticKey_noCollision_betweenDifferentCompositions() { + // "1" + "0" must not produce the same key as "10" + "" + String key1 = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", "1", "item_no", "0"), List.of("order_ID", "item_no")); + String key2 = + RptInferenceClient.computeSyntheticKey( + Map.of("order_ID", "10", "item_no", ""), List.of("order_ID", "item_no")); + assertThat(key1).isNotEqualTo(key2); + } + + @Test + void computeSyntheticKey_nullValue_doesNotCrash() { + Map row = new java.util.HashMap<>(); + row.put("order_ID", 1); + row.put("item_no", null); + String key = RptInferenceClient.computeSyntheticKey(row, List.of("order_ID", "item_no")); + assertThat(key).isEqualTo("order_ID" + '\0' + "1" + '\0' + "item_no" + '\0'); + } +} diff --git a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds index bfcd611..5c30916 100644 --- a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds +++ b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds @@ -1,5 +1,7 @@ namespace test; +// genre_ID and currency_code are declared as plain scalars here because this is a test model. +// In a real CDS model these would be generated foreign key columns from annotated associations. @odata.draft.enabled entity Books { key ID : UUID; @@ -46,6 +48,16 @@ entity PlainEntity { title : String; } +@odata.draft.enabled +entity BooksWithDisabledValueList { + key ID : UUID; + @Common.ValueListWithFixedValues + genre_ID : Integer; + @Common.ValueListWithFixedValues + @cds.odata.valuelist: false + suppressed_ID : Integer; +} + service TestService { entity Books as projection on test.Books; entity Genres as projection on test.Genres; @@ -53,4 +65,5 @@ service TestService { entity OrderItems as projection on test.OrderItems; entity IsbnBooks as projection on test.IsbnBooks; entity PlainEntity as projection on test.PlainEntity; + entity BooksWithDisabledValueList as projection on test.BooksWithDisabledValueList; } diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java index ebaa19f..5ebfdf3 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -112,45 +112,39 @@ public void onCreateConfiguration(EventContext context) { public void onPredictCategory(EventContext context) { List> products = (List>) context.get("products"); - List rows = new ArrayList<>(); - rows.add( - CdsData.create( - Map.of("ID", "ctx-1", "name", "Laptop", "price", "999.99", "category", "Electronics"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-2", "name", "Mouse", "price", "29.99", "category", "Electronics"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-3", "name", "Shirt", "price", "49.99", "category", "Clothing"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-4", "name", "Novel", "price", "14.99", "category", "Books"))); - rows.add( - CdsData.create( - Map.of("ID", "ctx-5", "name", "Blender", "price", "89.99", "category", "Appliances"))); - - for (Map product : products) { - Map row = new HashMap<>(product); - row.put("category", "[PREDICT]"); - rows.add(CdsData.create(row)); - } + List contextRows = + List.of( + CdsData.create( + Map.of("ID", "ctx-1", "name", "Laptop", "price", "999.99", "category", "Electronics")), + CdsData.create( + Map.of("ID", "ctx-2", "name", "Mouse", "price", "29.99", "category", "Electronics")), + CdsData.create( + Map.of("ID", "ctx-3", "name", "Shirt", "price", "49.99", "category", "Clothing")), + CdsData.create( + Map.of("ID", "ctx-4", "name", "Novel", "price", "14.99", "category", "Books")), + CdsData.create( + Map.of( + "ID", "ctx-5", "name", "Blender", "price", "89.99", "category", "Appliances"))); AICoreService service = getAICoreService(); String rg = service.resourceGroup(); String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); RptInferenceClient client = - new RptInferenceClient(service.inferenceClient(rg, deploymentId)); - List predictions = client.predict(rows, List.of("category"), "ID"); + new RptInferenceClient(service.inferenceClient(rg, deploymentId), List.of("ID")); List> results = new ArrayList<>(); - for (CdsData prediction : predictions) { - String id = (String) prediction.get("ID"); - Object categoryObj = prediction.get("category"); - String category = - categoryObj instanceof List list && !list.isEmpty() - ? extractPrediction(list) - : String.valueOf(categoryObj); - results.add(Map.of("ID", id, "category", category)); + for (Map product : products) { + CdsData predictionRow = CdsData.create(new HashMap<>(product)); + List predictions = client.predict(predictionRow, contextRows, List.of("category")); + for (CdsData prediction : predictions) { + String id = (String) prediction.get("ID"); + Object categoryObj = prediction.get("category"); + String category = + categoryObj instanceof List list && !list.isEmpty() + ? extractPrediction(list) + : String.valueOf(categoryObj); + results.add(Map.of("ID", id, "category", category)); + } } context.put("result", results); From 62f9b63b5aeb1164e649c12593feeef9207d7ad5 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Thu, 18 Jun 2026 11:07:48 +0200 Subject: [PATCH 06/14] chore: migrate to remote service (#80) * refactor(ai-core): migrate AICoreService to RemoteService - Remove AICoreService interface and AICoreServiceImpl - Introduce AICore constants class with SERVICE_NAME - Convert all handlers to use RemoteService with event contexts - Update DeploymentIdContext, InferenceClientContext, ResourceGroupContext - Update AICoreServiceConfiguration to register handlers on RemoteService - Adapt all unit tests to new RemoteService-based API - Update sample .cdsrc.json and ai-core-service.cds * chore(itests): adapt integration tests to RemoteService - Update all integration tests to use RemoteService + event contexts - Replace AICoreService.deploymentId/resourceGroup calls with context pattern - Update BaseIntegrationTest with new service resolution approach * refactor(recommendations): adapt to RemoteService API - Remove RptIndexColumns utility; inline resolveIndexColumn and addSyntheticKeyIfNeeded into RptInferenceClient - Change RecommendationClient.predict to accept keyNames as argument - Drop generic type parameter from RecommendationClientResolver; resolve now takes RemoteService directly - Remove keyNames from RptInferenceClient constructor (passed at predict time) - FioriRecommendationHandler now holds RemoteService reference and passes keyNames at prediction time - RecommendationConfiguration uses RemoteService + event context pattern (ResourceGroupContext, DeploymentIdContext, InferenceClientContext) - MockRecommendationClient simplified (no keyNames in constructor) - Update all tests to match new signatures * chore(samples): adapt bookshop sample to RemoteService - Replace AICoreService usage with RemoteService + event context pattern - Use AICore.SERVICE_NAME constant for service lookup - Demonstrate ResourceGroupContext, DeploymentIdContext, InferenceClientContext * update last Cqn references * update unit-tests * restore functionality * adapt tests * simplify itests * simplification * spotless * big blunder now fixed whoops * last occurences * spotless --- .../cds/feature/aicore/api/AICoreService.java | 88 ----------------- .../aicore/api/DeploymentIdContext.java | 6 +- .../aicore/api/InferenceClientContext.java | 4 +- .../aicore/api/ModelDeploymentSpec.java | 4 +- .../aicore/api/ResourceGroupContext.java | 6 +- .../core/AICoreServiceConfiguration.java | 97 +++++++++++-------- .../aicore/core/AICoreServiceImpl.java | 70 ------------- .../aicore/core/handler/AICoreApiHandler.java | 6 +- .../aicore/core/handler/ActionHandler.java | 4 +- .../core/handler/ConfigurationHandler.java | 4 +- .../core/handler/DeploymentHandler.java | 4 +- .../core/handler/MockAICoreApiHandler.java | 8 +- .../core/handler/MockEntityHandler.java | 27 +++--- .../core/handler/ResourceGroupHandler.java | 4 +- .../core/AICoreServiceConfigurationTest.java | 25 ++--- .../AICoreServiceImplDeploymentIdTest.java | 89 ++++++++--------- .../core/MockAICoreServiceImplTest.java | 68 +++++++++---- .../handler/ConfigurationHandlerTest.java | 23 +++-- .../core/handler/DeploymentHandlerTest.java | 27 +++--- .../aicore/core/handler/HandlerTestUtils.java | 23 +++++ .../handler/ResourceGroupHandlerTest.java | 41 ++++---- .../core/handler/TenantScopingTest.java | 29 +++--- .../RecommendationConfiguration.java | 43 ++++++-- .../RecommendationContextBuilder.java | 9 +- .../api/RptInferenceClient.java | 19 +++- .../FioriRecommendationHandlerTest.java | 5 +- .../RecommendationConfigurationTest.java | 10 +- .../api/RptInferenceClientTest.java | 28 ++++++ .../aicore/itest/mt/MtxLifecycleTest.java | 15 ++- .../itest/mt/SubscribeUnsubscribeTest.java | 15 ++- .../aicore/itest/mt/TenantIsolationTest.java | 28 ++++-- .../aicore/itest/AICoreServiceTest.java | 39 ++++++-- .../cds/feature/aicore/itest/ActionTest.java | 53 +++++++--- .../aicore/itest/BaseIntegrationTest.java | 35 ++++--- .../aicore/itest/ConfigurationTest.java | 23 ++--- .../feature/aicore/itest/DeploymentTest.java | 21 ++-- .../aicore/itest/MultiTenancyTest.java | 37 +++++-- .../aicore/itest/ResourceGroupTest.java | 39 ++++---- samples/bookshop/.cdsrc.json | 4 +- samples/bookshop/srv/ai-core-service.cds | 2 +- .../handlers/AICoreShowcaseHandler.java | 65 ++++++++++--- 41 files changed, 629 insertions(+), 518 deletions(-) delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java delete mode 100644 cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java create mode 100644 cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/HandlerTestUtils.java diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java deleted file mode 100644 index 8a2e24c..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.api; - -import com.sap.cds.services.cds.RemoteService; -import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; - -/** - * CAP service contract for SAP AI Core integration. - * - *

The service exposes resource-group, configuration and deployment lifecycle as CDS entities - * (see {@link #RESOURCE_GROUPS}, {@link #DEPLOYMENTS}, {@link #CONFIGURATIONS}) and additionally - * provides programmatic helpers to: - * - *

    - *
  • Resolve the resource group ID for the current tenant ({@link #resourceGroup()}), creating - * it on-demand when multi-tenancy is enabled. - *
  • Resolve (or create) a deployment matching a {@link ModelDeploymentSpec} ({@link - * #deploymentId(String, ModelDeploymentSpec)}). - *
  • Build an {@link ApiClient} preconfigured for inference against a specific deployment - * ({@link #inferenceClient(String, String)}). - *
- * - *

The implementation is tenant-aware: it reads the current tenant from the {@code - * RequestContext}. Callers do not need to pass tenant identifiers explicitly. - */ -public interface AICoreService extends RemoteService { - - /** Default service name under which an instance is registered in the service catalog. */ - String DEFAULT_NAME = "AICoreService$Default"; - - /** Qualified name of the {@code resourceGroups} entity exposed by this service. */ - String RESOURCE_GROUPS = "AICore.resourceGroups"; - - /** Qualified name of the {@code deployments} entity exposed by this service. */ - String DEPLOYMENTS = "AICore.deployments"; - - /** Qualified name of the {@code configurations} entity exposed by this service. */ - String CONFIGURATIONS = "AICore.configurations"; - - /** - * Returns the AI Core resource group ID associated with the current tenant. - * - *

When multi-tenancy is disabled the configured default resource group is returned. When - * enabled, the resource group is looked up by the {@code ext.ai.sap.com/CDS_TENANT_ID} label and - * created on first call if it does not exist. - * - * @return the AI Core resource group ID for the current tenant - */ - String resourceGroup(); - - /** - * Returns the AI Core resource group ID associated with the given tenant. - * - *

This variant is used during subscribe/unsubscribe flows where the tenant ID is explicitly - * available from the context rather than the current request. - * - * @param tenantId the CDS tenant identifier - * @return the AI Core resource group ID for the specified tenant - */ - String resourceGroupForTenant(String tenantId); - - /** - * Returns the deployment ID for the given model spec inside the given resource group. - * - *

Looks up an existing RUNNING/PENDING deployment that matches the spec, otherwise creates a - * configuration (if missing) and a new deployment, then polls until the deployment reaches - * RUNNING. Results are cached per {@code (resourceGroupId, configurationName)} pair. - * - * @param resourceGroupId the AI Core resource group to operate in - * @param spec the deployment specification (scenario, executable, configuration name and - * existing-match predicate) - * @return the deployment ID - */ - String deploymentId(String resourceGroupId, ModelDeploymentSpec spec); - - /** - * Returns an {@link ApiClient} preconfigured with the inference destination for the given - * deployment, suitable for constructing foundation-model SDK clients. - * - * @param resourceGroupId the AI Core resource group containing the deployment - * @param deploymentId the deployment ID returned by {@link #deploymentId(String, - * ModelDeploymentSpec)} - * @return a configured {@link ApiClient} pointing at the deployment's inference endpoint - */ - ApiClient inferenceClient(String resourceGroupId, String deploymentId); -} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java index 4fa04d9..04857c0 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/DeploymentIdContext.java @@ -9,9 +9,9 @@ /** * Typed {@link EventContext} for the {@code deploymentId} event. * - *

Emitted by {@link AICoreService#deploymentId(String, ModelDeploymentSpec)} to resolve (or - * create) a deployment matching the given spec inside the given resource group. The ON handler - * performs cache lookup, retry, configuration creation, deployment creation and polling. + *

Emitted on the AI Core service to resolve (or create) a deployment matching the given spec + * inside the given resource group. The ON handler performs cache lookup, retry, configuration + * creation, deployment creation and polling. */ @EventName(DeploymentIdContext.EVENT) public interface DeploymentIdContext extends EventContext { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java index 1b3095e..8ed8710 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/InferenceClientContext.java @@ -10,8 +10,8 @@ /** * Typed {@link EventContext} for the {@code inferenceClient} event. * - *

Emitted by {@link AICoreService#inferenceClient(String, String)} to build an {@link ApiClient} - * preconfigured with the inference destination for the given deployment. + *

Emitted on the AI Core service to build an {@link ApiClient} preconfigured with the inference + * destination for the given deployment. */ @EventName(InferenceClientContext.EVENT) public interface InferenceClientContext extends EventContext { diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java index a51d1b6..72b29f3 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ModelDeploymentSpec.java @@ -9,8 +9,8 @@ import java.util.function.Predicate; /** - * Describes a target AI Core deployment used by {@link AICoreService#deploymentId(String, - * ModelDeploymentSpec)} to look up or create a deployment inside a resource group. + * Describes a target AI Core deployment used by the {@code deploymentId} event to look up or create + * a deployment inside a resource group. * *

The spec carries the AI Core scenario/executable identification, the human-readable * configuration name (used as a stable key for caching and idempotent reuse), the parameter diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java index 19a2a82..9d08328 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/ResourceGroupContext.java @@ -9,9 +9,9 @@ /** * Typed {@link EventContext} for the {@code resourceGroup} event. * - *

Emitted by {@link AICoreService#resourceGroup()} to resolve the AI Core resource group ID for - * the current tenant. In multi-tenancy mode, the resource group is created on-demand if it does not - * exist. In single-tenancy mode, the configured default resource group is returned. + *

Emitted on the AI Core service to resolve the AI Core resource group ID for the current + * tenant. In multi-tenancy mode, the resource group is created on-demand if it does not exist. In + * single-tenancy mode, the configured default resource group is returned. * *

If {@link #getTenantId()} is non-null, the handler uses the explicit tenant ID. Otherwise, the * current tenant is read from the {@code RequestContext}. diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java index 202ed8d..8ad1bda 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceConfiguration.java @@ -7,7 +7,6 @@ import com.sap.ai.sdk.core.client.ConfigurationApi; import com.sap.ai.sdk.core.client.DeploymentApi; import com.sap.ai.sdk.core.client.ResourceGroupApi; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; import com.sap.cds.feature.aicore.core.handler.AICoreSetupHandler; import com.sap.cds.feature.aicore.core.handler.ActionHandler; @@ -17,7 +16,9 @@ import com.sap.cds.feature.aicore.core.handler.MockAICoreSetupHandler; import com.sap.cds.feature.aicore.core.handler.MockEntityHandler; import com.sap.cds.feature.aicore.core.handler.ResourceGroupHandler; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.mt.DeploymentService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; @@ -27,13 +28,16 @@ import org.slf4j.LoggerFactory; /** - * {@link CdsRuntimeConfiguration} that wires the {@code AICore} service and its event handlers into - * the CAP Java runtime. + * {@link CdsRuntimeConfiguration} that wires the {@code AICore} remote service and its event + * handlers into the CAP Java runtime. * - *

Detects the presence of an SAP AI Core service binding (either a regular service binding or - * the {@code AICORE_SERVICE_KEY} environment variable used for hybrid local testing) and registers - * the appropriate handlers. Picked up automatically through {@code ServiceLoader}; applications do - * not need to instantiate this class directly. + *

In the {@link #environment} phase, a {@link RemoteServiceConfig} entry for "AICore" is + * injected into the runtime properties so the framework's {@code RemoteServiceConfiguration} + * auto-creates the service instance from the CDS model. This follows the same pattern used by + * {@code cds-feature-notifications}. + * + *

In the {@link #eventHandlers} phase, production or mock handlers are registered depending on + * whether an AI Core service binding is present. */ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { @@ -43,34 +47,23 @@ public class AICoreServiceConfiguration implements CdsRuntimeConfiguration { private AICoreClients clients; private DeploymentResolver resolver; - private static boolean hasAICoreModel(CdsRuntime runtime) { - return runtime.getCdsModel().findService("AICore").isPresent(); - } - - private static boolean hasAICoreBinding(CdsRuntime runtime) { - return runtime - .getEnvironment() - .getServiceBindings() - .filter(b -> ServiceBindingUtils.matches(b, "aicore")) - .findFirst() - .isPresent(); - } - /** - * Detects multi-tenancy by checking the standard CAP Java {@code cds.multiTenancy.sidecar.url} - * property or the presence of a {@link DeploymentService} in the service catalog. This aligns - * with the standard CAP Java convention — no custom property flag is needed. + * Injects a {@link RemoteServiceConfig} for "AICore" into the runtime properties. This runs + * before all {@code services()} methods, ensuring the framework's {@code + * RemoteServiceConfiguration} will auto-create a {@code RemoteServiceImpl} for the AICore CDS + * service definition. */ - private static boolean detectMultiTenancy(CdsRuntime runtime) { - CdsProperties props = runtime.getEnvironment().getCdsProperties(); - String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); - if (sidecarUrl != null && !sidecarUrl.isBlank()) { - return true; - } - return runtime - .getServiceCatalog() - .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) - != null; + @Override + public void environment(CdsRuntimeConfigurer configurer) { + RemoteServiceConfig remoteConfig = new RemoteServiceConfig(AICore_.CDS_NAME); + remoteConfig.setModel(AICore_.CDS_NAME); + configurer + .getCdsRuntime() + .getEnvironment() + .getCdsProperties() + .getRemote() + .getServices() + .putIfAbsent(AICore_.CDS_NAME, remoteConfig); } @Override @@ -78,7 +71,7 @@ public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); if (!hasAICoreModel(runtime)) { - logger.debug("AICore CDS model not found in runtime model — skipping service registration."); + logger.debug("AICore CDS model not found in runtime model - skipping handler setup."); return; } @@ -96,23 +89,19 @@ public void services(CdsRuntimeConfigurer configurer) { this.clients = new AICoreClients(deploymentApi, configurationApi, resourceGroupApi, sdkService); this.resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - logger.info("Registered AICoreService backed by AI Core binding."); + logger.info("AI Core binding detected - production handlers will be registered."); } else { - logger.info( - "Registered AICoreService (no AI Core binding found — mock handlers will be used)."); + logger.info("No AI Core binding found - mock handlers will be registered."); } - - configurer.service(new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime)); } @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { if (config == null) { - return; // No AICore model — services() skipped registration + return; // No AICore model - services() skipped } if (clients != null) { - // Production path: real AI Core binding configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); @@ -125,7 +114,6 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { logger.debug("Registered AI Core setup handler for MTX subscribe/unsubscribe."); } } else { - // Mock path: no AI Core binding MockAICoreApiHandler mockApiHandler = new MockAICoreApiHandler(config); configurer.eventHandler(new MockEntityHandler()); configurer.eventHandler(mockApiHandler); @@ -137,4 +125,29 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { } } } + + private static boolean hasAICoreModel(CdsRuntime runtime) { + return runtime.getCdsModel().findService(AICore_.CDS_NAME).isPresent(); + } + + private static boolean hasAICoreBinding(CdsRuntime runtime) { + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); + } + + private static boolean detectMultiTenancy(CdsRuntime runtime) { + CdsProperties props = runtime.getEnvironment().getCdsProperties(); + String sidecarUrl = props.getMultiTenancy().getSidecar().getUrl(); + if (sidecarUrl != null && !sidecarUrl.isBlank()) { + return true; + } + return runtime + .getServiceCatalog() + .getService(DeploymentService.class, DeploymentService.DEFAULT_NAME) + != null; + } } diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java deleted file mode 100644 index b1c226c..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. - */ -package com.sap.cds.feature.aicore.core; - -import com.sap.cds.feature.aicore.api.AICoreService; -import com.sap.cds.feature.aicore.api.DeploymentIdContext; -import com.sap.cds.feature.aicore.api.InferenceClientContext; -import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; -import com.sap.cds.feature.aicore.api.ResourceGroupContext; -import com.sap.cds.services.impl.cds.AbstractCdsDefinedService; -import com.sap.cds.services.runtime.CdsRuntime; -import com.sap.cloud.sdk.services.openapi.apache.apiclient.ApiClient; - -/** - * Production implementation of {@link AICoreService}. - * - *

This class is a pure delegation layer: each API method creates a typed {@link - * com.sap.cds.services.EventContext EventContext}, emits it via the CAP event mechanism, and - * returns the handler's result. All business logic (caching, locking, API calls) lives in the - * registered ON handlers which receive their dependencies via constructor injection. - * - *

Implementation note: This class extends {@code AbstractCdsDefinedService} - * from the CAP Java runtime's internal {@code impl} package. This is necessary because the public - * API ({@code ServiceDelegator}) does not provide CQN execution capabilities or CDS model binding. - * The semi-public {@code AbstractCqnService} (from {@code cds-services-utils}) provides CQN but not - * {@code getDefinition()}. Until a public API alternative exists, this coupling is accepted and - * version-compatibility is verified through integration tests against the CAP Java runtime. - */ -public class AICoreServiceImpl extends AbstractCdsDefinedService implements AICoreService { - - private static final String CDS_DEFINITION_NAME = "AICore"; - - public AICoreServiceImpl(String name, CdsRuntime runtime) { - super(name, CDS_DEFINITION_NAME, runtime); - } - - @Override - public String resourceGroup() { - ResourceGroupContext ctx = ResourceGroupContext.create(); - emit(ctx); - return ctx.getResult(); - } - - @Override - public String resourceGroupForTenant(String tenantId) { - ResourceGroupContext ctx = ResourceGroupContext.create(); - ctx.setTenantId(tenantId); - emit(ctx); - return ctx.getResult(); - } - - @Override - public String deploymentId(String resourceGroupId, ModelDeploymentSpec spec) { - DeploymentIdContext ctx = DeploymentIdContext.create(); - ctx.setResourceGroupId(resourceGroupId); - ctx.setSpec(spec); - emit(ctx); - return ctx.getResult(); - } - - @Override - public ApiClient inferenceClient(String resourceGroupId, String deploymentId) { - InferenceClientContext ctx = InferenceClientContext.create(); - ctx.setResourceGroupId(resourceGroupId); - ctx.setDeploymentId(deploymentId); - emit(ctx); - return ctx.getResult(); - } -} diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java index 3ecf8e7..2ab2ee7 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/AICoreApiHandler.java @@ -10,7 +10,6 @@ import com.sap.ai.sdk.core.model.AiDeploymentList; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentStatus; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.InferenceClientContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; @@ -18,6 +17,7 @@ import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; @@ -32,13 +32,13 @@ import org.slf4j.LoggerFactory; /** - * ON handler for the {@link AICoreService} API events ({@code resourceGroup}, {@code deploymentId}, + * ON handler for the AI Core service API events ({@code resourceGroup}, {@code deploymentId}, * {@code inferenceClient}). * *

Contains the business logic for deployment discovery/creation and inference client * construction. Resource-group resolution is delegated to {@link DeploymentResolver}. */ -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore_.CDS_NAME) public class AICoreApiHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(AICoreApiHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java index 2c9d1e7..f2ea8ef 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ActionHandler.java @@ -5,10 +5,10 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; import com.sap.cds.feature.aicore.generated.cds4j.aicore.DeploymentsStopContext; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; @@ -19,7 +19,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore_.CDS_NAME) public class ActionHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ActionHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java index bf4e1d8..01a9f4b 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandler.java @@ -7,10 +7,10 @@ import com.sap.ai.sdk.core.model.AiConfigurationBaseData; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.ai.sdk.core.model.AiParameterArgumentBinding; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ArtifactArgumentBinding; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; @@ -32,7 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore_.CDS_NAME) public class ConfigurationHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ConfigurationHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java index 4657bc4..c16f166 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandler.java @@ -9,10 +9,10 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentTargetStatus; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments; import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; @@ -36,7 +36,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore_.CDS_NAME) public class DeploymentHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(DeploymentHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java index f5155a2..b8916c1 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java @@ -3,12 +3,12 @@ */ package com.sap.cds.feature.aicore.core.handler; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.InferenceClientContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; @@ -20,10 +20,10 @@ import org.slf4j.LoggerFactory; /** - * Mock ON handler for the {@link AICoreService} API events when no AI Core binding is available. - * Uses in-memory maps instead of real API calls. + * Mock ON handler for the AI Core service API events when no AI Core binding is available. Uses + * in-memory maps instead of real API calls. */ -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore_.CDS_NAME) public class MockAICoreApiHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(MockAICoreApiHandler.class); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java index 2ac76ab..a8ea0c9 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java @@ -4,7 +4,10 @@ package com.sap.cds.feature.aicore.core.handler; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; import com.sap.cds.ql.cqn.AnalysisResult; import com.sap.cds.ql.cqn.CqnAnalyzer; import com.sap.cds.ql.cqn.CqnDelete; @@ -25,7 +28,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore_.CDS_NAME) public class MockEntityHandler implements EventHandler { private final Map> resourceGroups = new ConcurrentHashMap<>(); @@ -34,7 +37,7 @@ public class MockEntityHandler implements EventHandler { // --- Resource Groups --- - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void readResourceGroups(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -61,7 +64,7 @@ public void readResourceGroups(CdsReadEventContext context) { } } - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void createResourceGroups(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); @@ -76,7 +79,7 @@ public void createResourceGroups(CdsCreateEventContext context) { context.setResult(results); } - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void updateResourceGroups(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -92,7 +95,7 @@ public void updateResourceGroups(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(existing))); } - @On(entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void deleteResourceGroups(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -105,7 +108,7 @@ public void deleteResourceGroups(CdsDeleteEventContext context) { // --- Deployments --- - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void readDeployments(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -121,7 +124,7 @@ public void readDeployments(CdsReadEventContext context) { } } - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void createDeployments(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); @@ -136,7 +139,7 @@ public void createDeployments(CdsCreateEventContext context) { context.setResult(results); } - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void updateDeployments(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -152,7 +155,7 @@ public void updateDeployments(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(existing))); } - @On(entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void deleteDeployments(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -165,7 +168,7 @@ public void deleteDeployments(CdsDeleteEventContext context) { // --- Configurations --- - @On(entity = AICoreService.CONFIGURATIONS) + @On(entity = Configurations_.CDS_NAME) public void readConfigurations(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -181,7 +184,7 @@ public void readConfigurations(CdsReadEventContext context) { } } - @On(entity = AICoreService.CONFIGURATIONS) + @On(entity = Configurations_.CDS_NAME) public void createConfigurations(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); diff --git a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java index 9471662..567f292 100644 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandler.java @@ -9,10 +9,10 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.CdsData; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups; import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; import com.sap.cds.ql.cqn.AnalysisResult; @@ -36,7 +36,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -@ServiceName(AICoreService.DEFAULT_NAME) +@ServiceName(AICore_.CDS_NAME) public class ResourceGroupHandler extends AbstractCrudHandler { private static final Logger logger = LoggerFactory.getLogger(ResourceGroupHandler.class); diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java index c6612a6..fcea483 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceConfigurationTest.java @@ -5,7 +5,8 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; @@ -17,8 +18,8 @@ * CDS model. This verifies the full service registration and handler wiring lifecycle without heavy * Mockito mocks. * - *

Since the test runtime has no service bindings, the configuration always registers an {@link - * AICoreServiceImpl} with mock handlers regardless of environment variables. + *

Since the test runtime has no service bindings, the configuration always registers mock + * handlers regardless of environment variables. */ class AICoreServiceConfigurationTest { @@ -26,15 +27,16 @@ class AICoreServiceConfigurationTest { void noBinding_noMultiTenancy_registersService() { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .environmentConfigurations() .cdsModel("edmx/csn.json") .serviceConfigurations() .eventHandlerConfigurations() .complete(); - AICoreService service = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); - assertThat(service).isNotNull().isInstanceOf(AICoreServiceImpl.class); + assertThat(service).isNotNull(); } @Test @@ -48,15 +50,16 @@ void noBinding_withSidecarUrl_registersService() { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .environmentConfigurations() .cdsModel("edmx/csn.json") .serviceConfigurations() .eventHandlerConfigurations() .complete(); - AICoreService service = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); - assertThat(service).isNotNull().isInstanceOf(AICoreServiceImpl.class); + assertThat(service).isNotNull(); } @Test @@ -67,8 +70,8 @@ void noModel_skipsServiceRegistration() { .eventHandlerConfigurations() .complete(); - AICoreService service = - runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); assertThat(service).isNull(); } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java index 9c66431..e43f64b 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/AICoreServiceImplDeploymentIdTest.java @@ -23,16 +23,19 @@ import com.sap.ai.sdk.core.model.AiDeploymentList; import com.sap.ai.sdk.core.model.AiDeploymentResponseWithDetails; import com.sap.ai.sdk.core.model.AiDeploymentStatus; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.handler.AICoreApiHandler; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cloud.sdk.services.openapi.apache.core.OpenApiRequestException; import java.lang.reflect.Field; -import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; @@ -53,7 +56,7 @@ class AICoreServiceImplDeploymentIdTest { private DeploymentApi deploymentApi; private ConfigurationApi configurationApi; private ResourceGroupApi resourceGroupApi; - private AICoreServiceImpl service; + private RemoteService service; private DeploymentResolver resolver; private final ModelDeploymentSpec spec = @@ -64,16 +67,19 @@ private String cacheKey() { } /** - * Creates an {@link AICoreServiceImpl} properly registered with a CDS runtime and the {@link + * Creates a {@link RemoteService} properly registered with a CDS runtime and the {@link * AICoreApiHandler} so that {@code emit()} dispatches to the handler. */ - private AICoreServiceImpl createService(boolean multiTenancy) { - TestPropertiesProvider props = new TestPropertiesProvider(); - props.setProperty("cds.ai.core.maxRetries", 1); - props.setProperty("cds.ai.core.initialDelayMs", 1L); - - CdsRuntimeConfigurer configurer = CdsRuntimeConfigurer.create(props); + private RemoteService createService(boolean multiTenancy) { + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore_.CDS_NAME); + rsConfig.setModel(AICore_.CDS_NAME); + props.getRemote().getServices().put(AICore_.CDS_NAME, rsConfig); + + CdsRuntimeConfigurer configurer = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); CdsRuntime runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 1, 1L, multiTenancy); @@ -82,11 +88,10 @@ private AICoreServiceImpl createService(boolean multiTenancy) { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - AICoreServiceImpl svc = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(svc); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.complete(); - return svc; + + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } @BeforeEach @@ -105,7 +110,7 @@ void cacheHit_runningDeployment_returnsCachedIdWithoutQuery() throws Exception { when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); verify(deploymentApi).get(RG, DEPLOYMENT_ID); @@ -130,7 +135,7 @@ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() throws Excepti when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) .thenReturn(list); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(otherDeployment); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), otherDeployment); @@ -144,7 +149,7 @@ void cacheStale_5xxOnGet_propagatesAndPreservesCacheEntry() throws Exception { OpenApiRequestException serverError = new OpenApiRequestException("boom").statusCode(503); when(deploymentApi.get(RG, "still-valid-id")).thenThrow(serverError); - assertThatThrownBy(() -> service.deploymentId(RG, spec)).rootCause().isSameAs(serverError); + assertThatThrownBy(() -> emitDeploymentId(service, RG, spec)).rootCause().isSameAs(serverError); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), "still-valid-id"); verify(deploymentApi, never()).query(any(), any(), any(), any(), any(), any(), any(), any()); @@ -162,7 +167,7 @@ void noCache_existingMatchingDeployment_isReusedAndCached() throws Exception { when(deploymentApi.query(eq(RG), any(), any(), eq(SCENARIO), any(), any(), any(), any())) .thenReturn(list); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); @@ -185,8 +190,8 @@ void secondCallUsesCachedResult_singleQueryToApi() { when(running.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(running); - String first = service.deploymentId(RG, spec); - String second = service.deploymentId(RG, spec); + String first = emitDeploymentId(service, RG, spec); + String second = emitDeploymentId(service, RG, spec); assertThat(first).isEqualTo(DEPLOYMENT_ID); assertThat(second).isEqualTo(DEPLOYMENT_ID); @@ -197,15 +202,15 @@ void secondCallUsesCachedResult_singleQueryToApi() { @Test void resourceGroupForTenant_nullTenantId_returnsDefault() { - AICoreServiceImpl mtService = createService(true); + RemoteService mtService = createService(true); - String result = mtService.resourceGroupForTenant(null); + String result = emitResourceGroup(mtService, null); assertThat(result).isEqualTo("default"); } @Test void resourceGroupForTenant_multiTenancyDisabled_returnsDefault() { - String result = service.resourceGroupForTenant("any-tenant"); + String result = emitResourceGroup(service, "any-tenant"); assertThat(result).isEqualTo("default"); } @@ -232,7 +237,7 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() throws E when(runningPoll.getStatus()).thenReturn(AiDeploymentStatus.RUNNING); when(deploymentApi.get(RG, DEPLOYMENT_ID)).thenReturn(runningPoll); - String result = service.deploymentId(RG, spec); + String result = emitDeploymentId(service, RG, spec); assertThat(result).isEqualTo(DEPLOYMENT_ID); assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), DEPLOYMENT_ID); @@ -244,6 +249,21 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() throws E // Helpers // ────────────────────────────────────────────────────────────────────────── + private static String emitDeploymentId(RemoteService svc, String rg, ModelDeploymentSpec spec) { + DeploymentIdContext ctx = DeploymentIdContext.create(); + ctx.setResourceGroupId(rg); + ctx.setSpec(spec); + svc.emit(ctx); + return ctx.getResult(); + } + + private static String emitResourceGroup(RemoteService svc, String tenantId) { + ResourceGroupContext ctx = ResourceGroupContext.create(); + ctx.setTenantId(tenantId); + svc.emit(ctx); + return ctx.getResult(); + } + @SuppressWarnings("unchecked") private static void putInDeploymentCache(DeploymentResolver resolver, String key, String value) throws Exception { @@ -260,27 +280,4 @@ private static Map getDeploymentCache(DeploymentResolver resolve field.setAccessible(true); return ((com.github.benmanes.caffeine.cache.Cache) field.get(resolver)).asMap(); } - - /** Properties provider that allows overriding specific keys for test configuration. */ - private static class TestPropertiesProvider extends SimplePropertiesProvider { - private final Map properties = new HashMap<>(); - - TestPropertiesProvider() { - super(new CdsProperties()); - } - - void setProperty(String key, Object value) { - properties.put(key, value); - } - - @Override - @SuppressWarnings("unchecked") - public T getProperty(String key, Class asClazz, T defaultValue) { - Object value = properties.get(key); - if (value != null && asClazz.isInstance(value)) { - return (T) value; - } - return defaultValue; - } - } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java index c555b58..00919b7 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/MockAICoreServiceImplTest.java @@ -5,9 +5,12 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.api.ModelDeploymentSpec; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.handler.MockAICoreApiHandler; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.runtime.CdsRuntime; @@ -21,7 +24,7 @@ */ class MockAICoreServiceImplTest { - private AICoreService createMockService(boolean multiTenancy) { + private RemoteService createMockService(boolean multiTenancy) { CdsProperties props = new CdsProperties(); if (multiTenancy) { CdsProperties.MultiTenancy mt = new CdsProperties.MultiTenancy(); @@ -33,55 +36,84 @@ private AICoreService createMockService(boolean multiTenancy) { CdsRuntime runtime = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)) + .environmentConfigurations() .cdsModel("edmx/csn.json") .serviceConfigurations() .eventHandlerConfigurations() .complete(); - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } @Test void noMultiTenancy_resourceGroupReturnsDefault() { - AICoreService service = createMockService(false); - assertThat(service.resourceGroup()).isEqualTo("default"); + RemoteService service = createMockService(false); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("default"); } @Test void noMultiTenancy_resourceGroupForTenant_returnsDefault() { - AICoreService service = createMockService(false); - assertThat(service.resourceGroupForTenant("any-tenant")).isEqualTo("default"); + RemoteService service = createMockService(false); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant"); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("default"); } @Test void multiTenancy_resourceGroupForTenant_returnsPrefixed() { - AICoreService service = createMockService(true); - String rg = service.resourceGroupForTenant("my-tenant"); - assertThat(rg).isEqualTo("cds-my-tenant"); + RemoteService service = createMockService(true); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("my-tenant"); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("cds-my-tenant"); } @Test void multiTenancy_resourceGroupForTenant_cachesResult() { - AICoreService service = createMockService(true); - String first = service.resourceGroupForTenant("t1"); - String second = service.resourceGroupForTenant("t1"); + RemoteService service = createMockService(true); + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("t1"); + service.emit(rgCtx1); + String first = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("t1"); + service.emit(rgCtx2); + String second = rgCtx2.getResult(); assertThat(first).isEqualTo(second); } @Test void deploymentId_returnsMockId() { - AICoreService service = createMockService(false); + RemoteService service = createMockService(false); var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); - String id = service.deploymentId("default", spec); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId("default"); + depCtx.setSpec(spec); + service.emit(depCtx); + String id = depCtx.getResult(); assertThat(id).startsWith("mock-deployment-"); } @Test void deploymentId_cachesSameResult() { - AICoreService service = createMockService(false); + RemoteService service = createMockService(false); var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); - String first = service.deploymentId("default", spec); - String second = service.deploymentId("default", spec); + + DeploymentIdContext depCtx1 = DeploymentIdContext.create(); + depCtx1.setResourceGroupId("default"); + depCtx1.setSpec(spec); + service.emit(depCtx1); + String first = depCtx1.getResult(); + + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId("default"); + depCtx2.setSpec(spec); + service.emit(depCtx2); + String second = depCtx2.getResult(); assertThat(first).isEqualTo(second); } } diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java index 7fdb3e2..03d360a 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ConfigurationHandlerTest.java @@ -20,14 +20,14 @@ import com.sap.ai.sdk.core.model.AiConfigurationCreationResponse; import com.sap.ai.sdk.core.model.AiConfigurationList; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -47,7 +47,7 @@ class ConfigurationHandlerTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static ConfigurationApi configurationApi; private static ResourceGroupApi resourceGroupApi; @@ -57,8 +57,11 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); DeploymentApi deploymentApi = mock(DeploymentApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); @@ -67,11 +70,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ConfigurationHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } @BeforeEach @@ -99,7 +102,7 @@ void onRead_returnsConfigurationsForResourceGroup() { (Function) ctx -> service.run( - Select.from("AICore.configurations") + Select.from(Configurations_.CDS_NAME) .where(c -> c.get("resourceGroup_resourceGroupId").eq("default")))); verify(configurationApi).query(eq("default"), any(), any(), any(), any(), any(), any(), any()); @@ -122,7 +125,7 @@ void onRead_nullResources_returnsEmptyList() { (Function) ctx -> service.run( - Select.from("AICore.configurations") + Select.from(Configurations_.CDS_NAME) .where(c -> c.get("resourceGroup_resourceGroupId").eq("default")))); assertThat(result.list()).isEmpty(); @@ -142,7 +145,7 @@ void onCreate_createsConfiguration() { (Function) ctx -> service.run( - Insert.into("AICore.configurations") + Insert.into(Configurations_.CDS_NAME) .entry( Map.of( "name", "test-config", diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java index 0e47302..c4fad6c 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/DeploymentHandlerTest.java @@ -21,16 +21,16 @@ import com.sap.ai.sdk.core.model.AiDeploymentModificationRequest; import com.sap.ai.sdk.core.model.AiExecutionStatus; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Update; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.ServiceException; -import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -50,7 +50,7 @@ class DeploymentHandlerTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static DeploymentApi deploymentApi; private static ResourceGroupApi resourceGroupApi; private static ConfigurationApi configurationApi; @@ -61,8 +61,11 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); configurationApi = mock(ConfigurationApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); @@ -71,11 +74,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } @BeforeEach @@ -98,7 +101,7 @@ void onCreate_createsDeploymentWithConfigurationId() { (Function) ctx -> service.run( - Insert.into("AICore.deployments") + Insert.into(Deployments_.CDS_NAME) .entry( Map.of( "configurationId", "cfg-1", @@ -125,7 +128,7 @@ void onCreate_withTtl_setsTtlOnRequest() { (Function) ctx -> service.run( - Insert.into("AICore.deployments") + Insert.into(Deployments_.CDS_NAME) .entry( Map.of( "configurationId", "cfg-2", @@ -146,7 +149,7 @@ void onUpdate_withTargetStatus_callsModifyWithTargetStatus() { (Function) ctx -> service.run( - Update.entity("AICore.deployments") + Update.entity(Deployments_.CDS_NAME) .where(d -> d.get("id").eq("dep-123")) .data("targetStatus", "STOPPED"))); @@ -164,7 +167,7 @@ void onUpdate_withConfigurationId_callsModifyWithConfigurationId() { (Function) ctx -> service.run( - Update.entity("AICore.deployments") + Update.entity(Deployments_.CDS_NAME) .where(d -> d.get("id").eq("dep-789")) .data("configurationId", "config-456"))); @@ -184,7 +187,7 @@ void onUpdate_withoutTargetStatusOrConfigurationId_throwsBadRequest() { (Function) ctx -> service.run( - Update.entity("AICore.deployments") + Update.entity(Deployments_.CDS_NAME) .where(d -> d.get("id").eq("dep-x")) .data("ttl", "1d")))) .isInstanceOfSatisfying( diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/HandlerTestUtils.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/HandlerTestUtils.java new file mode 100644 index 0000000..a8ac72c --- /dev/null +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/HandlerTestUtils.java @@ -0,0 +1,23 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-ai contributors. + */ +package com.sap.cds.feature.aicore.core.handler; + +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.environment.CdsProperties.Remote.RemoteServiceConfig; + +/** Shared test utilities for handler tests that boot a CDS runtime with the AICore model. */ +final class HandlerTestUtils { + + private HandlerTestUtils() {} + + /** Creates CdsProperties with the AICore RemoteService configured. */ + static CdsProperties aicoreProperties() { + CdsProperties props = new CdsProperties(); + RemoteServiceConfig rsConfig = new RemoteServiceConfig(AICore_.CDS_NAME); + rsConfig.setModel(AICore_.CDS_NAME); + props.getRemote().getServices().put(AICore_.CDS_NAME, rsConfig); + return props; + } +} diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java index 4dfc443..abe4335 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/ResourceGroupHandlerTest.java @@ -22,15 +22,15 @@ import com.sap.ai.sdk.core.model.BckndResourceGroupPatchRequest; import com.sap.ai.sdk.core.model.BckndResourceGroupsPostRequest; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; -import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -51,7 +51,7 @@ class ResourceGroupHandlerTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static ResourceGroupApi resourceGroupApi; @BeforeAll @@ -60,8 +60,11 @@ static void bootRuntime() { DeploymentApi deploymentApi = mock(DeploymentApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, false); @@ -70,11 +73,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } @BeforeEach @@ -97,7 +100,7 @@ void onRead_returnsAllResourceGroups() { .requestContext() .run( (Function) - ctx -> service.run(Select.from("AICore.resourceGroups"))); + ctx -> service.run(Select.from(ResourceGroups_.CDS_NAME))); verify(resourceGroupApi).getAll(any(), any(), any(), any(), any(), any(), any()); assertThat(result.list()).hasSize(1); @@ -112,7 +115,7 @@ void onCreate_createsResourceGroup() { (Function) ctx -> service.run( - Insert.into("AICore.resourceGroups") + Insert.into(ResourceGroups_.CDS_NAME) .entry(Map.of("resourceGroupId", "rg-new")))); ArgumentCaptor captor = @@ -129,7 +132,7 @@ void onCreate_withTenantId_setsTenantLabel() { (Function) ctx -> service.run( - Insert.into("AICore.resourceGroups") + Insert.into(ResourceGroups_.CDS_NAME) .entry( Map.of( "resourceGroupId", "rg-tenant", @@ -156,7 +159,7 @@ void onUpdate_withLabels_callsPatchWithLabels() { (Function) ctx -> service.run( - Update.entity("AICore.resourceGroups") + Update.entity(ResourceGroups_.CDS_NAME) .where(d -> d.get("resourceGroupId").eq("rg-upd")) .data("labels", List.of(Map.of("key", "env", "value", "staging"))))); @@ -181,7 +184,7 @@ void onUpdate_withoutLabels_callsPatchWithoutLabels() { (Function) ctx -> service.run( - Update.entity("AICore.resourceGroups") + Update.entity(ResourceGroups_.CDS_NAME) .where(d -> d.get("resourceGroupId").eq("rg-nolabel")) .data("statusMessage", "updated"))); @@ -199,7 +202,7 @@ void onUpdate_withoutLabels_callsPatchWithoutLabels() { class MultiTenancyTests { private static CdsRuntime mtRuntime; - private static AICoreServiceImpl mtService; + private static RemoteService mtService; private static ResourceGroupApi mtResourceGroupApi; @BeforeAll @@ -208,9 +211,11 @@ static void bootMtRuntime() { DeploymentApi deploymentApi = mock(DeploymentApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = - CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); mtRuntime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); @@ -220,11 +225,11 @@ static void bootMtRuntime() { DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, mtResourceGroupApi); - mtService = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, mtRuntime); - configurer.service(mtService); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); configurer.complete(); + + mtService = mtRuntime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } @BeforeEach @@ -251,7 +256,7 @@ void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() { .setIsAuthenticated(true)) .run( (Function) - ctx -> mtService.run(Select.from("AICore.resourceGroups"))); + ctx -> mtService.run(Select.from(ResourceGroups_.CDS_NAME))); ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); verify(mtResourceGroupApi) diff --git a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java index 078779c..a79326f 100644 --- a/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java +++ b/cds-feature-ai-core/src/test/java/com/sap/cds/feature/aicore/core/handler/TenantScopingTest.java @@ -18,14 +18,14 @@ import com.sap.ai.sdk.core.model.BckndResourceGroup; import com.sap.ai.sdk.core.model.BckndResourceGroupLabel; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; import com.sap.cds.feature.aicore.core.AICoreClients; import com.sap.cds.feature.aicore.core.AICoreConfig; -import com.sap.cds.feature.aicore.core.AICoreServiceImpl; import com.sap.cds.feature.aicore.core.DeploymentResolver; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.ql.Select; import com.sap.cds.services.ServiceException; -import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.request.RequestContext; import com.sap.cds.services.runtime.CdsRuntime; @@ -44,7 +44,7 @@ class TenantScopingTest { private static CdsRuntime runtime; - private static AICoreServiceImpl service; + private static RemoteService service; private static DeploymentApi deploymentApi; private static ResourceGroupApi resourceGroupApi; @@ -54,8 +54,11 @@ static void bootRuntime() { resourceGroupApi = mock(ResourceGroupApi.class); ConfigurationApi configurationApi = mock(ConfigurationApi.class); - var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())); + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); runtime = configurer.getCdsRuntime(); AICoreConfig config = new AICoreConfig("default", "cds-", 10, 300, true); @@ -64,11 +67,11 @@ static void bootRuntime() { deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); - service = new AICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime); - configurer.service(service); configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); configurer.complete(); + + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } @BeforeEach @@ -96,7 +99,7 @@ void matchingTenant_allowsAccess() { (Function) ctx -> service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where( d -> d.get("resourceGroup_resourceGroupId") @@ -123,7 +126,7 @@ void nonMatchingTenant_throws404() { (Function) ctx -> service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where( d -> d.get("resourceGroup_resourceGroupId") @@ -147,7 +150,7 @@ void providerUser_bypassesTenantCheck() { (Function) ctx -> service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where( d -> d.get("resourceGroup_resourceGroupId") @@ -175,7 +178,7 @@ void nullTenantUser_bypassesTenantCheck() { (Function) ctx -> service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where( d -> d.get("resourceGroup_resourceGroupId") @@ -204,7 +207,7 @@ void noLabelsOnResourceGroup_throws404() { (Function) ctx -> service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where( d -> d.get("resourceGroup_resourceGroupId") @@ -234,7 +237,7 @@ void emptyLabelsOnResourceGroup_throws404() { (Function) ctx -> service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where( d -> d.get("resourceGroup_resourceGroupId") diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java index 529c7cf..773526e 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationConfiguration.java @@ -3,12 +3,16 @@ */ package com.sap.cds.feature.recommendation; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; import com.sap.cds.feature.recommendation.api.RecommendationClient; import com.sap.cds.feature.recommendation.api.RecommendationClientResolver; import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; @@ -27,8 +31,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); ServiceCatalog serviceCatalog = runtime.getServiceCatalog(); - AICoreService aiCoreService = - serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME); + RemoteService aiCoreService = serviceCatalog.getService(RemoteService.class, AICore_.CDS_NAME); if (aiCoreService == null) { logger.info("No AICoreService found, skipping Fiori recommendation handler registration."); @@ -47,7 +50,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { boolean hasBind = hasAICoreBinding(runtime); // The real resolver is a lambda resolved at prediction time. That's necessary because // resource group and deployment ID are tenant-specific and are only available at - // prediction time from the request context. AICoreService is captured in the closure. + // prediction time from the request context. The RemoteService is captured in the closure. RecommendationClientResolver> clientResolver = hasBind ? keyNames -> resolveRptClient(aiCoreService, keyNames) @@ -68,9 +71,33 @@ private static boolean hasAICoreBinding(CdsRuntime runtime) { } private static RecommendationClient resolveRptClient( - AICoreService service, List keyNames) { - String resourceGroup = service.resourceGroup(); - String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - return new RptInferenceClient(service.inferenceClient(resourceGroup, deploymentId), keyNames); + RemoteService service, List keyNames) { + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String resourceGroup = rgCtx.getResult(); + if (resourceGroup == null) { + throw new IllegalStateException("Failed to resolve resource group from AI Core service"); + } + + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); + if (deploymentId == null) { + throw new IllegalStateException( + "Failed to resolve deployment ID for resource group: " + resourceGroup); + } + + InferenceClientContext infCtx = InferenceClientContext.create(); + infCtx.setResourceGroupId(resourceGroup); + infCtx.setDeploymentId(deploymentId); + service.emit(infCtx); + if (infCtx.getResult() == null) { + throw new IllegalStateException( + "Failed to create inference client for deployment: " + deploymentId); + } + + return new RptInferenceClient(infCtx.getResult(), keyNames); } } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index 049d6e5..d0c105c 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -124,10 +124,11 @@ CdsData buildPredictRow(CdsData row) { } Set allowed = new HashSet<>(contextColumns); allowed.addAll(keyNames); - Map predictRow = - allowed.stream() - .filter(row::containsKey) - .collect(HashMap::new, (m, col) -> m.put(col, row.get(col)), HashMap::putAll); + Map predictRow = new HashMap<>(); + allowed.forEach( + col -> { + if (row.containsKey(col)) predictRow.put(col, row.get(col)); + }); return CdsData.create(predictRow); } diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java index c8058e6..c3e3c43 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/api/RptInferenceClient.java @@ -33,10 +33,19 @@ *

Example usage: * *

{@code
- * AICoreService service = ...;
- * String rg = service.resourceGroup();
- * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1());
- * RptInferenceClient client = new RptInferenceClient(service.inferenceClient(rg, deploymentId), keyNames);
+ * RemoteService service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME);
+ * ResourceGroupContext rgCtx = ResourceGroupContext.create();
+ * service.emit(rgCtx);
+ * String rg = rgCtx.getResult();
+ * DeploymentIdContext depCtx = DeploymentIdContext.create();
+ * depCtx.setResourceGroupId(rg);
+ * depCtx.setSpec(RptModelSpec.rpt1());
+ * service.emit(depCtx);
+ * InferenceClientContext infCtx = InferenceClientContext.create();
+ * infCtx.setResourceGroupId(rg);
+ * infCtx.setDeploymentId(depCtx.getResult());
+ * service.emit(infCtx);
+ * RptInferenceClient client = new RptInferenceClient(infCtx.getResult(), keyNames);
  * List predictions = client.predict(predictionRow, contextRows, List.of("targetColumn"));
  * }
*/ @@ -124,7 +133,7 @@ private static PredictRequestPayload buildRequest( // RPT-1 requires exactly one string-typed index column per row to identify predictions. // When the entity key is composite or non-string, then the index column is - // RptIndexColumns.SYNTHETIC_INDEX_COLUMN and we need to compute the sytheticKey for all rows + // RptIndexColumns.SYNTHETIC_INDEX_COLUMN and we need to compute the syntheticKey for all rows // before sending them to RPT-1. boolean syntheticKeyNeeded = RptIndexColumns.SYNTHETIC_INDEX_COLUMN.equals(indexColumn); var sdkRows = diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index c21c823..b25faa9 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -31,11 +31,8 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) class FioriRecommendationHandlerTest { private static CdsRuntime runtime; @@ -61,7 +58,7 @@ void setup() { reset(db); when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); predictionClient = randomPickClient(); - cut = new FioriRecommendationHandler(keyNames -> predictionClient, db); + cut = new FioriRecommendationHandler((keyNames) -> predictionClient, db); } // ── tests ────────────────────────────────────────────────────────────────── diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java index 8b3ebce..ee9ee52 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/RecommendationConfigurationTest.java @@ -5,8 +5,9 @@ import static org.mockito.Mockito.*; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; import com.sap.cds.services.ServiceCatalog; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; @@ -24,7 +25,7 @@ class RecommendationConfigurationTest { @Mock private CdsRuntime runtime; @Mock private ServiceCatalog serviceCatalog; @Mock private CdsEnvironment environment; - @Mock private AICoreService aiCoreService; + @Mock private RemoteService aiCoreService; @Mock private PersistenceService persistenceService; @Test @@ -33,7 +34,7 @@ void aiCoreServiceFound_registersHandler() { when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); when(runtime.getEnvironment()).thenReturn(environment); when(environment.getServiceBindings()).thenReturn(Stream.empty()); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) + when(serviceCatalog.getService(RemoteService.class, AICore_.CDS_NAME)) .thenReturn(aiCoreService); when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) .thenReturn(persistenceService); @@ -47,8 +48,7 @@ void aiCoreServiceFound_registersHandler() { void aiCoreServiceNull_doesNotRegisterHandler() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) - .thenReturn(null); + when(serviceCatalog.getService(RemoteService.class, AICore_.CDS_NAME)).thenReturn(null); new RecommendationConfiguration().eventHandlers(configurer); diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java index 8abce82..54586bb 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -5,12 +5,40 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.sap.cds.CdsData; +import com.sap.cds.feature.recommendation.RptIndexColumns; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; class RptInferenceClientTest { + @Test + void resolveIndexColumn_singleStringKey_usesItDirectly() { + CdsData row = CdsData.create(Map.of("isbn", "978-3-16")); + assertThat(RptIndexColumns.resolveIndexColumn(List.of("isbn"), row)).isEqualTo("isbn"); + } + + @Test + void resolveIndexColumn_singleUuidKey_usesItDirectly() { + CdsData row = CdsData.create(Map.of("ID", "a009c640-434a-4542-ac68-51b400c880ec")); + assertThat(RptIndexColumns.resolveIndexColumn(List.of("ID"), row)).isEqualTo("ID"); + } + + @Test + void resolveIndexColumn_singleIntegerKey_usesSyntheticColumn() { + CdsData row = CdsData.create(Map.of("order_ID", 42)); + assertThat(RptIndexColumns.resolveIndexColumn(List.of("order_ID"), row)) + .isEqualTo("SAP_RECOMMENDATIONS_ID"); + } + + @Test + void resolveIndexColumn_compositeKey_usesSyntheticColumn() { + CdsData row = CdsData.create(Map.of("order_ID", 1, "item_no", 10)); + assertThat(RptIndexColumns.resolveIndexColumn(List.of("order_ID", "item_no"), row)) + .isEqualTo("SAP_RECOMMENDATIONS_ID"); + } + @Test void computeSyntheticKey_singleKey() { String key = RptInferenceClient.computeSyntheticKey(Map.of("ID", "abc"), List.of("ID")); diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java index 6e88c40..30e5a4d 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/MtxLifecycleTest.java @@ -7,8 +7,10 @@ import static org.assertj.core.api.Assertions.assertThatCode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -57,19 +59,22 @@ void unsubscribe_isIdempotent() throws Exception { @Test void subscribeUnsubscribe_repeatedTwice_completesCleanly() throws Exception { - AICoreService service = getService(); + RemoteService service = getService(); for (int i = 0; i < 2; i++) { subscriptionEndpointClient.subscribeTenant(TENANT); // After subscribe, the service should resolve a resource group for this tenant - String rg = service.resourceGroupForTenant(TENANT); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(TENANT); + service.emit(rgCtx); + String rg = rgCtx.getResult(); assertThat(rg).isNotNull().isNotBlank(); subscriptionEndpointClient.unsubscribeTenant(TENANT); } } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java index eb1b9d2..185d387 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/SubscribeUnsubscribeTest.java @@ -9,8 +9,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -50,12 +52,15 @@ void subscribeTenant_thenServiceIsReachable() throws Exception { @Test void subscribeTenant_createsResourceGroup() throws Exception { - AICoreService service = getService(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-3"); // After subscription, the service should be able to resolve a resource group for the tenant - String resourceGroup = service.resourceGroupForTenant("tenant-3"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("tenant-3"); + service.emit(rgCtx); + String resourceGroup = rgCtx.getResult(); assertThat(resourceGroup).isNotNull().isNotBlank(); } @@ -82,7 +87,7 @@ void tearDown() { } } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } } diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java index 7aa06b8..d30830f 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/aicore/itest/mt/TenantIsolationTest.java @@ -6,9 +6,11 @@ import static org.assertj.core.api.Assertions.assertThat; import com.fasterxml.jackson.databind.ObjectMapper; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.aicore.itest.mt.utils.SubscriptionEndpointClient; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.runtime.CdsRuntime; import org.junit.jupiter.api.AfterEach; @@ -44,13 +46,20 @@ void multiTenancyEnabled() { @Test void differentTenants_getDifferentResourceGroups() throws Exception { - AICoreService service = getService(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); subscriptionEndpointClient.subscribeTenant("tenant-2"); - String rg1 = service.resourceGroupForTenant("tenant-1"); - String rg2 = service.resourceGroupForTenant("tenant-2"); + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("tenant-1"); + service.emit(rgCtx1); + String rg1 = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("tenant-2"); + service.emit(rgCtx2); + String rg2 = rgCtx2.getResult(); assertThat(rg1).isNotNull(); assertThat(rg2).isNotNull(); @@ -60,16 +69,19 @@ void differentTenants_getDifferentResourceGroups() throws Exception { @Test void resourceGroupPrefix_applied() throws Exception { AICoreConfig config = getConfig(); - AICoreService service = getService(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); - String rg = service.resourceGroupForTenant("tenant-1"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("tenant-1"); + service.emit(rgCtx); + String rg = rgCtx.getResult(); assertThat(rg).startsWith(config.resourceGroupPrefix()); } - private AICoreService getService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } private AICoreConfig getConfig() { diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java index ce0c89b..795d6f8 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/AICoreServiceTest.java @@ -5,9 +5,11 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; import com.sap.cds.feature.recommendation.api.RptModelSpec; +import com.sap.cds.services.cds.RemoteService; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -23,15 +25,18 @@ void prepareDeployment() { @Test void service_isRegistered() { assertThat(getAICoreService()).isNotNull(); - assertThat(getAICoreService()).isInstanceOf(AICoreService.class); + assertThat(getAICoreService()).isInstanceOf(RemoteService.class); } @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); if (!config.multiTenancyEnabled()) { - String result = service.resourceGroupForTenant("any-tenant"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant"); + service.emit(rgCtx); + String result = rgCtx.getResult(); assertThat(result).isEqualTo(config.defaultResourceGroup()); } } @@ -39,29 +44,43 @@ void resourceGroupForTenant_singleTenancy_returnsDefault() { @Test void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); if (config.multiTenancyEnabled()) { String tenantId = "itest-svc-tenant-" + System.currentTimeMillis(); - String resourceGroupId = service.resourceGroupForTenant(tenantId); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantId); + service.emit(rgCtx); + String resourceGroupId = rgCtx.getResult(); assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); assertThat(resourceGroupId).contains(tenantId); // Second call should return cached value - String cached = service.resourceGroupForTenant(tenantId); + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId(tenantId); + service.emit(rgCtx2); + String cached = rgCtx2.getResult(); assertThat(cached).isEqualTo(resourceGroupId); } } @Test void deploymentId_returnsDeploymentId() { - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); - String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); assertThat(deploymentId).isNotNull().isNotBlank(); // Second call should use cache - String cached = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId(resourceGroup); + depCtx2.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx2); + String cached = depCtx2.getResult(); assertThat(cached).isEqualTo(deploymentId); } diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java index 0e7bf34..42ae5d3 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ActionTest.java @@ -9,12 +9,14 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.Map; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; @@ -28,45 +30,64 @@ class ActionTest extends BaseIntegrationTest { @BeforeAll void ensureResourceGroupReady() { - ensureResourceGroupProvisioned(getAICoreCqnService(), getAICoreConfig().defaultResourceGroup()); + ensureResourceGroupProvisioned(getAICoreService(), getAICoreConfig().defaultResourceGroup()); } @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); - String result = service.resourceGroupForTenant("any-tenant-id"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant-id"); + service.emit(rgCtx); + String result = rgCtx.getResult(); assertThat(result).isEqualTo(config.defaultResourceGroup()); } @Test void resourceGroupForTenant_multiTenancy_createsGroup() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantId = "itest-action-tenant-" + System.currentTimeMillis(); - String resourceGroupId = service.resourceGroupForTenant(tenantId); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantId); + service.emit(rgCtx); + String resourceGroupId = rgCtx.getResult(); assertThat(resourceGroupId).startsWith(config.resourceGroupPrefix()); assertThat(resourceGroupId).contains(tenantId); } @Test void deploymentId_returnsValidDeployment() { - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); - String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroup); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); assertThat(deploymentId).isNotNull().isNotBlank(); } @Test void deploymentId_cachedOnSecondCall() { - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); - String first = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - String second = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + DeploymentIdContext depCtx1 = DeploymentIdContext.create(); + depCtx1.setResourceGroupId(resourceGroup); + depCtx1.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx1); + String first = depCtx1.getResult(); + + DeploymentIdContext depCtx2 = DeploymentIdContext.create(); + depCtx2.setResourceGroupId(resourceGroup); + depCtx2.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx2); + String second = depCtx2.getResult(); assertThat(second).isEqualTo(first); } @@ -75,12 +96,12 @@ void deploymentId_cachedOnSecondCall() { + "re-enable once test creates its own isolated deployment") @Test void stop_deployment_changesTargetStatus() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result deployments = service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); String deploymentId = null; @@ -96,14 +117,14 @@ void stop_deployment_changesTargetStatus() { final String targetId = deploymentId; service.run( - Update.entity("AICore.deployments") + Update.entity(Deployments_.CDS_NAME) .where(d -> d.get("id").eq(targetId)) .data( Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); Result readResult = service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where( d -> d.get("id") diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java index 06a09af..ea356aa 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/BaseIntegrationTest.java @@ -5,12 +5,14 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.environment.CdsProperties; import com.sap.cds.services.runtime.CdsRuntime; import java.util.List; @@ -39,8 +41,8 @@ public abstract class BaseIntegrationTest { @Autowired protected CdsRuntime runtime; - protected AICoreService getAICoreService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + protected RemoteService getAICoreService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } protected AICoreConfig getAICoreConfig() { @@ -50,25 +52,26 @@ protected AICoreConfig getAICoreConfig() { return AICoreConfig.from(runtime.getEnvironment(), mt); } - protected CqnService getAICoreCqnService() { - return (CqnService) getAICoreService(); - } - protected String ensureRptDeploymentReady() { String resourceGroup = getAICoreConfig().defaultResourceGroup(); return CACHED_DEPLOYMENT_IDS.computeIfAbsent( resourceGroup, rg -> { - ensureResourceGroupProvisioned(getAICoreCqnService(), rg); - return getAICoreService().deploymentId(rg, RptModelSpec.rpt1()); + ensureResourceGroupProvisioned(getAICoreService(), rg); + RemoteService service = getAICoreService(); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(rg); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + return depCtx.getResult(); }); } - protected void ensureResourceGroupProvisioned(CqnService service, String resourceGroup) { + protected void ensureResourceGroupProvisioned(RemoteService service, String resourceGroup) { if (!resourceGroupExists(service, resourceGroup)) { logger.info("Creating resource group {} with itest owner label", resourceGroup); service.run( - Insert.into("AICore.resourceGroups") + Insert.into(ResourceGroups_.CDS_NAME) .entry( Map.of( "resourceGroupId", @@ -79,8 +82,8 @@ protected void ensureResourceGroupProvisioned(CqnService service, String resourc waitForResourceGroupProvisioned(service, resourceGroup); } - private boolean resourceGroupExists(CqnService service, String resourceGroup) { - Result all = service.run(Select.from("AICore.resourceGroups")); + private boolean resourceGroupExists(RemoteService service, String resourceGroup) { + Result all = service.run(Select.from(ResourceGroups_.CDS_NAME)); for (Row row : all) { if (resourceGroup.equals(row.get("resourceGroupId"))) { return true; @@ -89,9 +92,9 @@ private boolean resourceGroupExists(CqnService service, String resourceGroup) { return false; } - private void waitForResourceGroupProvisioned(CqnService service, String resourceGroup) { + private void waitForResourceGroupProvisioned(RemoteService service, String resourceGroup) { for (int i = 0; i < 30; i++) { - Result all = service.run(Select.from("AICore.resourceGroups")); + Result all = service.run(Select.from(ResourceGroups_.CDS_NAME)); for (Row row : all) { if (resourceGroup.equals(row.get("resourceGroupId"))) { String status = (String) row.get("status"); diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java index 2b1e5b5..db4a823 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ConfigurationTest.java @@ -7,9 +7,10 @@ import com.sap.cds.Result; import com.sap.cds.Row; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -18,11 +19,11 @@ class ConfigurationTest extends BaseIntegrationTest { @Test void readAll_returnsConfigurations() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( - Select.from("AICore.configurations") + Select.from(Configurations_.CDS_NAME) .where(c -> c.get("resourceGroup_resourceGroupId").eq(resourceGroup))); assertThat(result.list()).isNotNull(); @@ -30,11 +31,11 @@ void readAll_returnsConfigurations() { @Test void readAll_filterByScenario() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( - Select.from("AICore.configurations") + Select.from(Configurations_.CDS_NAME) .where( c -> c.get("scenarioId") @@ -46,13 +47,13 @@ void readAll_filterByScenario() { @Test void create_andReadById() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); String configName = "itest-config-" + System.currentTimeMillis(); Result created = service.run( - Insert.into("AICore.configurations") + Insert.into(Configurations_.CDS_NAME) .entry( Map.of( "name", @@ -75,7 +76,7 @@ void create_andReadById() { // Read back by ID Result readResult = service.run( - Select.from("AICore.configurations") + Select.from(Configurations_.CDS_NAME) .where( c -> c.get("id") @@ -91,13 +92,13 @@ void create_andReadById() { @Test void create_withParameterBindings_mapsCorrectly() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); String configName = "itest-params-" + System.currentTimeMillis(); Result created = service.run( - Insert.into("AICore.configurations") + Insert.into(Configurations_.CDS_NAME) .entry( Map.of( "name", @@ -117,7 +118,7 @@ void create_withParameterBindings_mapsCorrectly() { Result readResult = service.run( - Select.from("AICore.configurations") + Select.from(Configurations_.CDS_NAME) .where( c -> c.get("id") diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java index d73fd50..e4fbba8 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/DeploymentTest.java @@ -8,9 +8,10 @@ import com.sap.cds.Result; import com.sap.cds.Row; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; import com.sap.cds.ql.Select; import com.sap.cds.ql.Update; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.Map; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -19,11 +20,11 @@ class DeploymentTest extends BaseIntegrationTest { @Test void readAll_returnsDeployments() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result result = service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); assertThat(result.list()).isNotNull(); @@ -31,11 +32,11 @@ void readAll_returnsDeployments() { @Test void readSingle_returnsDeploymentDetails() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result all = service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); assumeFalse(all.list().isEmpty(), "No deployments available"); @@ -43,7 +44,7 @@ void readSingle_returnsDeploymentDetails() { String id = (String) all.list().get(0).get("id"); Result single = service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where( d -> d.get("id") @@ -62,12 +63,12 @@ void readSingle_returnsDeploymentDetails() { + "re-enable once test creates its own isolated deployment") @Test void update_targetStatus_stopsRunningDeployment() { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); String resourceGroup = getAICoreConfig().defaultResourceGroup(); Result deployments = service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where(d -> d.get("resourceGroup_resourceGroupId").eq(resourceGroup))); String deploymentId = null; @@ -83,14 +84,14 @@ void update_targetStatus_stopsRunningDeployment() { final String targetId = deploymentId; service.run( - Update.entity("AICore.deployments") + Update.entity(Deployments_.CDS_NAME) .where(d -> d.get("id").eq(targetId)) .data( Map.of("targetStatus", "STOPPED", "resourceGroup_resourceGroupId", resourceGroup))); Result readResult = service.run( - Select.from("AICore.deployments") + Select.from(Deployments_.CDS_NAME) .where( d -> d.get("id") diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java index 2716d06..9d88af9 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/MultiTenancyTest.java @@ -7,8 +7,9 @@ import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.aicore.core.AICoreConfig; +import com.sap.cds.services.cds.RemoteService; import org.junit.jupiter.api.Test; class MultiTenancyTest extends BaseIntegrationTest { @@ -16,13 +17,20 @@ class MultiTenancyTest extends BaseIntegrationTest { @Test void differentTenants_getDifferentResourceGroups() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantA = "itest-mt-a-" + System.currentTimeMillis(); String tenantB = "itest-mt-b-" + System.currentTimeMillis(); - String rgA = service.resourceGroupForTenant(tenantA); - String rgB = service.resourceGroupForTenant(tenantB); + ResourceGroupContext rgCtxA = ResourceGroupContext.create(); + rgCtxA.setTenantId(tenantA); + service.emit(rgCtxA); + String rgA = rgCtxA.getResult(); + + ResourceGroupContext rgCtxB = ResourceGroupContext.create(); + rgCtxB.setTenantId(tenantB); + service.emit(rgCtxB); + String rgB = rgCtxB.getResult(); assertThat(rgA).isNotEqualTo(rgB); assertThat(rgA).contains(tenantA); @@ -32,21 +40,32 @@ void differentTenants_getDifferentResourceGroups() { @Test void resourceGroupPrefix_appliedCorrectly() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); String tenantA = "itest-prefix-" + System.currentTimeMillis(); - String rg = service.resourceGroupForTenant(tenantA); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantA); + service.emit(rgCtx); + String rg = rgCtx.getResult(); assertThat(rg).startsWith(config.resourceGroupPrefix()); } @Test void singleTenancy_alwaysReturnsDefault() { AICoreConfig config = getAICoreConfig(); - AICoreService service = getAICoreService(); + RemoteService service = getAICoreService(); assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); - String rg1 = service.resourceGroupForTenant("tenant-x"); - String rg2 = service.resourceGroupForTenant("tenant-y"); + + ResourceGroupContext rgCtx1 = ResourceGroupContext.create(); + rgCtx1.setTenantId("tenant-x"); + service.emit(rgCtx1); + String rg1 = rgCtx1.getResult(); + + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId("tenant-y"); + service.emit(rgCtx2); + String rg2 = rgCtx2.getResult(); assertThat(rg1).isEqualTo(config.defaultResourceGroup()); assertThat(rg2).isEqualTo(config.defaultResourceGroup()); diff --git a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java index e6ca394..fea4ff2 100644 --- a/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java +++ b/integration-tests/spring/src/test/java/com/sap/cds/feature/aicore/itest/ResourceGroupTest.java @@ -8,10 +8,11 @@ import com.sap.cds.Result; import com.sap.cds.Row; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.ResourceGroups_; import com.sap.cds.ql.Delete; import com.sap.cds.ql.Insert; import com.sap.cds.ql.Select; -import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import java.util.List; import java.util.Map; import org.junit.jupiter.api.AfterEach; @@ -30,10 +31,10 @@ class ResourceGroupTest extends BaseIntegrationTest { void cleanup() { if (createdResourceGroupId != null) { try { - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); waitForResourceGroupProvisioned(service, createdResourceGroupId); service.run( - Delete.from("AICore.resourceGroups") + Delete.from(ResourceGroups_.CDS_NAME) .where(r -> r.get("resourceGroupId").eq(createdResourceGroupId))); } catch (Exception ignored) { } @@ -44,15 +45,15 @@ void cleanup() { @Test void create_andRead_resourceGroup() { createdResourceGroupId = TEST_RG_PREFIX + System.currentTimeMillis(); - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); service.run( - Insert.into("AICore.resourceGroups") + Insert.into(ResourceGroups_.CDS_NAME) .entry(Map.of("resourceGroupId", createdResourceGroupId))); Result result = service.run( - Select.from("AICore.resourceGroups") + Select.from(ResourceGroups_.CDS_NAME) .where(r -> r.get("resourceGroupId").eq(createdResourceGroupId))); assertThat(result.list()).hasSize(1); @@ -65,15 +66,15 @@ void create_andRead_resourceGroup() { void create_withTenantLabel_andFilterByTenant() { String tenantId = "itest-tenant-" + System.currentTimeMillis(); createdResourceGroupId = TEST_RG_PREFIX + tenantId; - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); service.run( - Insert.into("AICore.resourceGroups") + Insert.into(ResourceGroups_.CDS_NAME) .entry(Map.of("resourceGroupId", createdResourceGroupId, "tenantId", tenantId))); Result result = service.run( - Select.from("AICore.resourceGroups").where(r -> r.get("tenantId").eq(tenantId))); + Select.from(ResourceGroups_.CDS_NAME).where(r -> r.get("tenantId").eq(tenantId))); assertThat(result.list()).isNotEmpty(); Row row = result.first().orElseThrow(); @@ -82,18 +83,18 @@ void create_withTenantLabel_andFilterByTenant() { @Test void readAll_returnsResourceGroups() { - CqnService service = getAICoreCqnService(); - Result result = service.run(Select.from("AICore.resourceGroups")); + RemoteService service = getAICoreService(); + Result result = service.run(Select.from(ResourceGroups_.CDS_NAME)); assertThat(result.list()).isNotNull(); } @Test void create_withLabels() { createdResourceGroupId = TEST_RG_PREFIX + "labels-" + System.currentTimeMillis(); - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); service.run( - Insert.into("AICore.resourceGroups") + Insert.into(ResourceGroups_.CDS_NAME) .entry( Map.of( "resourceGroupId", @@ -106,7 +107,7 @@ void create_withLabels() { Result result = service.run( - Select.from("AICore.resourceGroups") + Select.from(ResourceGroups_.CDS_NAME) .where(r -> r.get("resourceGroupId").eq(createdResourceGroupId))); assertThat(result.list()).hasSize(1); @@ -119,28 +120,28 @@ void create_withLabels() { @Test void delete_resourceGroup() throws InterruptedException { String rgId = TEST_RG_PREFIX + "del-" + System.currentTimeMillis(); - CqnService service = getAICoreCqnService(); + RemoteService service = getAICoreService(); - service.run(Insert.into("AICore.resourceGroups").entry(Map.of("resourceGroupId", rgId))); + service.run(Insert.into(ResourceGroups_.CDS_NAME).entry(Map.of("resourceGroupId", rgId))); waitForResourceGroupProvisioned(service, rgId); assertThatCode( () -> service.run( - Delete.from("AICore.resourceGroups") + Delete.from(ResourceGroups_.CDS_NAME) .where(r -> r.get("resourceGroupId").eq(rgId)))) .doesNotThrowAnyException(); createdResourceGroupId = null; // already deleted } - private void waitForResourceGroupProvisioned(CqnService service, String rgId) + private void waitForResourceGroupProvisioned(RemoteService service, String rgId) throws InterruptedException { for (int i = 0; i < 30; i++) { Result result = service.run( - Select.from("AICore.resourceGroups").where(r -> r.get("resourceGroupId").eq(rgId))); + Select.from(ResourceGroups_.CDS_NAME).where(r -> r.get("resourceGroupId").eq(rgId))); if (!result.list().isEmpty()) { String status = (String) result.single().get("status"); if ("PROVISIONED".equals(status)) { diff --git a/samples/bookshop/.cdsrc.json b/samples/bookshop/.cdsrc.json index 94e9eda..f419d38 100644 --- a/samples/bookshop/.cdsrc.json +++ b/samples/bookshop/.cdsrc.json @@ -2,13 +2,13 @@ "requires": { "db": "hana", "AICore": { - "model": "com.sap.cds/ai" + "model": "@cap-js/ai/srv/AICoreService" }, "[production]": { "auth": "xsuaa" } }, "cdsc": { - "moduleLookupDirectories": ["node_modules/", "target/cds/"] + "moduleLookupDirectories": ["node_modules/"] } } diff --git a/samples/bookshop/srv/ai-core-service.cds b/samples/bookshop/srv/ai-core-service.cds index 7124cc4..1fc24c8 100644 --- a/samples/bookshop/srv/ai-core-service.cds +++ b/samples/bookshop/srv/ai-core-service.cds @@ -1,4 +1,4 @@ -using { AICore } from 'com.sap.cds/ai'; +using { AICore } from '@cap-js/ai/srv/AICoreService'; service AICoreShowcaseService @(requires: 'any') { diff --git a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java index 5ebfdf3..e7ce877 100644 --- a/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java +++ b/samples/bookshop/srv/src/main/java/customer/bookshop/handlers/AICoreShowcaseHandler.java @@ -2,7 +2,12 @@ import com.sap.cds.CdsData; import com.sap.cds.Result; -import com.sap.cds.feature.aicore.api.AICoreService; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.AICore_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Configurations_; +import com.sap.cds.feature.aicore.generated.cds4j.aicore.Deployments_; +import com.sap.cds.feature.aicore.api.DeploymentIdContext; +import com.sap.cds.feature.aicore.api.InferenceClientContext; +import com.sap.cds.feature.aicore.api.ResourceGroupContext; import com.sap.cds.feature.recommendation.api.RptInferenceClient; import com.sap.cds.feature.recommendation.api.RptModelSpec; import com.sap.cds.ql.Insert; @@ -11,6 +16,7 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.cds.CqnService; +import com.sap.cds.services.cds.RemoteService; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; @@ -28,8 +34,8 @@ public class AICoreShowcaseHandler implements EventHandler { @Autowired private CdsRuntime runtime; - private AICoreService getAICoreService() { - return runtime.getServiceCatalog().getService(AICoreService.class, AICoreService.DEFAULT_NAME); + private RemoteService getAICoreService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } // This handler is NOT required - the plugin automatically delegates reads on projections @@ -37,19 +43,25 @@ private AICoreService getAICoreService() { // programmatically, e.g. for custom filtering or post-processing. @On(event = CqnService.EVENT_READ, entity = "AICoreShowcaseService.Configurations") public void onReadConfigurations(CdsReadEventContext context) { - context.setResult(getAICoreService().run(Select.from("AICore.configurations"))); + context.setResult(getAICoreService().run(Select.from(Configurations_.CDS_NAME))); } @On(event = "setupTenantResources") public void onSetupTenantResources(EventContext context) { - String rgId = getAICoreService().resourceGroup(); + RemoteService service = getAICoreService(); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rgId = rgCtx.getResult(); context.put("result", rgId); context.setCompleted(); } @On(event = "getMyResourceGroup") public void onGetMyResourceGroup(EventContext context) { - String rgId = getAICoreService().resourceGroup(); + RemoteService service = getAICoreService(); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rgId = rgCtx.getResult(); context.put("result", rgId); context.setCompleted(); } @@ -57,7 +69,12 @@ public void onGetMyResourceGroup(EventContext context) { @On(event = "provisionRpt1") public void onProvisionRpt1(EventContext context) { String resourceGroupId = (String) context.get("resourceGroupId"); - String deploymentId = getAICoreService().deploymentId(resourceGroupId, RptModelSpec.rpt1()); + RemoteService service = getAICoreService(); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(resourceGroupId); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + String deploymentId = depCtx.getResult(); context.put("result", deploymentId); context.setCompleted(); } @@ -69,7 +86,7 @@ public void onStopDeployment(EventContext context) { getAICoreService() .run( - Update.entity("AICore.deployments") + Update.entity(Deployments_.CDS_NAME) .where(d -> d.get("id").eq(deploymentId)) .data( Map.of( @@ -90,7 +107,7 @@ public void onCreateConfiguration(EventContext context) { Result result = getAICoreService() .run( - Insert.into("AICore.configurations") + Insert.into(Configurations_.CDS_NAME) .entry( Map.of( "name", name, @@ -126,16 +143,14 @@ public void onPredictCategory(EventContext context) { Map.of( "ID", "ctx-5", "name", "Blender", "price", "89.99", "category", "Appliances"))); - AICoreService service = getAICoreService(); - String rg = service.resourceGroup(); - String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); - RptInferenceClient client = - new RptInferenceClient(service.inferenceClient(rg, deploymentId), List.of("ID")); + RemoteService service = getAICoreService(); + RptInferenceClient client = createRptClient(service, List.of("ID")); List> results = new ArrayList<>(); for (Map product : products) { CdsData predictionRow = CdsData.create(new HashMap<>(product)); - List predictions = client.predict(predictionRow, contextRows, List.of("category")); + List predictions = + client.predict(predictionRow, contextRows, List.of("category")); for (CdsData prediction : predictions) { String id = (String) prediction.get("ID"); Object categoryObj = prediction.get("category"); @@ -158,4 +173,24 @@ private String extractPrediction(List predictionList) { } return predictionList.get(0).toString(); } + + /** Helper to resolve a ready-to-use RptInferenceClient from the AI Core RemoteService. */ + private static RptInferenceClient createRptClient( + RemoteService service, List keyNames) { + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + String rg = rgCtx.getResult(); + + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId(rg); + depCtx.setSpec(RptModelSpec.rpt1()); + service.emit(depCtx); + + InferenceClientContext infCtx = InferenceClientContext.create(); + infCtx.setResourceGroupId(rg); + infCtx.setDeploymentId(depCtx.getResult()); + service.emit(infCtx); + + return new RptInferenceClient(infCtx.getResult(), keyNames); + } } From f4b8d064dd019057d58a3673fcfa0309768d3d73 Mon Sep 17 00:00:00 2001 From: Marvin L Date: Thu, 18 Jun 2026 13:04:08 +0200 Subject: [PATCH 07/14] feature: static recommendation state (#82) * refactor(ai-core): migrate AICoreService to RemoteService - Remove AICoreService interface and AICoreServiceImpl - Introduce AICore constants class with SERVICE_NAME - Convert all handlers to use RemoteService with event contexts - Update DeploymentIdContext, InferenceClientContext, ResourceGroupContext - Update AICoreServiceConfiguration to register handlers on RemoteService - Adapt all unit tests to new RemoteService-based API - Update sample .cdsrc.json and ai-core-service.cds * chore(itests): adapt integration tests to RemoteService - Update all integration tests to use RemoteService + event contexts - Replace AICoreService.deploymentId/resourceGroup calls with context pattern - Update BaseIntegrationTest with new service resolution approach * refactor(recommendations): adapt to RemoteService API - Remove RptIndexColumns utility; inline resolveIndexColumn and addSyntheticKeyIfNeeded into RptInferenceClient - Change RecommendationClient.predict to accept keyNames as argument - Drop generic type parameter from RecommendationClientResolver; resolve now takes RemoteService directly - Remove keyNames from RptInferenceClient constructor (passed at predict time) - FioriRecommendationHandler now holds RemoteService reference and passes keyNames at prediction time - RecommendationConfiguration uses RemoteService + event context pattern (ResourceGroupContext, DeploymentIdContext, InferenceClientContext) - MockRecommendationClient simplified (no keyNames in constructor) - Update all tests to match new signatures * chore(samples): adapt bookshop sample to RemoteService - Replace AICoreService usage with RemoteService + event context pattern - Use AICore.SERVICE_NAME constant for service lookup - Demonstrate ResourceGroupContext, DeploymentIdContext, InferenceClientContext * update last Cqn references * update unit-tests * restore functionality * adapt tests * simplify itests * simplification * spotless * big blunder now fixed whoops * last occurences * add filter * readme update * test adaption * cleanup * update readme --------- Signed-off-by: Marvin L --- cds-feature-recommendations/README.md | 14 +++ .../RecommendationContextBuilder.java | 9 ++ .../FioriRecommendationHandlerTest.java | 109 +++++++++++++++++- .../resources/model/recommendations-test.cds | 14 +++ .../src/main/resources/application.yaml | 2 + 5 files changed, 147 insertions(+), 1 deletion(-) diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md index 9b59766..27be8f2 100644 --- a/cds-feature-recommendations/README.md +++ b/cds-feature-recommendations/README.md @@ -116,6 +116,20 @@ annotate Books with { } ``` +Fields annotated with `@UI.RecommendationState: 0` are excluded from predictions entirely. +A value of `1` (or omitting the annotation) means the field is eligible for recommendations. + +> **Note:** Since `@UI.RecommendationState` is a UI annotation, you must enable UI annotation loading +> in the Java runtime for it to take effect. By default, the CAP Java runtime strips `@UI.*` +> annotations from the in-memory model to reduce memory consumption (they are typically only +> needed for OData metadata generation, not for runtime logic). +> +> ```yaml +> cds: +> model: +> include-ui-annotations: true +> ``` + ## Configuration ```yaml diff --git a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java index d0c105c..6843533 100644 --- a/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java +++ b/cds-feature-recommendations/src/main/java/com/sap/cds/feature/recommendation/RecommendationContextBuilder.java @@ -32,6 +32,7 @@ class RecommendationContextBuilder { private static final String VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION = "@Common.ValueListWithFixedValues"; private static final String ODATA_VALUE_LIST_ANNOTATION = "@cds.odata.valuelist"; + private static final String RECOMMENDATION_STATE_ANNOTATION = "@UI.RecommendationState"; private static final String COMPUTED_ANNOTATION = "@Core.Computed"; private static final String READONLY_ANNOTATION = "@readonly"; private static final Set SUPPORTED_CONTEXT_TYPES = @@ -140,6 +141,10 @@ private List computePredictionElements() { .or(byAnnotation(VALUE_LIST_WITH_FIXED_VALUES_ANNOTATION))) .filter(e -> !e.getType().isAssociation()) .filter(e -> !Boolean.FALSE.equals(e.getAnnotationValue(ODATA_VALUE_LIST_ANNOTATION, null))) + .filter( + e -> + !isRecommendationDisabled( + e.getAnnotationValue(RECOMMENDATION_STATE_ANNOTATION, null))) .map(CdsElement::getName) .toList(); } @@ -157,4 +162,8 @@ private List computeContextColumns() { .map(CdsElement::getName) .toList(); } + + private static boolean isRecommendationDisabled(Object annotationValue) { + return annotationValue instanceof Number n && n.intValue() == 0; + } } diff --git a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index b25faa9..d3f46e9 100644 --- a/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java @@ -17,6 +17,8 @@ import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.services.Service; import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.impl.environment.SimplePropertiesProvider; import com.sap.cds.services.impl.utils.CdsServiceUtils; import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.request.RequestContext; @@ -45,8 +47,10 @@ class FioriRecommendationHandlerTest { static void bootRuntime() { db = mock(PersistenceService.class); when(db.getName()).thenReturn(PersistenceService.DEFAULT_NAME); + CdsProperties properties = new CdsProperties(); + properties.getModel().setIncludeUiAnnotations(true); runtime = - CdsRuntimeConfigurer.create() + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(properties)) .cdsModel("model/csn.json") .serviceConfigurations() .service(db) @@ -243,6 +247,109 @@ void cdsoDataValueListFalse_fieldIsExcludedFromPredictions() { }); } + @Test + @SuppressWarnings("unchecked") + void recommendationStateZero_fieldIsExcludedFromPredictions() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", null); + row.put("disabled_ID", null); + row.put("enabled_ID", null); + CdsReadEventContext ctx = readContext("test.BooksWithRecommendationState", List.of(row)); + when(db.run(any(CqnSelect.class))) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>( + Map.of( + "ID", + "x1", + "genre_ID", + 1, + "disabled_ID", + 10, + "enabled_ID", + 100)), + new HashMap<>( + Map.of( + "ID", + "x2", + "genre_ID", + 2, + "disabled_ID", + 20, + "enabled_ID", + 200))))) + .result(), + ResultBuilder.selectedRows(List.of()).result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + // genre_ID has no @UI.RecommendationState → predicted + // disabled_ID has @UI.RecommendationState: 0 → excluded + // enabled_ID has @UI.RecommendationState: 1 → predicted + assertThat(row).containsKey("SAP_Recommendations"); + Map recs = (Map) row.get("SAP_Recommendations"); + assertThat(recs).containsKey("genre_ID"); + assertThat(recs).doesNotContainKey("disabled_ID"); + assertThat(recs).containsKey("enabled_ID"); + }); + } + + @Test + void recommendationStateZero_fieldIsExcludedFromContextQuery() { + runIn( + () -> { + Map row = new HashMap<>(); + row.put("ID", "a009c640-434a-4542-ac68-51b400c880ec"); + row.put("IsActiveEntity", false); + row.put("genre_ID", null); + row.put("disabled_ID", null); + row.put("enabled_ID", null); + CdsReadEventContext ctx = readContext("test.BooksWithRecommendationState", List.of(row)); + ArgumentCaptor selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); + when(db.run(selectCaptor.capture())) + .thenReturn( + ResultBuilder.selectedRows( + new ArrayList<>( + List.of( + new HashMap<>( + Map.of( + "ID", + "x1", + "genre_ID", + 1, + "disabled_ID", + 10, + "enabled_ID", + 100)), + new HashMap<>( + Map.of( + "ID", + "x2", + "genre_ID", + 2, + "disabled_ID", + 20, + "enabled_ID", + 200))))) + .result(), + ResultBuilder.selectedRows(List.of()).result(), + ResultBuilder.selectedRows(List.of()).result()); + cut.afterRead(ctx, dataList(row)); + // The context query WHERE clause should NOT require disabled_ID to be non-null + // (it is still a valid context *column* for SELECT, but not a prediction target) + CqnSelect contextQuery = selectCaptor.getAllValues().get(0); + String whereSql = contextQuery.where().map(Object::toString).orElse(""); + assertThat(whereSql).contains("genre_ID"); + assertThat(whereSql).contains("enabled_ID"); + assertThat(whereSql).doesNotContain("disabled_ID"); + }); + } + @Test void blobAndVectorFields_areExcludedFromContextSelect() { runIn( diff --git a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds index 5c30916..3e5f45d 100644 --- a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds +++ b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds @@ -58,6 +58,19 @@ entity BooksWithDisabledValueList { suppressed_ID : Integer; } +@odata.draft.enabled +entity BooksWithRecommendationState { + key ID : UUID; + @Common.ValueListWithFixedValues + genre_ID : Integer; + @Common.ValueListWithFixedValues + @UI.RecommendationState: 0 + disabled_ID : Integer; + @Common.ValueListWithFixedValues + @UI.RecommendationState: 1 + enabled_ID : Integer; +} + service TestService { entity Books as projection on test.Books; entity Genres as projection on test.Genres; @@ -66,4 +79,5 @@ service TestService { entity IsbnBooks as projection on test.IsbnBooks; entity PlainEntity as projection on test.PlainEntity; entity BooksWithDisabledValueList as projection on test.BooksWithDisabledValueList; + entity BooksWithRecommendationState as projection on test.BooksWithRecommendationState; } diff --git a/integration-tests/spring/src/main/resources/application.yaml b/integration-tests/spring/src/main/resources/application.yaml index 73e07c4..b256334 100644 --- a/integration-tests/spring/src/main/resources/application.yaml +++ b/integration-tests/spring/src/main/resources/application.yaml @@ -7,6 +7,8 @@ spring: mode: always cds: + model: + include-ui-annotations: true ai: core: resourceGroup: ${CDS_AICORE_TEST_RESOURCE_GROUP:cap-java-ai-default} From c4f2a3dc58151f0e13539aee686b6a95e3020623 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Tue, 9 Jun 2026 09:06:43 +0200 Subject: [PATCH 08/14] Document how to add the SAP_Recommendations navigation property manually --- cds-feature-recommendations/README.md | 44 +++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md index 27be8f2..9030ef6 100644 --- a/cds-feature-recommendations/README.md +++ b/cds-feature-recommendations/README.md @@ -40,9 +40,12 @@ Or use the starter that bundles this with `cds-feature-ai-core`: - An [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding (see [`cds-feature-ai-core`](../cds-feature-ai-core/README.md)) - Entity must be **draft-enabled** (`@odata.draft.enabled`) - At least one field annotated with a **value list** -- The `@cap-js/ai` CDS plugin must be installed (provides the model enhancement that adds `SAP_Recommendations` as a navigation property) +- The `SAP_Recommendations` navigation property must be added to the entities that should receive recommendations by + - either installing the `@cap-js/ai` CDS plugin (automatically provides the model enhancement that adds `SAP_Recommendations` as a navigation property) + - or adding the `SAP_Recommendations`property manually. + Without the `SAP_Recommendations` navigation property, the predictions will be computed but not serialized in OData responses. -### CDS Plugin +#### CDS Plugin Add `@cap-js/ai` to your project's `package.json`: @@ -55,7 +58,7 @@ Add `@cap-js/ai` to your project's `package.json`: } ``` -Then run `npm install`. The plugin hooks into the CDS compiler and automatically adds the `SAP_Recommendations` navigation property to draft-enabled entities that have value-list fields. Without this plugin, predictions will be computed but not serialized in OData responses. +Then run `npm install`. The plugin hooks into the CDS compiler and automatically adds the `SAP_Recommendations` navigation property to draft-enabled entities that have value-list fields. Since the Java module `cds-feature-ai-core` already provides the `AICore` service CDS model, disable the duplicate model from `@cap-js/ai` in your `.cdsrc.json`: @@ -68,6 +71,41 @@ Since the Java module `cds-feature-ai-core` already provides the `AICore` servic } } ``` +#### Adding the SAP_Recommendations navigation property manually + +If you cannot use the CDS plugin, add the `SAP_Recommendations` navigation property directly in your CDS model. You need to: + +1. **Define a `RecommendationItem_*` type** for each CDS primitive type used by your value-list fields. Each type must contain the four fixed fields shown below — only `RecommendedFieldValue` varies by type. +2. **Extend each target entity** with a `SAP_Recommendations` composition that has one entry per value-list field, using the field name as the property name and the matching `RecommendationItem_*` type. + +The property names inside `SAP_Recommendations` must exactly match the field names on the entity (e.g. `genre_ID`, `author_ID`). + +```cds +// Define one type per CDS primitive used by your value-list fields +type RecommendationItem_Integer { + RecommendedFieldValue : Integer; + RecommendedFieldDescription : String; + RecommendedFieldScoreValue : Decimal; + RecommendedFieldIsSuggestion: Boolean; +} + +type RecommendationItem_UUID { + RecommendedFieldValue : UUID; + RecommendedFieldDescription : String; + RecommendedFieldScoreValue : Decimal; + RecommendedFieldIsSuggestion: Boolean; +} + +// Extend your entity — one entry per value-list field +extend my.Books with { + SAP_Recommendations: Composition of one { + genre_ID : many RecommendationItem_Integer; + author_ID: many RecommendationItem_UUID; + } +} +``` + +See also the [SAP Fiori Elements – Recommendations documentation](https://help.sap.com/docs/SAPUI5/b2f662dd9d7a4ec680056733050b4d34/1a6324d5ad7f4034a93f911b4e53e080.html). ## Enabling Recommendations From 5b22cd87f0b6e8f12c3b7de74d904ece3035b11e Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Thu, 18 Jun 2026 10:55:47 +0200 Subject: [PATCH 09/14] Add @odata.draft.enabled in README.md --- cds-feature-recommendations/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md index 9030ef6..778e870 100644 --- a/cds-feature-recommendations/README.md +++ b/cds-feature-recommendations/README.md @@ -112,6 +112,7 @@ See also the [SAP Fiori Elements – Recommendations documentation](https://help Recommendations are triggered for fields annotated with `@Common.ValueList`, `@Common.ValueListWithFixedValues`, or whose association target has `@cds.odata.valuelist`: ```cds +@odata.draft.enabled entity Books { key ID : Integer; title : String(111); From 15a6a825ee1469500d0ac147f8c65b907fd5b119 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Thu, 18 Jun 2026 11:03:37 +0200 Subject: [PATCH 10/14] Mark RPT-1 specific setting as such in README --- cds-feature-recommendations/README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md index 778e870..16a6189 100644 --- a/cds-feature-recommendations/README.md +++ b/cds-feature-recommendations/README.md @@ -7,7 +7,7 @@ AI-powered field recommendations for SAP Fiori UIs in CAP Java applications, lev The plugin generically hooks into any draft-enabled entity that has properties with a value help. When a user edits a draft record, the plugin: 1. Fetches historical records as training context -2. Sends context + current row to the RPT-1 model +2. Sends context + current row to the provided model (default: RPT-1 model) 3. Returns predictions as `SAP_Recommendations` in the OData response 4. Fiori Elements renders the recommendations as suggestions in form fields @@ -171,11 +171,13 @@ A value of `1` (or omitting the annotation) means the field is eligible for reco ## Configuration +The following configuration applies to the RPT-1 model implementation. + ```yaml cds: requires: recommendations: - contextRowLimit: 2000 # Max historical rows used as training context + contextRowLimit: 2000 # Max historical rows used as training context (RPT-1) ``` See [`cds-feature-ai-core`](../cds-feature-ai-core/README.md) for AI Core connection and multi-tenancy configuration. @@ -203,6 +205,8 @@ SAP Fiori Elements automatically renders these as suggestions in form fields whe ## Supported Field Types +The following field types are supported by the RPT-1 model implementation: + | Category | Types | | -------- | --------------------------------------------------------- | | String | `String`, `LargeString`, `UUID` | From 4d6dd1fb254e2b22c9ca75feeb6090b7337776dd Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Thu, 18 Jun 2026 11:09:49 +0200 Subject: [PATCH 11/14] Add Integer64 and UUID to the supported-types table --- cds-feature-recommendations/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md index 16a6189..dc1c237 100644 --- a/cds-feature-recommendations/README.md +++ b/cds-feature-recommendations/README.md @@ -207,12 +207,12 @@ SAP Fiori Elements automatically renders these as suggestions in form fields whe The following field types are supported by the RPT-1 model implementation: -| Category | Types | -| -------- | --------------------------------------------------------- | -| String | `String`, `LargeString`, `UUID` | -| Numeric | `Integer`, `Int16`, `Int32`, `Int64`, `Decimal`, `Double` | -| Temporal | `Date`, `Time`, `DateTime`, `Timestamp` | -| Other | `Boolean` | +| Category | Types | +| -------- | ---------------------------------------------------------------------- | +| String | `String`, `LargeString`, `UUID` (treated as string) | +| Numeric | `Integer`, `Int16`, `Int32`, `Int64`, `Integer64`, `Decimal`, `Double` | +| Temporal | `Date`, `Time`, `DateTime`, `Timestamp` | +| Other | `Boolean` | Binary, vector, and draft system fields are excluded automatically. From 6a2d536dc6ade8bebc64ad4cd091ff25fc014a41 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Thu, 18 Jun 2026 11:26:51 +0200 Subject: [PATCH 12/14] Drop Prerequisites section and include the info from there throughout the README --- cds-feature-recommendations/README.md | 77 +++++++++++++-------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/cds-feature-recommendations/README.md b/cds-feature-recommendations/README.md index dc1c237..088b165 100644 --- a/cds-feature-recommendations/README.md +++ b/cds-feature-recommendations/README.md @@ -35,15 +35,7 @@ Or use the starter that bundles this with `cds-feature-ai-core`: ``` -### Prerequisites - -- An [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding (see [`cds-feature-ai-core`](../cds-feature-ai-core/README.md)) -- Entity must be **draft-enabled** (`@odata.draft.enabled`) -- At least one field annotated with a **value list** -- The `SAP_Recommendations` navigation property must be added to the entities that should receive recommendations by - - either installing the `@cap-js/ai` CDS plugin (automatically provides the model enhancement that adds `SAP_Recommendations` as a navigation property) - - or adding the `SAP_Recommendations`property manually. - Without the `SAP_Recommendations` navigation property, the predictions will be computed but not serialized in OData responses. +Requires an [SAP AI Core](https://help.sap.com/docs/sap-ai-core) service binding — see [`cds-feature-ai-core`](../cds-feature-ai-core/README.md) for setup. #### CDS Plugin @@ -71,6 +63,43 @@ Since the Java module `cds-feature-ai-core` already provides the `AICore` servic } } ``` + +## Enabling Recommendations + +For recommendations to fire on an entity: + +- The entity must be **draft-enabled** (`@odata.draft.enabled`) +- At least one field must be annotated with a **value list** +- The `SAP_Recommendations` navigation property must be present — either via the CDS plugin (see above) or added manually (see below). Without it, predictions are computed but not serialized in OData responses. + +Recommendations are triggered for fields annotated with `@Common.ValueList`, `@Common.ValueListWithFixedValues`, or whose association target has `@cds.odata.valuelist`: + +```cds +@odata.draft.enabled +entity Books { + key ID : Integer; + title : String(111); + descr : String(1111); + genre : Association to one Genres; + status : Association to one Status; +} + +// Option 1: Annotate the association target +annotate Genres with @cds.odata.valuelist; + +// Option 2: Annotate the field directly +annotate Books with { + status @Common.ValueList: { + CollectionPath: 'Status', + Parameters: [{ + $Type: 'Common.ValueListParameterInOut', + ValueListProperty: 'code', + LocalDataProperty: status_code + }] + } +} +``` + #### Adding the SAP_Recommendations navigation property manually If you cannot use the CDS plugin, add the `SAP_Recommendations` navigation property directly in your CDS model. You need to: @@ -107,36 +136,6 @@ extend my.Books with { See also the [SAP Fiori Elements – Recommendations documentation](https://help.sap.com/docs/SAPUI5/b2f662dd9d7a4ec680056733050b4d34/1a6324d5ad7f4034a93f911b4e53e080.html). -## Enabling Recommendations - -Recommendations are triggered for fields annotated with `@Common.ValueList`, `@Common.ValueListWithFixedValues`, or whose association target has `@cds.odata.valuelist`: - -```cds -@odata.draft.enabled -entity Books { - key ID : Integer; - title : String(111); - descr : String(1111); - genre : Association to one Genres; - status : Association to one Status; -} - -// Option 1: Annotate the association target -annotate Genres with @cds.odata.valuelist; - -// Option 2: Annotate the field directly -annotate Books with { - status @Common.ValueList: { - CollectionPath: 'Status', - Parameters: [{ - $Type: 'Common.ValueListParameterInOut', - ValueListProperty: 'code', - LocalDataProperty: status_code - }] - } -} -``` - ### Adding Text Descriptions Use `@Common.Text` to show human-readable descriptions alongside recommended values: From a09bf6dc133a6924a95eba2230567e60f5068dd3 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Thu, 18 Jun 2026 11:39:14 +0200 Subject: [PATCH 13/14] Changes to recommendations-test.cds --- .../src/test/resources/model/recommendations-test.cds | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds index 3e5f45d..0491697 100644 --- a/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds +++ b/cds-feature-recommendations/src/test/resources/model/recommendations-test.cds @@ -6,13 +6,13 @@ namespace test; entity Books { key ID : UUID; title : String; - @Common.ValueListWithFixedValues genre_ID : Integer; @Common.Text : genre.name - genre : Association to Genres; @Common.ValueListWithFixedValues + genre : Association to Genres; currency_code : String(3); @(Common.Text: { $value: ![currency.name] }) + @Common.ValueListWithFixedValues currency : Association to Currencies; image : LargeBinary; embedding : Vector(8); From 11a9e2bc819bce632fa14f2b1fd2a40603b359e4 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Thu, 18 Jun 2026 11:40:38 +0200 Subject: [PATCH 14/14] Remove 'detect automatically via ServiceBindingUtils' --- cds-feature-ai-core/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cds-feature-ai-core/README.md b/cds-feature-ai-core/README.md index 1b1125f..61ffcf6 100644 --- a/cds-feature-ai-core/README.md +++ b/cds-feature-ai-core/README.md @@ -29,7 +29,7 @@ The plugin auto-registers via Java's `ServiceLoader` mechanism - no code changes In production, bind an SAP AI Core service instance to your application. Supported methods: -- **Service binding** (Cloud Foundry / Kubernetes) - detected automatically via `ServiceBindingUtils` +- **Service binding** (Cloud Foundry / Kubernetes) - **Environment variable** `AICORE_SERVICE_KEY` - for local hybrid testing (via `cds bind --exec`) Without a binding the plugin registers a mock implementation.