diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 251b68a..60bb1c6 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -116,6 +116,7 @@ jobs: cf-space: ${{ secrets.CF_SPACE_AWS }} - name: SonarQube Scan + continue-on-error: true uses: ./.github/actions/scan-with-sonar with: java-version: '17' 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. 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 deleted file mode 100644 index 40374bb..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/api/AICoreService.java +++ /dev/null @@ -1,77 +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.CqnService; -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 CqnService { - - /** Default service name under which an instance is registered in the service catalog. */ - String DEFAULT_NAME = "AICore"; - - /** 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 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 new file mode 100644 index 0000000..04857c0 --- /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 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 { + + /** 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..8ed8710 --- /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 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 { + + /** 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/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 new file mode 100644 index 0000000..9d08328 --- /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 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}. + */ +@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 bfd4c8c..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,14 +7,18 @@ 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.AICoreApplicationServiceHandler; +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.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; @@ -24,103 +28,126 @@ 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 - * 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. + *

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 { private static final Logger logger = LoggerFactory.getLogger(AICoreServiceConfiguration.class); - 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(); - } + private AICoreConfig config; + private AICoreClients clients; + private DeploymentResolver resolver; /** - * 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 public void services(CdsRuntimeConfigurer configurer) { CdsRuntime runtime = configurer.getCdsRuntime(); - boolean hasBinding = hasAICoreBinding(runtime); + if (!hasAICoreModel(runtime)) { + logger.debug("AICore CDS model not found in runtime model - skipping handler setup."); + return; + } + 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); - logger.info("Registered AICoreService backed by AI Core binding."); + 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("AI Core binding detected - production handlers will be registered."); } else { - MockAICoreServiceImpl mockService = - new MockAICoreServiceImpl(AICoreService.DEFAULT_NAME, runtime, multiTenancyEnabled); - configurer.service(mockService); - logger.info("Registered MockAICoreService (no AI Core binding found)."); + logger.info("No AI Core binding found - mock handlers will be registered."); } } @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 + } - 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)); - configurer.eventHandler(new AICoreApplicationServiceHandler(service)); - logger.debug("Registered Prod AI-Core Implementation"); + if (clients != null) { + 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 { + MockAICoreApiHandler mockApiHandler = new MockAICoreApiHandler(config); 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."); + 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"); } } + + 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 52eeb4f..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AICoreServiceImpl.java +++ /dev/null @@ -1,426 +0,0 @@ -/* - * © 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.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.ModelDeploymentSpec; -import com.sap.cds.services.ErrorStatuses; -import com.sap.cds.services.ServiceException; -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 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. - * - *

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. - * - *

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. - */ -public class AICoreServiceImpl extends AbstractAICoreService { - - private static final Logger logger = LoggerFactory.getLogger(AICoreServiceImpl.class); - - 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; - } - - private static Cache newCache() { - return Caffeine.newBuilder() - .maximumSize(DEFAULT_CACHE_MAX_SIZE) - .expireAfterAccess(DEFAULT_CACHE_EXPIRY) - .build(); - } - - @Override - public String resourceGroupForTenant(String tenantId) { - if (!multiTenancyEnabled || tenantId == null) { - logger.debug("Using default resource group {}", defaultResourceGroup); - return defaultResourceGroup; - } - return getOrCreateResourceGroupForTenant(tenantId); - } - - @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); - } - } - - @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); - } -} 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 0e7ec79..0000000 --- a/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/AbstractAICoreService.java +++ /dev/null @@ -1,94 +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.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; - -/** - * 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 AbstractCqnService implements AICoreService { - - protected AbstractAICoreService(String name, CdsRuntime runtime) { - super(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/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..2ab2ee7 --- /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.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.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; +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 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(AICore_.CDS_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/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/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 76% 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 e4fd983..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,16 +1,19 @@ /* * © 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.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.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.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 +28,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) + @After + @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, @@ -47,7 +53,8 @@ public void afterSubscribe(SubscribeEventContext context) { } } - @Before(event = DeploymentService.EVENT_UNSUBSCRIBE) + @Before + @HandlerOrder(HandlerOrder.EARLY) public void beforeUnsubscribe(UnsubscribeEventContext context) { String tenantId = context.getTenant(); logger.debug("Deleting AI Core resources for tenant {}", tenantId); @@ -55,7 +62,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 +75,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 +99,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/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..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 @@ -3,47 +3,43 @@ */ 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.AICore_; 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; 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); - 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..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 @@ -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.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_; 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; @@ -31,19 +32,17 @@ 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); - 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 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..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 @@ -3,16 +3,18 @@ */ 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; 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.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.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; 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; @@ -35,19 +36,17 @@ 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); - 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 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 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..4a5ccd3 --- /dev/null +++ b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreApiHandler.java @@ -0,0 +1,91 @@ +/* + * © 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.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; +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 AI Core service API events when no AI Core binding is available. Uses + * in-memory maps instead of real API calls. + */ +@ServiceName(AICore_.CDS_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; + } + context.setResult(resolveResourceGroup(tenantId)); + } + + @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"); + } + + /** Resolves (or creates) the resource group name for the given tenant using the configured prefix. */ + public String resolveResourceGroup(String tenantId) { + return tenantResourceGroupCache.computeIfAbsent(tenantId, id -> config.resourceGroupPrefix() + id); + } + + /** 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/MockAICoreSetupHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockAICoreSetupHandler.java similarity index 63% 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 fe152fd..5c8e891 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,11 +1,12 @@ /* * © 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.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 +19,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) + @After + @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.resolveResourceGroup(tenantId); + 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(); - 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/MockEntityHandler.java b/cds-feature-ai-core/src/main/java/com/sap/cds/feature/aicore/core/handler/MockEntityHandler.java index cef5666..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,11 @@ 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; import com.sap.cds.ql.cqn.CqnInsert; @@ -15,7 +19,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; @@ -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,23 +37,34 @@ public class MockEntityHandler implements EventHandler { // --- Resource Groups --- - @On(event = CqnService.EVENT_READ, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) 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 = ResourceGroups_.CDS_NAME) public void createResourceGroups(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); @@ -65,7 +79,7 @@ public void createResourceGroups(CdsCreateEventContext context) { context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void updateResourceGroups(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -81,7 +95,7 @@ public void updateResourceGroups(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(existing))); } - @On(event = CqnService.EVENT_DELETE, entity = AICoreService.RESOURCE_GROUPS) + @On(entity = ResourceGroups_.CDS_NAME) public void deleteResourceGroups(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -94,7 +108,7 @@ public void deleteResourceGroups(CdsDeleteEventContext context) { // --- Deployments --- - @On(event = CqnService.EVENT_READ, entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void readDeployments(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -110,7 +124,7 @@ public void readDeployments(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void createDeployments(CdsCreateEventContext context) { CqnInsert insert = context.getCqn(); List> results = new ArrayList<>(); @@ -125,7 +139,7 @@ public void createDeployments(CdsCreateEventContext context) { context.setResult(results); } - @On(event = CqnService.EVENT_UPDATE, entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void updateDeployments(CdsUpdateEventContext context) { CqnUpdate update = context.getCqn(); CdsModel model = context.getModel(); @@ -141,7 +155,7 @@ public void updateDeployments(CdsUpdateEventContext context) { context.setResult(List.of(CdsData.create(existing))); } - @On(event = CqnService.EVENT_DELETE, entity = AICoreService.DEPLOYMENTS) + @On(entity = Deployments_.CDS_NAME) public void deleteDeployments(CdsDeleteEventContext context) { CqnDelete delete = context.getCqn(); CdsModel model = context.getModel(); @@ -154,7 +168,7 @@ public void deleteDeployments(CdsDeleteEventContext context) { // --- Configurations --- - @On(event = CqnService.EVENT_READ, entity = AICoreService.CONFIGURATIONS) + @On(entity = Configurations_.CDS_NAME) public void readConfigurations(CdsReadEventContext context) { CqnSelect select = context.getCqn(); CdsModel model = context.getModel(); @@ -170,7 +184,7 @@ public void readConfigurations(CdsReadEventContext context) { } } - @On(event = CqnService.EVENT_CREATE, 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 b0e7094..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 @@ -3,16 +3,18 @@ */ 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; 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.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.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; 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; @@ -34,19 +36,17 @@ 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); - 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 d2feaaa..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 @@ -3,175 +3,76 @@ */ 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.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.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 mock + * handlers 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_registersService() { + CdsRuntime runtime = + CdsRuntimeConfigurer.create(new SimplePropertiesProvider(new CdsProperties())) + .environmentConfigurations() + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + + assertThat(service).isNotNull(); } - /** - * 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_registersService() { + 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)) + .environmentConfigurations() + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); - AICoreServiceConfiguration config = new AICoreServiceConfiguration(); - config.services(configurer); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); - ArgumentCaptor captor = - ArgumentCaptor.forClass(MockAICoreServiceImpl.class); - verify(configurer).service(captor.capture()); - assert captor.getValue().isMultiTenancyEnabled(); + assertThat(service).isNotNull(); } - /** - * 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); + RemoteService service = + runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_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..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 @@ -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; @@ -22,19 +23,28 @@ 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.services.environment.CdsEnvironment; +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.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; 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 { @@ -46,15 +56,42 @@ class AICoreServiceImplDeploymentIdTest { private DeploymentApi deploymentApi; private ConfigurationApi configurationApi; private ResourceGroupApi resourceGroupApi; - private AICoreServiceImpl service; + private RemoteService service; + private DeploymentResolver resolver; private final ModelDeploymentSpec spec = 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); + return DeploymentResolver.deploymentCacheKey(RG, spec); + } + + /** + * Creates a {@link RemoteService} properly registered with a CDS runtime and the {@link + * AICoreApiHandler} so that {@code emit()} dispatches to the handler. + */ + 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); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.complete(); + + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } @BeforeEach @@ -62,41 +99,18 @@ 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-"); - - service = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - runtime, - false, - deploymentApi, - configurationApi, - resourceGroupApi, - sdkService); + 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); 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); @@ -105,9 +119,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); @@ -121,34 +135,29 @@ void cacheStale_404OnGet_invalidatesAndReturnsExistingFromQuery() { 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(service.getResourceGroupDeploymentCache()) - .containsEntry(cacheKey(), otherDeployment); + assertThat(getDeploymentCache(resolver)).containsEntry(cacheKey(), otherDeployment); verify(deploymentApi, never()).create(any(), any()); } @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"); + 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); - org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.deploymentId(RG, spec)) - .isSameAs(serverError); + assertThatThrownBy(() -> emitDeploymentId(service, 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); @@ -158,10 +167,10 @@ void noCache_existingMatchingDeployment_isReusedAndCached() { 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(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()); } @@ -181,12 +190,11 @@ 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); - // 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,47 +202,25 @@ 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-"); - - AICoreServiceImpl mtService = - new AICoreServiceImpl( - AICoreService.DEFAULT_NAME, - rtMt, - true, // multi-tenancy enabled - deploymentApi, - configurationApi, - resourceGroupApi, - mock(AiCoreService.class)); - - String result = mtService.resourceGroupForTenant(null); - assertThat(result).isEqualTo("my-default"); + RemoteService mtService = createService(true); + + String result = emitResourceGroup(mtService, null); + 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"); + String result = emitResourceGroup(service, "any-tenant"); assertThat(result).isEqualTo("default"); } @Test - void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { - // Empty deployment list → falls through to create path. + 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())) .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"); @@ -251,11 +237,47 @@ void noCacheNoExistingDeployment_createsNewDeploymentWhenConfigExists() { 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(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 + // ────────────────────────────────────────────────────────────────────────── + + 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 { + 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(); + } } 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..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 @@ -12,15 +12,18 @@ 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.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; -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 +37,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 +82,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 +96,7 @@ void cacheMissAndApiReturnsEmpty_isNoOp() { cut.beforeUnsubscribe(unsubscribeContext); verify(resourceGroupApi, never()).delete(any()); - verify(service).clearTenantCache(TENANT); + assertThat(resolver.getTenantResourceGroupCacheView()).doesNotContainKey(TENANT); } @Test @@ -99,24 +108,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 +133,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 +151,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 +162,6 @@ void getAllThrows_wrappedInServiceException() { .isInstanceOf(ServiceException.class) .hasCauseReference(boom); verify(resourceGroupApi, never()).delete(any()); - verify(service).clearTenantCache(TENANT); } @SuppressWarnings({"unchecked", "rawtypes"}) @@ -176,4 +174,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 a8dd214..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 @@ -4,103 +4,116 @@ 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.services.environment.CdsEnvironment; +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; +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); - 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 RemoteService 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)) + .environmentConfigurations() + .cdsModel("edmx/csn.json") + .serviceConfigurations() + .eventHandlerConfigurations() + .complete(); + + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } @Test - void defaultConstructor_setsMultiTenancyFalse() { - CdsRuntime runtime = mock(CdsRuntime.class); - CdsEnvironment env = mock(CdsEnvironment.class); - when(runtime.getEnvironment()).thenReturn(env); - 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() { + RemoteService service = createMockService(false); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("default"); } @Test - void mtConstructor_setsMultiTenancyTrue() { - MockAICoreServiceImpl service = createService(true); - assertThat(service.isMultiTenancyEnabled()).isTrue(); + void noMultiTenancy_resourceGroupForTenant_returnsDefault() { + RemoteService service = createMockService(false); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant"); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).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); - String rg = service.resourceGroupForTenant("my-tenant"); - assertThat(rg).isEqualTo("prefix-my-tenant"); - } - - @Test - void resourceGroupForTenant_mtEnabled_cachesResult() { - MockAICoreServiceImpl service = createService(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"); + void multiTenancy_resourceGroupForTenant_returnsPrefixed() { + RemoteService service = createMockService(true); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("my-tenant"); + service.emit(rgCtx); + assertThat(rgCtx.getResult()).isEqualTo("cds-my-tenant"); } @Test - void getRetry_returnsNonNull() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.getRetry()).isNotNull(); + void multiTenancy_resourceGroupForTenant_cachesResult() { + 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 getDefaultResourceGroup_readsFromConfig() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.getDefaultResourceGroup()).isEqualTo("test-rg"); + void deploymentId_returnsMockId() { + RemoteService service = createMockService(false); + var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); + DeploymentIdContext depCtx = DeploymentIdContext.create(); + depCtx.setResourceGroupId("default"); + depCtx.setSpec(spec); + service.emit(depCtx); + String id = depCtx.getResult(); + assertThat(id).startsWith("mock-deployment-"); } @Test - void getResourceGroupPrefix_readsFromConfig() { - MockAICoreServiceImpl service = createService(false); - assertThat(service.getResourceGroupPrefix()).isEqualTo("prefix-"); + void deploymentId_cachesSameResult() { + RemoteService service = createMockService(false); + var spec = new ModelDeploymentSpec("scenario", "exec", "cfg1", List.of(), d -> true); + + 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 03e0579..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 @@ -5,65 +5,160 @@ 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.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.Result; +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.Configurations_; +import com.sap.cds.ql.Insert; +import com.sap.cds.ql.Select; +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; +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 RemoteService 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 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); + AICoreClients clients = + new AICoreClients( + deploymentApi, configurationApi, resourceGroupApi, mock(AiCoreService.class)); + DeploymentResolver resolver = new DeploymentResolver(config, deploymentApi, resourceGroupApi); + + 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 + 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(Configurations_.CDS_NAME) + .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(Configurations_.CDS_NAME) + .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(Configurations_.CDS_NAME) + .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..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 @@ -7,187 +7,193 @@ 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.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.Result; +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.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.cds.RemoteService; +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 static CdsRuntime runtime; + private static RemoteService service; + private static DeploymentApi deploymentApi; + private static ResourceGroupApi resourceGroupApi; + private static ConfigurationApi configurationApi; - private DeploymentHandler cut; + @BeforeAll + static void bootRuntime() { + deploymentApi = mock(DeploymentApi.class); + resourceGroupApi = mock(ResourceGroupApi.class); + configurationApi = mock(ConfigurationApi.class); - @BeforeEach - void setup() { - when(service.getDeploymentApi()).thenReturn(deploymentApi); - cut = new DeploymentHandler(service); - } + var props = HandlerTestUtils.aicoreProperties(); - @Test - void onUpdate_emptyEntries_throwsBadRequest() { - List entries = List.of(); + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); + configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); + runtime = configurer.getCdsRuntime(); - assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) - .isInstanceOfSatisfying( - ServiceException.class, - e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) - .hasMessageContaining("No update payload provided"); + 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); - verifyNoInteractions(deploymentApi); - } + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.complete(); - @Test - void onUpdate_payloadWithoutTargetStatusOrConfigurationId_throwsBadRequest() { - List entries = List.of(Deployments.of(Map.of("ttl", "1d"))); - - assertThatThrownBy(() -> cut.onUpdate(updateContext, entries)) - .isInstanceOfSatisfying( - ServiceException.class, - e -> assertThat(e.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST)) - .hasMessageContaining("targetStatus") - .hasMessageContaining("configurationId"); - - verifyNoInteractions(deploymentApi); - } - - @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); - } - - ArgumentCaptor captor = - ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); - verify(deploymentApi).modify(eq("rg-1"), eq("dep-123"), captor.capture()); - assertThat(captor.getValue().getTargetStatus().getValue()).isEqualTo("STOPPED"); + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } - @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); - } - - ArgumentCaptor captor = - ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); - verify(deploymentApi).modify(eq("rg-2"), eq("dep-789"), captor.capture()); - assertThat(captor.getValue().getConfigurationId()).isEqualTo("config-456"); + @BeforeEach + void clearMockInvocations() { + clearInvocations(deploymentApi, resourceGroupApi, configurationApi); } @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))) + when(deploymentApi.create(eq("default"), any(AiDeploymentCreationRequest.class))) .thenReturn(response); - cut.onCreate(createContext, entries); + Result result = + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into(Deployments_.CDS_NAME) + .entry( + Map.of( + "configurationId", "cfg-1", + "resourceGroup_resourceGroupId", "default")))); - verify(createContext).setResult(any(List.class)); ArgumentCaptor captor = ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); - verify(deploymentApi).create(eq("rg-test"), captor.capture()); + 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 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))) + when(deploymentApi.create(eq("default"), any(AiDeploymentCreationRequest.class))) .thenReturn(response); - cut.onCreate(createContext, entries); + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Insert.into(Deployments_.CDS_NAME) + .entry( + Map.of( + "configurationId", "cfg-2", + "ttl", "PT24H", + "resourceGroup_resourceGroupId", "default")))); ArgumentCaptor captor = ArgumentCaptor.forClass(AiDeploymentCreationRequest.class); - verify(deploymentApi).create(eq("rg-default"), captor.capture()); + verify(deploymentApi).create(eq("default"), captor.capture()); assertThat(captor.getValue().getTtl()).isEqualTo("PT24H"); } + + @Test + void onUpdate_withTargetStatus_callsModifyWithTargetStatus() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity(Deployments_.CDS_NAME) + .where(d -> d.get("id").eq("dep-123")) + .data("targetStatus", "STOPPED"))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); + verify(deploymentApi).modify(eq("default"), eq("dep-123"), captor.capture()); + assertThat(captor.getValue().getTargetStatus().getValue()).isEqualTo("STOPPED"); + } + + @Test + void onUpdate_withConfigurationId_callsModifyWithConfigurationId() { + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity(Deployments_.CDS_NAME) + .where(d -> d.get("id").eq("dep-789")) + .data("configurationId", "config-456"))); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(AiDeploymentModificationRequest.class); + verify(deploymentApi).modify(eq("default"), eq("dep-789"), captor.capture()); + assertThat(captor.getValue().getConfigurationId()).isEqualTo("config-456"); + } + + @Test + void onUpdate_withoutTargetStatusOrConfigurationId_throwsBadRequest() { + assertThatThrownBy( + () -> + runtime + .requestContext() + .run( + (Function) + ctx -> + service.run( + Update.entity(Deployments_.CDS_NAME) + .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/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 14a828b..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 @@ -4,454 +4,265 @@ 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.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.Result; +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.ql.Insert; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +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; +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 static CdsRuntime runtime; + private static RemoteService service; + private static ResourceGroupApi resourceGroupApi; + + @BeforeAll + static void bootRuntime() { + resourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); + + var props = HandlerTestUtils.aicoreProperties(); + + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); + configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); + runtime = configurer.getCdsRuntime(); - private ResourceGroupHandler handler; + 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); + + 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 - 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(ResourceGroups_.CDS_NAME))); + + 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(ResourceGroups_.CDS_NAME) + .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(ResourceGroups_.CDS_NAME) + .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(ResourceGroups_.CDS_NAME) + .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(ResourceGroups_.CDS_NAME) + .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 { + class MultiTenancyTests { - @Mock private CdsUpdateEventContext updateContext; - @Mock private CqnUpdate cqnUpdate; - @Mock private CdsModel model; - @Mock private CqnAnalyzer analyzer; - @Mock private AnalysisResult analysisResult; + private static CdsRuntime mtRuntime; + private static RemoteService mtService; + private static ResourceGroupApi mtResourceGroupApi; - @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")); - } + @BeforeAll + static void bootMtRuntime() { + mtResourceGroupApi = mock(ResourceGroupApi.class); + DeploymentApi deploymentApi = mock(DeploymentApi.class); + ConfigurationApi configurationApi = mock(ConfigurationApi.class); - @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(); - } - } + var props = HandlerTestUtils.aicoreProperties(); - @Nested - @ExtendWith(MockitoExtension.class) - class BuildTenantLabelSelectorTests { + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); + configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); + mtRuntime = configurer.getCdsRuntime(); - @Mock private CdsReadEventContext readContext; - @Mock private CqnSelect cqnSelect; - @Mock private CdsModel model; - @Mock private CqnAnalyzer analyzer; - @Mock private AnalysisResult analysisResult; + 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); - @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"); - } + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new ResourceGroupHandler(config, clients, resolver)); + configurer.complete(); - @Test - 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") - 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 + "=current-tenant"); + mtService = mtRuntime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); } - @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(); - } + @BeforeEach + void clearMtMockInvocations() { + clearInvocations(mtResourceGroupApi); } @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"); - } - } + @SuppressWarnings("unchecked") + void readAll_multiTenancy_nonProviderUser_restrictsByCurrentTenant() { + 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(ResourceGroups_.CDS_NAME))); - @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(); - } + ArgumentCaptor> selectorCaptor = ArgumentCaptor.forClass(List.class); + verify(mtResourceGroupApi) + .getAll(any(), any(), any(), any(), any(), any(), selectorCaptor.capture()); + assertThat(selectorCaptor.getValue()) + .containsExactly(AICoreConfig.TENANT_LABEL_KEY + "=current-tenant"); } } - - 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..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 @@ -5,150 +5,260 @@ 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.feature.aicore.core.AICoreServiceImpl; +import com.sap.cds.Result; +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.ql.Select; import com.sap.cds.services.ServiceException; +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; +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 RemoteService 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 props = HandlerTestUtils.aicoreProperties(); - private TestableHandler handler; + var configurer = CdsRuntimeConfigurer.create(new SimplePropertiesProvider(props)); + configurer.cdsModel("edmx/csn.json"); + configurer.serviceConfigurations(); + runtime = configurer.getCdsRuntime(); - @BeforeEach - void setUp() { - handler = new TestableHandler(service); - } + 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); - // ── ensureResourceGroupAccessible ────────────────────────────────────────── + configurer.eventHandler(new AICoreApiHandler(config, clients, resolver)); + configurer.eventHandler(new DeploymentHandler(config, clients, resolver)); + configurer.complete(); - @Test - void providerUser_allowsAccessToAnyResourceGroup() { - when(service.isProviderUser()).thenReturn(true); + service = runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_NAME); + } - 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(Deployments_.CDS_NAME) + .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(Deployments_.CDS_NAME) + .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(Deployments_.CDS_NAME) + .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(Deployments_.CDS_NAME) + .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(Deployments_.CDS_NAME) + .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(Deployments_.CDS_NAME) + .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/README.md b/cds-feature-recommendations/README.md index 9b59766..088b165 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 @@ -35,14 +35,9 @@ Or use the starter that bundles this with `cds-feature-ai-core`: ``` -### Prerequisites +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. -- 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) - -### CDS Plugin +#### CDS Plugin Add `@cap-js/ai` to your project's `package.json`: @@ -55,7 +50,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`: @@ -71,9 +66,16 @@ 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); @@ -98,6 +100,42 @@ annotate Books with { } ``` +#### 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). + ### Adding Text Descriptions Use `@Common.Text` to show human-readable descriptions alongside recommended values: @@ -116,13 +154,29 @@ 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 +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. @@ -150,12 +204,14 @@ SAP Fiori Elements automatically renders these as suggestions in form fields whe ## Supported Field Types -| Category | Types | -| -------- | --------------------------------------------------------- | -| String | `String`, `LargeString`, `UUID` | -| Numeric | `Integer`, `Int16`, `Int32`, `Int64`, `Decimal`, `Double` | -| Temporal | `Date`, `Time`, `DateTime`, `Timestamp` | -| Other | `Boolean` | +The following field types are supported by the RPT-1 model implementation: + +| 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. 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/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..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; @@ -91,9 +94,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); @@ -102,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; } @@ -123,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."); @@ -138,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 879f0fe..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,17 +3,22 @@ */ 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.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; 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; @@ -26,29 +31,73 @@ 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."); return; } - RecommendationClientResolver resolver = - aiCoreService instanceof MockAICoreServiceImpl - ? service -> new MockRecommendationClient() - : RecommendationConfiguration::resolveRptClient; + PersistenceService db = + serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME); - FioriRecommendationHandler handler = new FioriRecommendationHandler(aiCoreService, resolver); + if (db == null) { + logger.info( + "No PersistenceService found, skipping Fiori recommendation handler registration."); + return; + } + + 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. The RemoteService is captured in the closure. + RecommendationClientResolver> clientResolver = + hasBind + ? keyNames -> resolveRptClient(aiCoreService, keyNames) + : keyNames -> new MockRecommendationClient(keyNames); + + FioriRecommendationHandler handler = new FioriRecommendationHandler(clientResolver, db); configurer.eventHandler(handler); configurer.eventHandler(new RecommendationModelChangedHandler(handler)); } - 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()); + private static boolean hasAICoreBinding(CdsRuntime runtime) { + return runtime + .getEnvironment() + .getServiceBindings() + .filter(b -> ServiceBindingUtils.matches(b, "aicore")) + .findFirst() + .isPresent(); + } + + private static RecommendationClient resolveRptClient( + 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 a158123..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 @@ -14,23 +14,27 @@ 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 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 = EnumSet.of( CdsBaseType.STRING, @@ -64,8 +68,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 +76,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 +86,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 +116,23 @@ 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 = new HashMap<>(); + allowed.forEach( + col -> { + if (row.containsKey(col)) predictRow.put(col, row.get(col)); + }); 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 +140,11 @@ 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))) + .filter( + e -> + !isRecommendationDisabled( + e.getAnnotationValue(RECOMMENDATION_STATE_ANNOTATION, null))) .map(CdsElement::getName) .toList(); } @@ -178,11 +152,18 @@ 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(); } + + private static boolean isRecommendationDisabled(Object annotationValue) { + return annotationValue instanceof Number n && n.intValue() == 0; + } } 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 2865dd3..1af7384 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,13 +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; @@ -30,43 +33,57 @@ *

Example usage: * *

{@code
- * AICoreService service = ...;
- * String rg = service.resourceGroup();
- * String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1());
- * RptInferenceClient client =
- *     new RptInferenceClient(service.inferenceClient(rg, deploymentId),
- *         ((AbstractAICoreService) service).getRetry());
- * List predictions = client.predict(rows, List.of("targetColumn"), "ID");
+ * 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"));
  * }
*/ 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 PREDICTION_PLACEHOLDER = "[PREDICT]"; - private final DefaultApi api; - private final Retry retry; + private static final Retry INFERENCE_RETRY = buildInferenceRetry(); - public RptInferenceClient(ApiClient apiClient, Retry retry) { - this.api = + private final DefaultApi rpt; + private final List keyNames; + + public RptInferenceClient(ApiClient apiClient, List keyNames) { + this.rpt = new DefaultApi(apiClient.withObjectMapper(JacksonConfiguration.getDefaultObjectMapper())); - this.retry = retry; + 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( - retry, + INFERENCE_RETRY, () -> { - var response = api.predict(request); + var response = rpt.predict(request); logger.debug("Prediction response id: {}", response.getId()); List> raw = JacksonConfiguration.getDefaultObjectMapper() @@ -76,35 +93,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, PREDICTION_PLACEHOLDER); + } + 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(PREDICTION_PLACEHOLDER)) .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 syntheticKey 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; }) @@ -115,4 +155,32 @@ private static PredictRequestPayload buildRequest( .rows(sdkRows) .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() + .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/FioriRecommendationHandlerTest.java b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/FioriRecommendationHandlerTest.java index c7f1a22..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 @@ -13,11 +13,12 @@ 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; 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; @@ -32,21 +33,13 @@ 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.Answers; import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -@ExtendWith(MockitoExtension.class) 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; @@ -54,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) @@ -67,7 +62,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 +147,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 +161,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 +198,158 @@ 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 + @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( @@ -393,55 +540,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 a4173e3..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,10 +5,14 @@ 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; 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,14 +24,20 @@ class RecommendationConfigurationTest { @Mock private CdsRuntimeConfigurer configurer; @Mock private CdsRuntime runtime; @Mock private ServiceCatalog serviceCatalog; - @Mock private AICoreService aiCoreService; + @Mock private CdsEnvironment environment; + @Mock private RemoteService aiCoreService; + @Mock private PersistenceService persistenceService; @Test void aiCoreServiceFound_registersHandler() { when(configurer.getCdsRuntime()).thenReturn(runtime); when(runtime.getServiceCatalog()).thenReturn(serviceCatalog); - when(serviceCatalog.getService(AICoreService.class, AICoreService.DEFAULT_NAME)) + when(runtime.getEnvironment()).thenReturn(environment); + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + when(serviceCatalog.getService(RemoteService.class, AICore_.CDS_NAME)) .thenReturn(aiCoreService); + when(serviceCatalog.getService(PersistenceService.class, PersistenceService.DEFAULT_NAME)) + .thenReturn(persistenceService); new RecommendationConfiguration().eventHandlers(configurer); @@ -38,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 new file mode 100644 index 0000000..54586bb --- /dev/null +++ b/cds-feature-recommendations/src/test/java/com/sap/cds/feature/recommendation/api/RptInferenceClientTest.java @@ -0,0 +1,76 @@ +/* + * © 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 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")); + 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..3e5f45d 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,29 @@ 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; +} + +@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; @@ -53,4 +78,6 @@ 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; + entity BooksWithRecommendationState as projection on test.BooksWithRecommendationState; } 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..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,9 +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.core.AbstractAICoreService; +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; @@ -49,30 +50,31 @@ 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(); + RemoteService 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 + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(TENANT); + service.emit(rgCtx); + String rg = rgCtx.getResult(); + 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 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 a38467a..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,9 +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.core.AbstractAICoreService; +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; @@ -51,25 +52,16 @@ void subscribeTenant_thenServiceIsReachable() throws Exception { @Test void subscribeTenant_createsResourceGroup() throws Exception { - AbstractAICoreService service = getService(); + RemoteService 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 + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("tenant-3"); + service.emit(rgCtx); + String resourceGroup = rgCtx.getResult(); + assertThat(resourceGroup).isNotNull().isNotBlank(); } @Test @@ -95,7 +87,7 @@ void tearDown() { } } - private AbstractAICoreService getService() { - return (AbstractAICoreService) 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 9b1c771..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,12 @@ 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.core.AbstractAICoreService; +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; import org.junit.jupiter.api.BeforeEach; @@ -37,19 +40,26 @@ 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(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); subscriptionEndpointClient.subscribeTenant("tenant-2"); - String rg1 = service.getTenantResourceGroupCache().get("tenant-1"); - String rg2 = service.getTenantResourceGroupCache().get("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(); @@ -58,31 +68,27 @@ void differentTenants_getDifferentResourceGroups() throws Exception { @Test void resourceGroupPrefix_applied() throws Exception { - AbstractAICoreService service = getService(); + AICoreConfig config = getConfig(); + RemoteService service = getService(); subscriptionEndpointClient.subscribeTenant("tenant-1"); - String rg = service.getTenantResourceGroupCache().get("tenant-1"); + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("tenant-1"); + service.emit(rgCtx); + String rg = rgCtx.getResult(); - 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 RemoteService getService() { + return runtime.getServiceCatalog().getService(RemoteService.class, AICore_.CDS_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/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} 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..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.core.AbstractAICoreService; +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,70 +25,69 @@ void prepareDeployment() { @Test void service_isRegistered() { assertThat(getAICoreService()).isNotNull(); - assertThat(getAICoreService()).isInstanceOf(AICoreService.class); + assertThat(getAICoreService()).isInstanceOf(RemoteService.class); } @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { - AbstractAICoreService service = getAICoreServiceImpl(); - if (!service.isMultiTenancyEnabled()) { - String result = service.resourceGroupForTenant("any-tenant"); - assertThat(result).isEqualTo(service.getDefaultResourceGroup()); + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + if (!config.multiTenancyEnabled()) { + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId("any-tenant"); + service.emit(rgCtx); + String result = rgCtx.getResult(); + assertThat(result).isEqualTo(config.defaultResourceGroup()); } } @Test void resourceGroupForTenant_multiTenancy_createsOrFindsGroup() { - AbstractAICoreService service = getAICoreServiceImpl(); - if (service.isMultiTenancyEnabled()) { + AICoreConfig config = getAICoreConfig(); + RemoteService 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); + 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); - assertThat(cached).isEqualTo(resourceGroupId); - } finally { - service.clearTenantCache(tenantId); - } + // Second call should return cached value + ResourceGroupContext rgCtx2 = ResourceGroupContext.create(); + rgCtx2.setTenantId(tenantId); + service.emit(rgCtx2); + String cached = rgCtx2.getResult(); + assertThat(cached).isEqualTo(resourceGroupId); } } @Test void deploymentId_returnsDeploymentId() { - AbstractAICoreService service = getAICoreServiceImpl(); - String resourceGroup = service.getDefaultResourceGroup(); + 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); } - @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..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.core.AbstractAICoreService; +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,60 +30,78 @@ class ActionTest extends BaseIntegrationTest { @BeforeAll void ensureResourceGroupReady() { - ensureResourceGroupProvisioned(getAICoreCqnService(), getAICoreServiceImpl().getDefaultResourceGroup()); + ensureResourceGroupProvisioned(getAICoreService(), getAICoreConfig().defaultResourceGroup()); } @Test void resourceGroupForTenant_singleTenancy_returnsDefault() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeFalse(service.isMultiTenancyEnabled(), "Multi-tenancy is enabled"); - String result = service.resourceGroupForTenant("any-tenant-id"); - assertThat(result).isEqualTo(service.getDefaultResourceGroup()); + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); + 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() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); + AICoreConfig config = getAICoreConfig(); + RemoteService 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); - } + 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() { - AbstractAICoreService service = getAICoreServiceImpl(); - String resourceGroup = service.getDefaultResourceGroup(); - - String deploymentId = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + + 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() { - AbstractAICoreService service = getAICoreServiceImpl(); - String resourceGroup = service.getDefaultResourceGroup(); - - String first = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); - String second = service.deploymentId(resourceGroup, RptModelSpec.rpt1()); + RemoteService service = getAICoreService(); + String resourceGroup = getAICoreConfig().defaultResourceGroup(); + + 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); } - @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(); + 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; @@ -97,13 +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))); + .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") @@ -114,20 +135,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/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/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..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,15 @@ 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.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; import java.util.Map; @@ -38,33 +41,37 @@ 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 AbstractAICoreService getAICoreServiceImpl() { - return (AbstractAICoreService) getAICoreService(); - } - - protected CqnService getAICoreCqnService() { - return (CqnService) 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 String ensureRptDeploymentReady() { - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + 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", @@ -75,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; @@ -85,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 ff45c9e..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,10 +7,10 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; +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; @@ -19,11 +19,11 @@ class ConfigurationTest extends BaseIntegrationTest { @Test void readAll_returnsConfigurations() { - CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + 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(); @@ -31,11 +31,11 @@ void readAll_returnsConfigurations() { @Test void readAll_filterByScenario() { - CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + 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") @@ -47,13 +47,13 @@ void readAll_filterByScenario() { @Test void create_andReadById() { - CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + 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", @@ -76,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") @@ -92,13 +92,13 @@ void create_andReadById() { @Test void create_withParameterBindings_mapsCorrectly() { - CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + 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", @@ -118,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 77f5502..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,10 +8,10 @@ import com.sap.cds.Result; import com.sap.cds.Row; -import com.sap.cds.feature.aicore.core.AbstractAICoreService; +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; @@ -20,11 +20,11 @@ class DeploymentTest extends BaseIntegrationTest { @Test void readAll_returnsDeployments() { - CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + 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(); @@ -32,11 +32,11 @@ void readAll_returnsDeployments() { @Test void readSingle_returnsDeploymentDetails() { - CqnService service = getAICoreCqnService(); - String resourceGroup = getAICoreServiceImpl().getDefaultResourceGroup(); + 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"); @@ -44,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") @@ -58,16 +58,17 @@ 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(); + 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,13 +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))); + .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 c845f13..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,38 +7,30 @@ 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.core.AbstractAICoreService; -import org.junit.jupiter.api.AfterEach; +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 { - 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(); - - String rgA = service.resourceGroupForTenant(tenantA); - String rgB = service.resourceGroupForTenant(tenantB); + AICoreConfig config = getAICoreConfig(); + 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(); + + 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); @@ -47,52 +39,35 @@ void differentTenants_getDifferentResourceGroups() { @Test void resourceGroupPrefix_appliedCorrectly() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeTrue(service.isMultiTenancyEnabled(), "Multi-tenancy is not enabled"); - 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); + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + assumeTrue(config.multiTenancyEnabled(), "Multi-tenancy is not enabled"); + String tenantA = "itest-prefix-" + System.currentTimeMillis(); + + ResourceGroupContext rgCtx = ResourceGroupContext.create(); + rgCtx.setTenantId(tenantA); + service.emit(rgCtx); + String rg = rgCtx.getResult(); + assertThat(rg).startsWith(config.resourceGroupPrefix()); } @Test void singleTenancy_alwaysReturnsDefault() { - AbstractAICoreService service = getAICoreServiceImpl(); - assumeFalse(service.isMultiTenancyEnabled(), "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()); + AICoreConfig config = getAICoreConfig(); + RemoteService service = getAICoreService(); + assumeFalse(config.multiTenancyEnabled(), "Multi-tenancy is enabled"); + + 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 2d32455..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,25 +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").where(r -> r.get("resourceGroupId").eq(rgId))) - ).doesNotThrowAnyException(); + assertThatCode( + () -> + service.run( + 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/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 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 8023ca2..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,8 +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.core.AbstractAICoreService; +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; @@ -12,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; @@ -29,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 @@ -38,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(); } @@ -58,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(); } @@ -70,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( @@ -91,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, @@ -113,47 +129,37 @@ 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)); - } - - AICoreService service = getAICoreService(); - String rg = service.resourceGroup(); - String deploymentId = service.deploymentId(rg, RptModelSpec.rpt1()); - RptInferenceClient client = - new RptInferenceClient( - service.inferenceClient(rg, deploymentId), - ((AbstractAICoreService) service).getRetry()); - List predictions = client.predict(rows, List.of("category"), "ID"); + 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"))); + + RemoteService service = getAICoreService(); + RptInferenceClient client = createRptClient(service, 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); @@ -167,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); + } }