From 5db1c82164b56e91d840efee85647f58a4db5f94 Mon Sep 17 00:00:00 2001 From: WMK666666 <3496319356@qq.com> Date: Wed, 11 Nov 2026 12:52:22 +0800 Subject: [PATCH 1/2] 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 and no X-Api-Key, API_KEY -> X-Api-Key and no Authorization, NONE -> neither. --- .../BearerTokenDoclingServeClient.java | 81 +++++++++++++ .../component/docling/DoclingProducer.java | 14 ++- .../docling/DoclingProducerAuthTest.java | 112 ++++++++++++++++++ 3 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/BearerTokenDoclingServeClient.java create mode 100644 components/camel-ai/camel-docling/src/test/java/org/apache/camel/component/docling/DoclingProducerAuthTest.java 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..a803361ee62a0 --- /dev/null +++ b/components/camel-ai/camel-docling/src/main/java/org/apache/camel/component/docling/BearerTokenDoclingServeClient.java @@ -0,0 +1,81 @@ +/* + * 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. + */ +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) { + try { + if (prettyPrintJson()) { + return jsonMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } + return jsonMapper.writeValueAsString(value); + } catch (JacksonException e) { + throw new RuntimeException(e); + } + } + + @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(); + } + } +} From 7766cd0253522aea445bc1994e7462bd783687a4 Mon Sep 17 00:00:00 2001 From: WMK666666 <3496319356@qq.com> Date: Wed, 11 Nov 2026 21:26:31 +0800 Subject: [PATCH 2/2] 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 | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 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 index a803361ee62a0..cad0d9d6e04e8 100644 --- 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 @@ -35,6 +35,10 @@ *

* 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 { @@ -57,14 +61,11 @@ protected T readValue(String value, Class type) { @Override protected String writeValueAsString(T value) { - try { - if (prettyPrintJson()) { - return jsonMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); - } - return jsonMapper.writeValueAsString(value); - } catch (JacksonException e) { - throw new RuntimeException(e); + // 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