diff --git a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/BearerTokenDoclingServeClient.java b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/BearerTokenDoclingServeClient.java new file mode 100644 index 0000000000000..cad0d9d6e04e8 --- /dev/null +++ b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/BearerTokenDoclingServeClient.java @@ -0,0 +1,82 @@ +/* + * 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.component.docling; + +import java.net.http.HttpRequest; + +import ai.docling.serve.api.DoclingServeApi; +import ai.docling.serve.client.DoclingServeClient; +import ai.docling.serve.client.JsonReadException; +import ai.docling.serve.client.operations.RequestContext; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; + +/** + * A {@link DoclingServeClient} that sends the access token in the {@code Authorization: Bearer} header. + *

+ * The docling-serve client only supports API key authentication, which it always sends in the {@code X-Api-Key} header, + * and it offers no way to configure any other header. A bearer token - for example one resolved from the + * {@code oauthProfile} option, or one issued by an OAuth proxy sitting in front of docling-serve - therefore never + * reaches the server, and every request fails with a 401 even though the endpoint is configured with credentials. + *

+ * The client leaves {@link #createRequestBuilder(RequestContext)} open for subclasses, which is where the header is + * added here. Everything else, including the JSON codec, mirrors the client the builder itself would have created. + *

+ * Upstream support for a bearer token is proposed in + * docling-java#785; this subclass can be + * replaced by the client option once it lands. + */ +final class BearerTokenDoclingServeClient extends DoclingServeClient { + + private final JsonMapper jsonMapper = JsonMapper.builder().build(); + private final String authorization; + + BearerTokenDoclingServeClient(DoclingServeClientBuilder builder, String token) { + super(builder); + this.authorization = "Bearer " + token; + } + + @Override + protected T readValue(String value, Class type) { + try { + return jsonMapper.readValue(value, type); + } catch (JacksonException e) { + throw new JsonReadException(e); + } + } + + @Override + protected String writeValueAsString(T value) { + // Jackson 3 makes JacksonException unchecked, so it propagates as it is, as in the client of the builder + if (prettyPrintJson()) { + return jsonMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } + return jsonMapper.writeValueAsString(value); + } + + @Override + protected HttpRequest.Builder createRequestBuilder(RequestContext context) { + return super.createRequestBuilder(context).header("Authorization", authorization); + } + + @Override + @SuppressWarnings("unchecked") + public > B toBuilder() { + // the token is not part of DoclingServeApiConfig, so a builder cannot carry it over + return (B) DoclingServeClient.builder().config(config()); + } +} diff --git a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java index 6d0be017149f9..a68eb13156a4b 100644 --- a/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java +++ b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/DoclingProducer.java @@ -196,13 +196,19 @@ protected void doStart() throws Exception { .asyncPollInterval(Duration.ofMillis(configuration.getAsyncPollInterval())) .asyncTimeout(Duration.ofMillis(configuration.getAsyncTimeout())); - // Apply API key authentication if configured + // Apply the authentication configured on the endpoint if (configuration.getAuthenticationScheme() == AuthenticationScheme.API_KEY && configuration.getAuthenticationToken() != null) { builder.apiKey(configuration.getAuthenticationToken()); } - doclingServeApi = builder.build(); + if (configuration.getAuthenticationScheme() == AuthenticationScheme.BEARER + && configuration.getAuthenticationToken() != null) { + // the client has no bearer token option, so the Authorization header is added by this subclass + doclingServeApi = new BearerTokenDoclingServeClient(builder, configuration.getAuthenticationToken()); + } else { + doclingServeApi = builder.build(); + } LOG.info("DoclingProducer configured to use docling-serve API at: {} with authentication: {} (async mode: {})", configuration.getDoclingServeUrl(), @@ -222,6 +228,10 @@ protected void doStop() throws Exception { } } + DoclingServeApi getDoclingServeApi() { + return doclingServeApi; + } + private void resolveOAuthToken() throws Exception { if (ObjectHelper.isEmpty(configuration.getOauthProfile())) { return; diff --git a/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingProducerAuthTest.java b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingProducerAuthTest.java new file mode 100644 index 0000000000000..bfe6f8838b52a --- /dev/null +++ b/components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingProducerAuthTest.java @@ -0,0 +1,112 @@ +/* + * 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.component.docling; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import ai.docling.serve.api.health.HealthCheckResponse; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpServer; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Asserts which credentials the producer puts on the wire for each {@link AuthenticationScheme}, using a stub + * docling-serve instead of a real one. + */ +class DoclingProducerAuthTest extends CamelTestSupport { + + private final List requests = new CopyOnWriteArrayList<>(); + private HttpServer server; + + @BeforeEach + void startStubServer() throws IOException { + requests.clear(); + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/", exchange -> { + requests.add(exchange.getRequestHeaders()); + byte[] body = "{\"status\":\"ok\"}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + } + + @AfterEach + void stopStubServer() { + if (server != null) { + server.stop(0); + server = null; + } + } + + @Test + void bearerTokenIsSentInTheAuthorizationHeader() throws Exception { + assertThat(healthStatus("BEARER", "secret-token")).isEqualTo("ok"); + + assertThat(requests).hasSize(1); + assertThat(requests.get(0).getFirst("Authorization")).isEqualTo("Bearer secret-token"); + assertThat(requests.get(0).getFirst("X-Api-Key")).isNull(); + } + + @Test + void apiKeyIsSentInTheApiKeyHeader() throws Exception { + assertThat(healthStatus("API_KEY", "secret-token")).isEqualTo("ok"); + + assertThat(requests).hasSize(1); + assertThat(requests.get(0).getFirst("X-Api-Key")).isEqualTo("secret-token"); + assertThat(requests.get(0).getFirst("Authorization")).isNull(); + } + + @Test + void noCredentialsAreSentWhenAuthenticationIsDisabled() throws Exception { + assertThat(healthStatus("NONE", null)).isEqualTo("ok"); + + assertThat(requests).hasSize(1); + assertThat(requests.get(0).getFirst("Authorization")).isNull(); + assertThat(requests.get(0).getFirst("X-Api-Key")).isNull(); + } + + private String healthStatus(String authenticationScheme, String authenticationToken) throws Exception { + String uri = "docling:CONVERT_TO_MARKDOWN?useDoclingServe=true&doclingServeUrl=http://localhost:" + + server.getAddress().getPort() + + "&authenticationScheme=" + authenticationScheme + + (authenticationToken != null ? "&authenticationToken=" + authenticationToken : ""); + DoclingEndpoint endpoint = context.getEndpoint(uri, DoclingEndpoint.class); + DoclingProducer producer = (DoclingProducer) endpoint.createProducer(); + producer.start(); + try { + HealthCheckResponse health = producer.getDoclingServeApi().health(); + return health.getStatus(); + } finally { + producer.stop(); + } + } +}