This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 17a3028a6abd camel-docling: send the bearer token in the Authorization 
header (#29593)
17a3028a6abd is described below

commit 17a3028a6abde83cbbf14f829d5dd584eb83eb6a
Author: WMK666666 <[email protected]>
AuthorDate: Thu Nov 12 14:33:13 2026 +0800

    camel-docling: send the bearer token in the Authorization header (#29593)
    
    * camel-docling: send the bearer token in the Authorization header
    
    authenticationScheme=BEARER and oauthProfile never sent any credentials. The
    only authentication the docling-serve client knows is an API key, which it
    always puts in the X-Api-Key header, and DoclingProducer only called
    builder.apiKey(...) in the API_KEY branch:
    
        if (configuration.getAuthenticationScheme() == 
AuthenticationScheme.API_KEY
                && configuration.getAuthenticationToken() != null) {
            builder.apiKey(configuration.getAuthenticationToken());
        }
        doclingServeApi = builder.build();
    
    So a BEARER endpoint - including every endpoint using oauthProfile, because
    resolveOAuthToken() sets the scheme to BEARER after it resolves the token -
    started up, logged "with authentication: BEARER", and then sent every 
request
    without an Authorization header. Against docling-serve behind an OAuth proxy
    (which is how docling-serve is secured, it has no built-in auth) each 
request
    failed with 401 while the endpoint looked correctly configured.
    
    The client offers no builder option for any other header, but it leaves
    createRequestBuilder(RequestContext) open for subclasses. 
BearerTokenDoclingServeClient
    extends DoclingServeClient and adds the header there; DoclingProducer uses 
it
    when the scheme is BEARER. Everything else, including the JSON codec, is the
    same as the client the builder would have created.
    
    getDoclingServeApi() is package private so the producer's wiring can be 
tested
    without a real docling-serve.
    
    DoclingProducerAuthTest starts a stub docling-serve and asserts the 
credentials
    on the wire for each scheme: BEARER -> Authorization: Bearer <token> and no
    X-Api-Key, API_KEY -> X-Api-Key and no Authorization, NONE -> neither.
    
    * camel-docling: let writeValueAsString propagate the exception of Jackson 3
    
    JacksonException is unchecked in Jackson 3, and the Jackson 3 client that 
the
    builder creates does not wrap it either, so the try-catch only turned a 
specific
    exception into a bare RuntimeException. Drop it, and readValue keeps the
    JsonReadException it throws on purpose.
    
    Link the upstream request for a bearer token option so the subclass can be
    removed once docling-java offers one.
---
 .../docling/BearerTokenDoclingServeClient.java     |  82 +++++++++++++++
 .../camel/component/docling/DoclingProducer.java   |  14 ++-
 .../component/docling/DoclingProducerAuthTest.java | 112 +++++++++++++++++++++
 3 files changed, 206 insertions(+), 2 deletions(-)

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 000000000000..cad0d9d6e04e
--- /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.
+ * <p>
+ * 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.
+ * <p>
+ * 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.
+ * <p>
+ * Upstream support for a bearer token is proposed in
+ * <a 
href="https://github.com/docling-project/docling-java/pull/785";>docling-java#785</a>;
 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> T readValue(String value, Class<T> type) {
+        try {
+            return jsonMapper.readValue(value, type);
+        } catch (JacksonException e) {
+            throw new JsonReadException(e);
+        }
+    }
+
+    @Override
+    protected <T> 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 <I, O> HttpRequest.Builder 
createRequestBuilder(RequestContext<I, O> context) {
+        return super.createRequestBuilder(context).header("Authorization", 
authorization);
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <T extends DoclingServeApi, B extends 
DoclingServeApi.DoclingApiBuilder<T, B>> 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 6d0be017149f..a68eb13156a4 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 @@ public class DoclingProducer extends DefaultProducer {
                     
.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 @@ public class DoclingProducer extends DefaultProducer {
         }
     }
 
+    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 000000000000..bfe6f8838b52
--- /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<Headers> 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();
+        }
+    }
+}

Reply via email to