gnodet-bot commented on code in PR #26768: URL: https://github.com/apache/camel/pull/26768#discussion_r4083573536
########## test-infra/camel-test-infra-typesafe-ai-mock/src/main/java/org/apache/camel/test/infra/typesafeai/mock/TypeSafeAiService.java: ########## @@ -0,0 +1,263 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.test.infra.typesafeai.mock; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.apache.camel.util.json.DeserializationException; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A local System One HTTP mock, or an existing compatible service when its base URL is supplied. Configure + * {@code TYPESAFE_AI_BASE_URL}, {@code TYPESAFE_AI_API_KEY}, and optionally {@code TYPESAFE_AI_MODEL} to run the same + * tests against a real API. The Laya equivalents {@code LAYA_BASE_URL} and {@code LAYA_API_KEY} are also accepted. + * + * @since 4.23 + */ +public class TypeSafeAiService implements BeforeEachCallback, AfterEachCallback { + private static final Logger LOG = LoggerFactory.getLogger(TypeSafeAiService.class); + + private final String remoteBaseUrl; + private final String apiKey; + private final String model; + private final List<JsonObject> requests = new CopyOnWriteArrayList<>(); + private volatile Function<JsonObject, Map<String, ?>> responder = TypeSafeAiService::defaultAnswers; + private HttpServer server; + private ExecutorService executor; + + /** + * Creates a service using environment configuration, or a local mock when absent. + * + * @since 4.23 + */ + public TypeSafeAiService() { + this(firstConfigured("TYPESAFE_AI_BASE_URL", "LAYA_BASE_URL"), + firstConfigured("TYPESAFE_AI_API_KEY", "LAYA_API_KEY"), + firstConfigured("TYPESAFE_AI_MODEL", "LAYA_MODEL")); + } + + /** + * Explicit configuration is useful when a test starts its own compatible server. + * + * @since 4.23 + */ + public TypeSafeAiService(String baseUrl, String apiKey, String model) { + remoteBaseUrl = baseUrl; + if (baseUrl != null && !baseUrl.isBlank() && (apiKey == null || apiKey.isBlank())) { + throw new IllegalArgumentException("TYPESAFE_AI_API_KEY is required with TYPESAFE_AI_BASE_URL"); + } + this.apiKey = apiKey == null || apiKey.isBlank() ? "test-key" : apiKey; + this.model = model == null || model.isBlank() ? "jev-latest" : model; + } + + /** + * Returns the URL used by the component. + * + * @since 4.23 + */ + public String getBaseUrl() { + if (isRemote()) { + return remoteBaseUrl; + } + if (server == null) { + throw new IllegalStateException("TypeSafe AI mock has not started"); + } + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + /** + * Returns the configured API key or the local mock's test key. + * + * @since 4.23 + */ + public String getApiKey() { + return apiKey; + } + + /** + * Returns the configured model name. + * + * @since 4.23 + */ + public String getModel() { + return model; + } + + /** + * Whether an existing API is used instead of a local mock. + * + * @since 4.23 + */ + public boolean isRemote() { + return remoteBaseUrl != null && !remoteBaseUrl.isBlank(); + } + + /** + * Returns requests received by the local mock. A remote service is not recorded. + * + * @since 4.23 + */ + public List<JsonObject> getRequests() { + return List.copyOf(requests); + } + + /** + * Supplies answer objects keyed by the question names in the incoming request. + * + * @since 4.23 + */ + public void setResponder(Function<JsonObject, Map<String, ?>> responder) { + this.responder = Objects.requireNonNull(responder); + } + + @Override + public void beforeEach(ExtensionContext context) throws IOException { + requests.clear(); + if (isRemote()) { + return; + } + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/systemone", this::handle); + executor = Executors.newCachedThreadPool(); + server.setExecutor(executor); + server.start(); + } + + @Override + public void afterEach(ExtensionContext context) { + if (server != null) { + server.stop(1); + server = null; + } + if (executor != null) { + executor.shutdownNow(); + try { + executor.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + executor = null; + } + } + + private void handle(HttpExchange exchange) throws IOException { + try { + if (!"POST".equals(exchange.getRequestMethod())) { + send(exchange, 405, "{}"); + return; + } + if (!("Bearer " + apiKey).equals(exchange.getRequestHeaders().getFirst("Authorization"))) { + send(exchange, 401, "{}"); + return; + } + Object parsed; + try { + parsed = Jsoner.deserialize(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + } catch (DeserializationException e) { + send(exchange, 400, "{}"); + return; + } + if (!(parsed instanceof JsonObject request) || !(request.get("questions") instanceof Map<?, ?> questions) + || questions.isEmpty() || !(request.get("model") instanceof String)) { + send(exchange, 400, "{}"); + return; + } + requests.add(request); + Map<String, ?> answers; + try { + answers = responder.apply(request); + } catch (RuntimeException e) { + LOG.error("TypeSafe AI mock responder failed", e); + send(exchange, 500, "{}"); + return; + } + if (!answers.keySet().equals(questions.keySet())) { Review Comment: ⚠️ **Silent 500 when responder returns wrong answer keys — no diagnostic log.** The `catch (RuntimeException e)` block two lines above calls `LOG.error("TypeSafe AI mock responder failed", e)` before sending 500. This structural-mismatch path (answers whose keys don't match the request's question keys) sends 500 with `{}` and no log. A test author using a custom responder that accidentally returns the wrong question name gets an opaque HTTP 500 with no indication of what went wrong. ```suggestion if (!answers.keySet().equals(questions.keySet())) { LOG.warn("Responder returned answer keys {} but request asked for {}", answers.keySet(), questions.keySet()); send(exchange, 500, "{}"); return; } ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
