Skip to content

Commit 5463c38

Browse files
MatKuhrbot-sdk-js
andauthored
fix: AiCoreService NoClassDefFound Error (#178)
* fix: AiCoreService NoClassDefFound Error * Formatting * chore: [Core] Refactor Service Key Loading and Testing (#181) * chore: Core Servce Key Loading Refactoring and Testing * Add test for lazy dotenv loading --------- Co-authored-by: SAP Cloud SDK Bot <[email protected]> --------- Co-authored-by: SAP Cloud SDK Bot <[email protected]>
1 parent 782e248 commit 5463c38

File tree

11 files changed

+299
-209
lines changed

11 files changed

+299
-209
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.sap.ai.sdk.core;
2+
3+
import lombok.experimental.StandardException;
4+
5+
/** Exception thrown when the JSON AI Core service key is invalid. */
6+
@StandardException
7+
class AiCoreCredentialsInvalidException extends RuntimeException {}

core/src/main/java/com/sap/ai/sdk/core/AiCoreDeployment.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.sap.ai.sdk.core;
22

3-
import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
3+
import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination;
44
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
55
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException;
66
import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient;
@@ -30,7 +30,8 @@ public AiCoreDeployment withResourceGroup(@Nonnull final String resourceGroup) {
3030

3131
@Nonnull
3232
@Override
33-
public Destination destination() throws DestinationAccessException, DestinationNotFoundException {
33+
public HttpDestination destination()
34+
throws DestinationAccessException, DestinationNotFoundException {
3435
aiCoreService.deploymentId = deploymentId.get();
3536
return aiCoreService.destination();
3637
}

core/src/main/java/com/sap/ai/sdk/core/AiCoreService.java

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
package com.sap.ai.sdk.core;
22

3-
import static com.sap.ai.sdk.core.DestinationResolver.AI_CLIENT_TYPE_KEY;
4-
import static com.sap.ai.sdk.core.DestinationResolver.AI_CLIENT_TYPE_VALUE;
5-
63
import com.fasterxml.jackson.annotation.JsonAutoDetect;
74
import com.fasterxml.jackson.annotation.JsonInclude;
85
import com.fasterxml.jackson.annotation.PropertyAccessor;
@@ -12,15 +9,14 @@
129
import com.sap.cloud.sdk.cloudplatform.connectivity.DefaultHttpDestination;
1310
import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
1411
import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationProperty;
12+
import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination;
1513
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
1614
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException;
1715
import com.sap.cloud.sdk.services.openapi.apiclient.ApiClient;
18-
import io.github.cdimascio.dotenv.Dotenv;
1916
import java.util.NoSuchElementException;
2017
import java.util.function.BiFunction;
2118
import java.util.function.Function;
2219
import javax.annotation.Nonnull;
23-
import lombok.RequiredArgsConstructor;
2420
import lombok.extern.slf4j.Slf4j;
2521
import lombok.val;
2622
import org.springframework.http.client.BufferingClientHttpRequestFactory;
@@ -31,19 +27,21 @@
3127

3228
/** Connectivity convenience methods for AI Core. */
3329
@Slf4j
34-
@RequiredArgsConstructor
3530
public class AiCoreService implements AiCoreDestination {
36-
37-
Function<AiCoreService, Destination> baseDestinationHandler;
38-
final BiFunction<AiCoreService, Destination, ApiClient> clientHandler;
39-
final BiFunction<AiCoreService, Destination, DefaultHttpDestination.Builder> builderHandler;
31+
static final String AI_CLIENT_TYPE_KEY = "URL.headers.AI-Client-Type";
32+
static final String AI_CLIENT_TYPE_VALUE = "AI SDK Java";
33+
static final String AI_RESOURCE_GROUP = "URL.headers.AI-Resource-Group";
4034

4135
private static final DeploymentCache DEPLOYMENT_CACHE = new DeploymentCache();
4236

43-
private static final String AI_RESOURCE_GROUP = "URL.headers.AI-Resource-Group";
37+
@Nonnull private final DestinationResolver destinationResolver;
4438

45-
/** loads the .env file from the root of the project */
46-
private static final Dotenv DOTENV = Dotenv.configure().ignoreIfMissing().load();
39+
@Nonnull private Function<AiCoreService, HttpDestination> baseDestinationHandler;
40+
@Nonnull private final BiFunction<AiCoreService, HttpDestination, ApiClient> clientHandler;
41+
42+
@Nonnull
43+
private final BiFunction<AiCoreService, HttpDestination, DefaultHttpDestination.Builder>
44+
builderHandler;
4745

4846
/** The resource group is defined by AiCoreDeployment.withResourceGroup(). */
4947
@Nonnull String resourceGroup;
@@ -53,8 +51,16 @@ public class AiCoreService implements AiCoreDestination {
5351

5452
/** The default constructor. */
5553
public AiCoreService() {
56-
this(AiCoreService::getApiClient, AiCoreService::getDestinationBuilder, "default", "");
54+
this(new DestinationResolver());
55+
}
56+
57+
AiCoreService(@Nonnull final DestinationResolver destinationResolver) {
58+
this.destinationResolver = destinationResolver;
5759
baseDestinationHandler = AiCoreService::getBaseDestination;
60+
clientHandler = AiCoreService::buildApiClient;
61+
builderHandler = AiCoreService::getDestinationBuilder;
62+
resourceGroup = "default";
63+
deploymentId = "";
5864
}
5965

6066
@Nonnull
@@ -66,7 +72,7 @@ public ApiClient client() {
6672

6773
@Nonnull
6874
@Override
69-
public Destination destination() {
75+
public HttpDestination destination() {
7076
val dest = baseDestinationHandler.apply(this);
7177
val builder = builderHandler.apply(this, dest);
7278
if (!deploymentId.isEmpty()) {
@@ -107,7 +113,7 @@ protected void destinationSetHeaders(@Nonnull final DefaultHttpDestination.Build
107113
* @return The AI Core Service based on the provided destination.
108114
*/
109115
@Nonnull
110-
public AiCoreService withDestination(@Nonnull final Destination destination) {
116+
public AiCoreService withDestination(@Nonnull final HttpDestination destination) {
111117
baseDestinationHandler = service -> destination;
112118
return this;
113119
}
@@ -161,10 +167,9 @@ public AiCoreDeployment forDeploymentByScenario(@Nonnull final String scenarioId
161167
* @throws DestinationNotFoundException If the destination cannot be found.
162168
*/
163169
@Nonnull
164-
protected Destination getBaseDestination()
170+
protected HttpDestination getBaseDestination()
165171
throws DestinationAccessException, DestinationNotFoundException {
166-
val serviceKey = DOTENV.get("AICORE_SERVICE_KEY");
167-
return DestinationResolver.getDestination(serviceKey);
172+
return destinationResolver.getDestination();
168173
}
169174

170175
/**
@@ -186,14 +191,13 @@ protected DefaultHttpDestination.Builder getDestinationBuilder(
186191
}
187192

188193
/**
189-
* Get a destination using the default service binding loading logic.
194+
* Build an {@link ApiClient} that can be used for executing plain REST HTTP calls.
190195
*
191-
* @return The destination.
192-
* @throws DestinationAccessException If the destination cannot be accessed.
193-
* @throws DestinationNotFoundException If the destination cannot be found.
196+
* @param destination The destination to use as basis for the client.
197+
* @return The new API client.
194198
*/
195199
@Nonnull
196-
protected ApiClient getApiClient(@Nonnull final Destination destination) {
200+
protected ApiClient buildApiClient(@Nonnull final Destination destination) {
197201
val objectMapper =
198202
new Jackson2ObjectMapperBuilder()
199203
.modules(new JavaTimeModule())
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.sap.ai.sdk.core;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.core.type.TypeReference;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import com.sap.cloud.environment.servicebinding.api.DefaultServiceBindingBuilder;
7+
import com.sap.cloud.environment.servicebinding.api.ServiceBinding;
8+
import com.sap.cloud.environment.servicebinding.api.ServiceBindingAccessor;
9+
import com.sap.cloud.environment.servicebinding.api.ServiceIdentifier;
10+
import com.sap.cloud.environment.servicebinding.api.exception.ServiceBindingAccessException;
11+
import io.github.cdimascio.dotenv.Dotenv;
12+
import io.github.cdimascio.dotenv.DotenvBuilder;
13+
import io.vavr.Lazy;
14+
import java.util.HashMap;
15+
import java.util.List;
16+
import javax.annotation.Nonnull;
17+
import lombok.AllArgsConstructor;
18+
import lombok.extern.slf4j.Slf4j;
19+
import lombok.val;
20+
21+
/**
22+
* Loads an AI Core service key from the "AICORE_SERVICE_KEY" environment variable with support for
23+
* .env files. Will ignore missing or malformed dotenv files.
24+
*/
25+
@Slf4j
26+
@AllArgsConstructor
27+
class AiCoreServiceKeyAccessor implements ServiceBindingAccessor {
28+
static final String ENV_VAR_KEY = "AICORE_SERVICE_KEY";
29+
30+
private final Lazy<Dotenv> dotenv;
31+
32+
AiCoreServiceKeyAccessor() {
33+
this(Dotenv.configure().ignoreIfMissing().ignoreIfMalformed());
34+
}
35+
36+
AiCoreServiceKeyAccessor(@Nonnull final DotenvBuilder dotenvBuilder) {
37+
dotenv = Lazy.of(dotenvBuilder::load);
38+
}
39+
40+
@Nonnull
41+
@Override
42+
public List<ServiceBinding> getServiceBindings() throws ServiceBindingAccessException {
43+
final String serviceKey;
44+
try {
45+
serviceKey = dotenv.get().get(ENV_VAR_KEY);
46+
} catch (Exception e) {
47+
throw new ServiceBindingAccessException("Failed to load service key from environment", e);
48+
}
49+
if (serviceKey == null) {
50+
log.debug("No service key found in environment variable {}", ENV_VAR_KEY);
51+
return List.of();
52+
}
53+
log.info(
54+
"""
55+
Found a service key in environment variable {}.
56+
Using a service key is recommended for local testing only.
57+
Bind the AI Core service to the application for productive usage.
58+
""",
59+
ENV_VAR_KEY);
60+
61+
val binding = createServiceBinding(serviceKey);
62+
return List.of(binding);
63+
}
64+
65+
static ServiceBinding createServiceBinding(@Nonnull final String serviceKey)
66+
throws ServiceBindingAccessException {
67+
final HashMap<String, Object> credentials;
68+
try {
69+
credentials = new ObjectMapper().readValue(serviceKey, new TypeReference<>() {});
70+
} catch (JsonProcessingException e) {
71+
throw new ServiceBindingAccessException(
72+
new AiCoreCredentialsInvalidException(
73+
"Error in parsing service key from the " + ENV_VAR_KEY + " environment variable.",
74+
e));
75+
}
76+
if (credentials.get("clientid") == null) {
77+
// explicitly check for the client ID to improve the error message
78+
// otherwise we get a rather generic DestinationNotFoundException error that none of the
79+
// loaders matched the binding
80+
throw new ServiceBindingAccessException(
81+
new AiCoreCredentialsInvalidException("Missing clientid in service key"));
82+
}
83+
84+
return new DefaultServiceBindingBuilder()
85+
.withServiceIdentifier(ServiceIdentifier.AI_CORE)
86+
.withCredentials(credentials)
87+
.build();
88+
}
89+
}
Lines changed: 25 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,57 @@
11
package com.sap.ai.sdk.core;
22

3-
import static com.google.common.collect.Iterables.tryFind;
43
import static com.sap.cloud.sdk.cloudplatform.connectivity.OnBehalfOf.TECHNICAL_USER_PROVIDER;
5-
import static com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions.forService;
64

7-
import com.fasterxml.jackson.core.JsonProcessingException;
8-
import com.fasterxml.jackson.core.type.TypeReference;
9-
import com.fasterxml.jackson.databind.ObjectMapper;
105
import com.sap.cloud.environment.servicebinding.api.DefaultServiceBindingAccessor;
11-
import com.sap.cloud.environment.servicebinding.api.DefaultServiceBindingBuilder;
6+
import com.sap.cloud.environment.servicebinding.api.ServiceBinding;
127
import com.sap.cloud.environment.servicebinding.api.ServiceBindingAccessor;
138
import com.sap.cloud.environment.servicebinding.api.ServiceBindingMerger;
149
import com.sap.cloud.environment.servicebinding.api.ServiceIdentifier;
10+
import com.sap.cloud.environment.servicebinding.api.exception.ServiceBindingAccessException;
1511
import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination;
1612
import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationLoader;
17-
import java.util.HashMap;
13+
import com.sap.cloud.sdk.cloudplatform.connectivity.ServiceBindingDestinationOptions;
1814
import java.util.List;
19-
import java.util.Optional;
20-
import java.util.function.Predicate;
2115
import javax.annotation.Nonnull;
22-
import javax.annotation.Nullable;
23-
import lombok.AccessLevel;
24-
import lombok.Getter;
25-
import lombok.NoArgsConstructor;
16+
import lombok.AllArgsConstructor;
2617
import lombok.extern.slf4j.Slf4j;
2718
import lombok.val;
2819

2920
/** Utility class to resolve the destination pointing to the AI Core service. */
3021
@Slf4j
31-
@NoArgsConstructor(access = AccessLevel.PRIVATE) // utility class should not be instantiated
22+
@AllArgsConstructor
3223
class DestinationResolver {
33-
static final String AI_CLIENT_TYPE_KEY = "URL.headers.AI-Client-Type";
34-
static final String AI_CLIENT_TYPE_VALUE = "AI SDK Java";
24+
@Nonnull private final ServiceBindingAccessor accessor;
3525

36-
@Getter(AccessLevel.PROTECTED)
37-
@Nonnull
38-
private static ServiceBindingAccessor accessor = DefaultServiceBindingAccessor.getInstance();
26+
DestinationResolver() {
27+
this(
28+
new ServiceBindingMerger(
29+
List.of(DefaultServiceBindingAccessor.getInstance(), new AiCoreServiceKeyAccessor()),
30+
ServiceBindingMerger.KEEP_EVERYTHING));
31+
}
3932

40-
/**
41-
* <b>For testing only</b>
42-
*
43-
* <p>Get a destination pointing to the AI Core service.
44-
*
45-
* @param serviceKey The service key in JSON format.
46-
* @return a destination pointing to the AI Core service.
47-
*/
4833
@SuppressWarnings("UnstableApiUsage")
49-
static HttpDestination getDestination(@Nullable final String serviceKey) {
50-
final Predicate<Object> aiCore = Optional.of(ServiceIdentifier.AI_CORE)::equals;
51-
val serviceBindings = accessor.getServiceBindings();
52-
val aiCoreBinding = tryFind(serviceBindings, b -> aiCore.test(b.getServiceIdentifier()));
53-
54-
val serviceKeyPresent = serviceKey != null;
55-
if (!aiCoreBinding.isPresent() && serviceKeyPresent) {
56-
addServiceBinding(serviceKey);
57-
}
34+
HttpDestination getDestination() {
35+
val binding =
36+
accessor.getServiceBindings().stream()
37+
.filter(DestinationResolver::isAiCoreService)
38+
.findFirst()
39+
.orElseThrow(
40+
() ->
41+
new ServiceBindingAccessException(
42+
"Could not find any matching service bindings for service identifier "
43+
+ ServiceIdentifier.AI_CORE));
5844

5945
// get a destination pointing to the AI Core service
6046
val opts =
61-
(aiCoreBinding.isPresent()
62-
? forService(aiCoreBinding.get())
63-
: forService(ServiceIdentifier.AI_CORE))
47+
ServiceBindingDestinationOptions.forService(binding)
6448
.onBehalfOf(TECHNICAL_USER_PROVIDER)
6549
.build();
6650

6751
return ServiceBindingDestinationLoader.defaultLoaderChain().getDestination(opts);
6852
}
6953

70-
/**
71-
* Set the AI Core service key as the service binding. This is used for local testing.
72-
*
73-
* @param serviceKey The service key in JSON format.
74-
* @throws AiCoreCredentialsInvalidException if the JSON service key cannot be parsed.
75-
*/
76-
private static void addServiceBinding(@Nonnull final String serviceKey) {
77-
log.info(
78-
"""
79-
Found a service key in environment variable "AICORE_SERVICE_KEY".
80-
Using a service key is recommended for local testing only.
81-
Bind the AI Core service to the application for productive usage.""");
82-
83-
var credentials = new HashMap<String, Object>();
84-
try {
85-
credentials = new ObjectMapper().readValue(serviceKey, new TypeReference<>() {});
86-
} catch (JsonProcessingException e) {
87-
throw new AiCoreCredentialsInvalidException(
88-
"Error in parsing service key from the \"AICORE_SERVICE_KEY\" environment variable.", e);
89-
}
90-
91-
val binding =
92-
new DefaultServiceBindingBuilder()
93-
.withServiceIdentifier(ServiceIdentifier.AI_CORE)
94-
.withCredentials(credentials)
95-
.build();
96-
val newAccessor =
97-
new ServiceBindingMerger(
98-
List.of(accessor, () -> List.of(binding)), ServiceBindingMerger.KEEP_EVERYTHING);
99-
DefaultServiceBindingAccessor.setInstance(newAccessor);
100-
}
101-
102-
/** Exception thrown when the JSON AI Core service key is invalid. */
103-
static class AiCoreCredentialsInvalidException extends RuntimeException {
104-
public AiCoreCredentialsInvalidException(
105-
@Nonnull final String message, @Nonnull final Throwable cause) {
106-
super(message, cause);
107-
}
108-
}
109-
110-
/**
111-
* For testing set the accessor to be used for service binding resolution.
112-
*
113-
* @param accessor The accessor to be used for service binding resolution.
114-
*/
115-
static void setAccessor(@Nullable final ServiceBindingAccessor accessor) {
116-
DestinationResolver.accessor =
117-
accessor == null ? DefaultServiceBindingAccessor.getInstance() : accessor;
54+
private static boolean isAiCoreService(@Nonnull final ServiceBinding binding) {
55+
return ServiceIdentifier.AI_CORE.equals(binding.getServiceIdentifier().orElse(null));
11856
}
11957
}

0 commit comments

Comments
 (0)