diff --git a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java index 6b56a0e574b5c..7da999cddc6b7 100644 --- a/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java +++ b/components/camel-ai/camel-mcp-server/src/main/java/org/apache/camel/component/mcp/server/vertx/VertxMcpServerEngine.java @@ -147,7 +147,7 @@ protected void doStop() throws Exception { PlatformHttpComponent platformHttpComponent = (PlatformHttpComponent) camelContext.hasComponent("platform-http"); if (platformHttpComponent != null && info != null) { - platformHttpComponent.removeHttpEndpoint(info.path()); + platformHttpComponent.removeHttpEndpoint(info.path(), null); } transport = null; } diff --git a/components/camel-platform-http-main/src/main/java/org/apache/camel/component/platform/http/main/MainHttpServerUtil.java b/components/camel-platform-http-main/src/main/java/org/apache/camel/component/platform/http/main/MainHttpServerUtil.java index 782e7e5c1db6c..5d5658b152d2a 100644 --- a/components/camel-platform-http-main/src/main/java/org/apache/camel/component/platform/http/main/MainHttpServerUtil.java +++ b/components/camel-platform-http-main/src/main/java/org/apache/camel/component/platform/http/main/MainHttpServerUtil.java @@ -17,7 +17,9 @@ package org.apache.camel.component.platform.http.main; import java.util.HashSet; +import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; import org.apache.camel.CamelContext; import org.apache.camel.StartupListener; @@ -35,14 +37,15 @@ protected static void setupStartupSummary( CamelContext camelContext, Set endpoints, int serverPort, boolean ssl, String header) throws Exception { camelContext.addStartupListener(new StartupListener() { - private volatile Set last; + private volatile Set lastEndpointSignatures; private void logSummary() { if (endpoints.isEmpty()) { return; } - // log only if changed - if (last == null || last.size() != endpoints.size() || !last.containsAll(endpoints)) { + // log only if changed (ignore consumer identity on route reload) + Set currentSignatures = endpointSignatures(endpoints); + if (lastEndpointSignatures == null || !lastEndpointSignatures.equals(currentSignatures)) { LOG.info(header); int longestEndpoint = 0; int longestVerbs = 0; @@ -78,8 +81,19 @@ private void logSummary() { } } - // use a defensive copy of last known endpoints - last = new HashSet<>(endpoints); + lastEndpointSignatures = currentSignatures; + } + + private Set endpointSignatures(Set endpointModels) { + return endpointModels.stream() + .map(this::endpointSignature) + .collect(Collectors.toCollection(HashSet::new)); + } + + private String endpointSignature(HttpEndpointModel model) { + return model.getUri() + "|" + Objects.toString(model.getVerbs(), "") + "|" + + Objects.toString(model.getConsumes(), "") + "|" + + Objects.toString(model.getProduces(), ""); } private String getEndpoint(HttpEndpointModel httpEndpointModel, boolean ssl) { diff --git a/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/DefaultPlatformHttpConsumer.java b/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/DefaultPlatformHttpConsumer.java index 70773c1d40202..3c7afd4cf9829 100644 --- a/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/DefaultPlatformHttpConsumer.java +++ b/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/DefaultPlatformHttpConsumer.java @@ -107,7 +107,7 @@ protected void configurePlatformHttpConsumer(PlatformHttpConsumer platformHttpCo protected void doStart() throws Exception { super.doStart(); ServiceHelper.startService(platformHttpConsumer); - if (register) { + if (register && platformHttpConsumer != null) { getComponent().addHttpEndpoint(getEndpoint().getPath(), getEndpoint().getHttpMethodRestrict(), getEndpoint().getConsumes(), getEndpoint().getProduces(), platformHttpConsumer); } @@ -116,8 +116,8 @@ protected void doStart() throws Exception { @Override protected void doStop() throws Exception { super.doStop(); - if (register) { - getComponent().removeHttpEndpoint(getEndpoint().getPath()); + if (register && platformHttpConsumer != null) { + getComponent().removeHttpEndpoint(platformHttpConsumer); } ServiceHelper.stopAndShutdownServices(platformHttpConsumer); } diff --git a/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/HttpEndpointModel.java b/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/HttpEndpointModel.java index 0e75f19ead6e4..a323439205fa5 100644 --- a/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/HttpEndpointModel.java +++ b/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/HttpEndpointModel.java @@ -96,16 +96,29 @@ public boolean equals(Object o) { return false; } HttpEndpointModel that = (HttpEndpointModel) o; - return uri.equals(that.uri); + return uri.equals(that.uri) && consumer == that.consumer; } @Override public int hashCode() { - return Objects.hash(uri); + return Objects.hash(uri, consumer); } @Override public int compareTo(HttpEndpointModel o) { - return uri.compareTo(o.uri); + int cmp = uri.compareTo(o.uri); + if (cmp != 0) { + return cmp; + } + if (consumer == o.consumer) { + return 0; + } + if (consumer == null) { + return -1; + } + if (o.consumer == null) { + return 1; + } + return Integer.compare(System.identityHashCode(consumer), System.identityHashCode(o.consumer)); } } diff --git a/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/PlatformHttpComponent.java b/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/PlatformHttpComponent.java index df2bdabc8030a..3840abbb13fe2 100644 --- a/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/PlatformHttpComponent.java +++ b/components/camel-platform-http/src/main/java/org/apache/camel/component/platform/http/PlatformHttpComponent.java @@ -18,10 +18,11 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.TreeSet; +import java.util.function.Predicate; import org.apache.camel.CamelContext; import org.apache.camel.CamelContextAware; @@ -72,8 +73,8 @@ public class PlatformHttpComponent extends HeaderFilterStrategyComponent + " or all requests must be handled by Camel.") private boolean serverRequestValidation = true; - private final Set httpEndpoints = new TreeSet<>(); - private final Set httpManagementEndpoints = new TreeSet<>(); + private final Set httpEndpoints = new LinkedHashSet<>(); + private final Set httpManagementEndpoints = new LinkedHashSet<>(); private final List listeners = new ArrayList<>(); private volatile boolean localEngine; @@ -164,19 +165,54 @@ private void addHttpEndpoint( * Removes a known http endpoint managed by this component. */ public void removeHttpEndpoint(String uri) { - this.removeHttpEndpoint(this.httpEndpoints, uri); + removeHttpEndpoints(this.httpEndpoints, e -> e.getUri().equals(uri)); + } + + /** + * Removes the http endpoint registered for the given consumer. + */ + public void removeHttpEndpoint(Consumer consumer) { + if (consumer == null) { + return; + } + removeHttpEndpoints(this.httpEndpoints, e -> e.getConsumer() == consumer); + } + + /** + * Removes the http endpoint registered for the given uri and consumer reference. + *

+ * Use this when multiple registrations share the same uri but have different consumers, or when the registration + * used a {@code null} consumer (for example MCP server metadata). + *

+ */ + public void removeHttpEndpoint(String uri, Consumer consumer) { + removeHttpEndpoints(this.httpEndpoints, e -> e.getUri().equals(uri) && e.getConsumer() == consumer); } /** * Removes a known http endpoint managed by this component. */ public void removeHttpManagementEndpoint(String uri) { - this.removeHttpEndpoint(this.httpManagementEndpoints, uri); + removeHttpEndpoints(this.httpManagementEndpoints, e -> e.getUri().equals(uri)); + } + + /** + * Removes the http management endpoint registered for the given consumer. + *

+ * Provided for symmetry with {@link #removeHttpManagementEndpoint(String)} for callers that track a management + * consumer reference. + *

+ */ + public void removeHttpManagementEndpoint(Consumer consumer) { + if (consumer == null) { + return; + } + removeHttpEndpoints(this.httpManagementEndpoints, e -> e.getConsumer() == consumer); } - private void removeHttpEndpoint(Set endpoints, String uri) { + private void removeHttpEndpoints(Set endpoints, Predicate filter) { List toRemove = new ArrayList<>(); - endpoints.stream().filter(e -> e.getUri().equals(uri)).forEach(model -> { + endpoints.stream().filter(filter).forEach(model -> { toRemove.add(model); for (PlatformHttpListener listener : listeners) { try { diff --git a/components/camel-platform-http/src/test/java/org/apache/camel/component/platform/http/HttpEndpointModelTest.java b/components/camel-platform-http/src/test/java/org/apache/camel/component/platform/http/HttpEndpointModelTest.java new file mode 100644 index 0000000000000..0be24d293c872 --- /dev/null +++ b/components/camel-platform-http/src/test/java/org/apache/camel/component/platform/http/HttpEndpointModelTest.java @@ -0,0 +1,72 @@ +/* + * 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.platform.http; + +import java.util.HashSet; +import java.util.Set; + +import org.apache.camel.Consumer; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class HttpEndpointModelTest { + + @Test + void setRetainsMultipleConsumersOnSamePath() { + Consumer getConsumer = mock(Consumer.class); + Consumer postConsumer = mock(Consumer.class); + + HttpEndpointModel getModel = new HttpEndpointModel("/shared", "GET", null, null, getConsumer); + HttpEndpointModel postModel = new HttpEndpointModel("/shared", "POST", null, null, postConsumer); + + Set endpoints = new HashSet<>(); + assertTrue(endpoints.add(getModel)); + assertTrue(endpoints.add(postModel)); + assertEquals(2, endpoints.size()); + } + + @Test + void equalsAndHashCodeUseConsumerIdentity() { + Consumer first = mock(Consumer.class); + Consumer second = mock(Consumer.class); + + HttpEndpointModel firstModel = new HttpEndpointModel("/shared", "GET", null, null, first); + HttpEndpointModel secondModel = new HttpEndpointModel("/shared", "POST", null, null, second); + HttpEndpointModel sameConsumerModel = new HttpEndpointModel("/shared", "GET", null, null, first); + + assertNotEquals(firstModel, secondModel); + assertEquals(firstModel, sameConsumerModel); + assertEquals(firstModel.hashCode(), sameConsumerModel.hashCode()); + } + + @Test + void compareToIsConsistentWithEquals() { + Consumer first = mock(Consumer.class); + Consumer second = mock(Consumer.class); + + HttpEndpointModel firstModel = new HttpEndpointModel("/shared", "GET", null, null, first); + HttpEndpointModel secondModel = new HttpEndpointModel("/shared", "POST", null, null, second); + HttpEndpointModel sameConsumerModel = new HttpEndpointModel("/shared", "GET", null, null, first); + + assertEquals(0, firstModel.compareTo(sameConsumerModel)); + assertNotEquals(0, firstModel.compareTo(secondModel)); + } +} diff --git a/components/camel-platform-http/src/test/java/org/apache/camel/component/platform/http/PlatformHttpSharedPathRouteLifecycleTest.java b/components/camel-platform-http/src/test/java/org/apache/camel/component/platform/http/PlatformHttpSharedPathRouteLifecycleTest.java new file mode 100644 index 0000000000000..10af266d1487e --- /dev/null +++ b/components/camel-platform-http/src/test/java/org/apache/camel/component/platform/http/PlatformHttpSharedPathRouteLifecycleTest.java @@ -0,0 +1,179 @@ +/* + * 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.platform.http; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.camel.Consumer; +import org.apache.camel.Endpoint; +import org.apache.camel.Processor; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.platform.http.spi.PlatformHttpConsumer; +import org.apache.camel.component.platform.http.spi.PlatformHttpEngine; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.support.DefaultConsumer; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class PlatformHttpSharedPathRouteLifecycleTest { + + @Test + void stoppingSecondConsumerPreservesFirstConsumerOnSamePath() throws Exception { + RecordingPlatformHttpListener listener = new RecordingPlatformHttpListener(); + + try (DefaultCamelContext context = new DefaultCamelContext()) { + PlatformHttpComponent component = createComponent(listener, context); + context.addRoutes(sharedPathRoutes()); + context.start(); + + assertEquals(2, component.getHttpEndpoints().size()); + assertEquals(2, listener.registered.size()); + + context.getRouteController().stopRoute("shared-post"); + + assertEquals(1, component.getHttpEndpoints().size()); + assertEquals(1, listener.registered.size()); + HttpEndpointModel remaining = listener.registered.get(0); + assertEquals("/shared", remaining.getUri()); + assertEquals("GET", remaining.getVerbs()); + } + } + + @Test + void stoppingFirstConsumerPreservesSecondConsumerOnSamePath() throws Exception { + RecordingPlatformHttpListener listener = new RecordingPlatformHttpListener(); + + try (DefaultCamelContext context = new DefaultCamelContext()) { + PlatformHttpComponent component = createComponent(listener, context); + context.addRoutes(sharedPathRoutes()); + context.start(); + + context.getRouteController().stopRoute("shared-get"); + + assertEquals(1, component.getHttpEndpoints().size()); + assertEquals(1, listener.registered.size()); + HttpEndpointModel remaining = listener.registered.get(0); + assertEquals("/shared", remaining.getUri()); + assertEquals("POST", remaining.getVerbs()); + } + } + + @Test + void restartRouteAfterStopReRegistersEndpoint() throws Exception { + RecordingPlatformHttpListener listener = new RecordingPlatformHttpListener(); + + try (DefaultCamelContext context = new DefaultCamelContext()) { + PlatformHttpComponent component = createComponent(listener, context); + context.addRoutes(sharedPathRoutes()); + context.start(); + + context.getRouteController().stopRoute("shared-post"); + assertEquals(1, component.getHttpEndpoints().size()); + + context.getRouteController().startRoute("shared-post"); + assertEquals(2, component.getHttpEndpoints().size()); + assertEquals(2, listener.registered.size()); + } + } + + @Test + void removeHttpEndpointByUriRemovesAllConsumersOnPath() { + PlatformHttpComponent component = new PlatformHttpComponent(); + Consumer getConsumer = mock(Consumer.class); + Consumer postConsumer = mock(Consumer.class); + + component.addHttpEndpoint("/shared", "GET", null, null, getConsumer); + component.addHttpEndpoint("/shared", "POST", null, null, postConsumer); + + assertEquals(2, component.getHttpEndpoints().size()); + + component.removeHttpEndpoint("/shared"); + + assertTrue(component.getHttpEndpoints().isEmpty()); + } + + @Test + void removeHttpEndpointWithNullConsumerIsIgnored() { + PlatformHttpComponent component = new PlatformHttpComponent(); + Consumer routeConsumer = mock(Consumer.class); + + component.addHttpEndpoint("/static", null, null, null, null); + component.addHttpEndpoint("/shared", "GET", null, null, routeConsumer); + + component.removeHttpEndpoint((Consumer) null); + + assertEquals(2, component.getHttpEndpoints().size()); + } + + private static PlatformHttpComponent createComponent(RecordingPlatformHttpListener listener, DefaultCamelContext context) { + PlatformHttpComponent component = new PlatformHttpComponent(); + component.setEngine(new NoopEngine()); + component.addPlatformHttpListener(listener); + context.addComponent("platform-http", component); + return component; + } + + private static RouteBuilder sharedPathRoutes() { + return new RouteBuilder() { + @Override + public void configure() { + from("platform-http:/shared?httpMethodRestrict=GET") + .routeId("shared-get") + .setBody().constant("shared-get"); + from("platform-http:/shared?httpMethodRestrict=POST") + .routeId("shared-post") + .setBody().constant("shared-post"); + } + }; + } + + private static final class RecordingPlatformHttpListener implements PlatformHttpListener { + private final List registered = new ArrayList<>(); + + @Override + public void registerHttpEndpoint(HttpEndpointModel model) { + registered.add(model); + } + + @Override + public void unregisterHttpEndpoint(HttpEndpointModel model) { + registered.remove(model); + } + } + + private static final class NoopEngine implements PlatformHttpEngine { + @Override + public PlatformHttpConsumer createConsumer(PlatformHttpEndpoint platformHttpEndpoint, Processor processor) { + return new NoopPlatformHttpConsumer(platformHttpEndpoint, processor); + } + } + + private static final class NoopPlatformHttpConsumer extends DefaultConsumer implements PlatformHttpConsumer { + private NoopPlatformHttpConsumer(Endpoint endpoint, Processor processor) { + super(endpoint, processor); + } + + @Override + public PlatformHttpEndpoint getEndpoint() { + return (PlatformHttpEndpoint) super.getEndpoint(); + } + } +} diff --git a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/DefaultRestOpenapiProcessorStrategy.java b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/DefaultRestOpenapiProcessorStrategy.java index 43b6056540c81..9478ecad5ba85 100644 --- a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/DefaultRestOpenapiProcessorStrategy.java +++ b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/DefaultRestOpenapiProcessorStrategy.java @@ -34,6 +34,7 @@ import org.apache.camel.AsyncProducer; import org.apache.camel.CamelContext; import org.apache.camel.CamelContextAware; +import org.apache.camel.Consumer; import org.apache.camel.Endpoint; import org.apache.camel.Exchange; import org.apache.camel.NamedNode; @@ -74,7 +75,7 @@ public class DefaultRestOpenapiProcessorStrategy extends ServiceSupport private String component = "direct"; private String missingOperation; private String mockIncludePattern; - private final List uris = new ArrayList<>(); + private Consumer registeredPlatformHttpConsumer; @Override public void validateOpenApi(OpenAPI openAPI, String basePath, PlatformHttpConsumerAware platformHttpConsumer) @@ -150,8 +151,9 @@ public void validateOpenApi(OpenAPI openAPI, String basePath, PlatformHttpConsum } } } - phc.addHttpEndpoint(uri, verbs, consumes, produces, platformHttpConsumer.getPlatformHttpConsumer()); - uris.add(uri); + Consumer consumer = platformHttpConsumer.getPlatformHttpConsumer(); + phc.addHttpEndpoint(uri, verbs, consumes, produces, consumer); + registeredPlatformHttpConsumer = consumer; } } } @@ -452,9 +454,9 @@ protected void doStop() throws Exception { if (camelContext != null) { PlatformHttpComponent phc = (PlatformHttpComponent) camelContext.hasComponent("platform-http"); - if (phc != null) { - uris.forEach(phc::removeHttpEndpoint); - uris.clear(); + if (phc != null && registeredPlatformHttpConsumer != null) { + phc.removeHttpEndpoint(registeredPlatformHttpConsumer); + registeredPlatformHttpConsumer = null; } } } diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index 14da60b110620..f32216064891a 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -1624,3 +1624,22 @@ The `backOffMaxAttempts` option now bounds the attempts to start the delegated c The retry task previously also carried the default five second duration of its budget, which ended the task before the second attempt for any `backOffDelay` at or above the default of five seconds. A delegate that fails to start is therefore retried for longer than before, up to `backOffMaxAttempts` times. + +=== camel-platform-http - shared path endpoint registry + +`HttpEndpointModel` identity now includes the registered consumer reference, so multiple consumers can +share the same path with different HTTP methods without overwriting each other in +`PlatformHttpComponent#getHttpEndpoints()`. + +* `getHttpEndpoints()` and `getHttpManagementEndpoints()` may list multiple entries for the same URI + (one per consumer). Ordering follows registration order (`LinkedHashSet`) instead of URI sort order + (`TreeSet`). +* `DefaultPlatformHttpConsumer` removes its own registration on stop via + `removeHttpEndpoint(Consumer)` instead of removing every endpoint on the path. +* `removeHttpEndpoint(String)` still removes all registrations for a URI (for example bulk cleanup). + Prefer `removeHttpEndpoint(Consumer)` or `removeHttpEndpoint(String, Consumer)` when only one + registration should be removed. +* `HttpEndpointModel#compareTo` remains available and is consistent with the consumer-aware + `equals`/`hashCode` implementation. + +Stopping one `platform-http` route on a shared path no longer unregisters sibling consumers on that path.