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 d747bffa5745 CAMEL-25069: camel-core - Rest DSL: fix the follow-ups 
from the deep review (#27078)
d747bffa5745 is described below

commit d747bffa574504ae2ac895d15ced051b08037173
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 30 12:42:47 2026 +0200

    CAMEL-25069: camel-core - Rest DSL: fix the follow-ups from the deep review 
(#27078)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../java/org/apache/camel/impl/DefaultModel.java   | 151 +++++++++++---------
 .../apache/camel/model/RouteDefinitionHelper.java  |  11 +-
 .../camel/model/rest/RestBindingDefinition.java    |  25 +++-
 .../apache/camel/model/rest/RestDefinition.java    |  42 ++++--
 .../camel/reifier/rest/RestBindingReifier.java     |   9 ++
 .../camel/component/rest/RestDslEdgeCasesTest.java | 152 +++++++++++++++++++++
 .../camel/support/processor/RestBindingAdvice.java |  61 +++++++--
 .../processor/RestBindingAdviceFactory.java        |   4 +-
 .../processor/RestBindingConfiguration.java        |  12 ++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |  19 +++
 docs/user-manual/modules/ROOT/pages/rest-dsl.adoc  |   2 +
 11 files changed, 396 insertions(+), 92 deletions(-)

diff --git 
a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModel.java 
b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModel.java
index 49238b5d0ebd..48107f9cb2f4 100644
--- 
a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModel.java
+++ 
b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModel.java
@@ -225,81 +225,74 @@ public class DefaultModel implements Model {
                         }
                     }
                 }
+                // how many rest services call each direct route
+                Map<String, Integer> directUsage = new HashMap<>();
+                for (RouteDefinition r : allRoutes) {
+                    ToDefinition to = restRouteToInline(r);
+                    if (to != null) {
+                        directUsage.merge(to.getEndpointUri(), 1, 
Integer::sum);
+                    }
+                }
                 for (RouteDefinition r : allRoutes) {
                     // loop all rest routes
                     FromDefinition from = r.getInput();
-                    if (from != null && !r.isInlined()) {
-                        // only attempt to inline if not already inlined
-                        String uri = from.getEndpointUri();
-                        if (uri != null && uri.startsWith("rest:")) {
-                            // find first EIP in the outputs (skip abstract 
which are onException/intercept
-                            // etc)
-                            ToDefinition to = null;
-                            for (ProcessorDefinition<?> def : r.getOutputs()) {
-                                if (def.isAbstract()) {
-                                    continue;
+                    ToDefinition to = restRouteToInline(r);
+                    if (to != null) {
+                        String toUri = to.getEndpointUri();
+                        RouteDefinition toBeInlined = directs.get(toUri);
+                        // a route used by more than one rest service is not 
inlined (its outputs can only
+                        // belong to one route), so the rest services call it
+                        if (toBeInlined != null && 
directUsage.getOrDefault(toUri, 0) == 1) {
+                            toBeRemoved.add(toBeInlined);
+                            // inline the source loc:line as starting from 
this direct input
+                            FromDefinition inlinedFrom = 
toBeInlined.getInput();
+                            from.setLocation(inlinedFrom.getLocation());
+                            from.setLineNumber(inlinedFrom.getLineNumber());
+                            // inline by replacing the outputs (preserve all 
abstracts such as interceptors)
+                            List<ProcessorDefinition<?>> toBeRemovedOut = new 
ArrayList<>();
+                            for (ProcessorDefinition<?> out : r.getOutputs()) {
+                                // should be removed if to be added via inlined
+                                boolean remove = 
toBeInlined.getOutputs().stream().anyMatch(o -> o == out);
+                                if (!remove) {
+                                    remove = !out.isAbstract(); // remove all 
non abstract
                                 }
-                                if (def instanceof ToDefinition toDefinition) {
-                                    to = toDefinition;
+                                if (remove) {
+                                    toBeRemovedOut.add(out);
                                 }
-                                break;
                             }
-                            if (to != null) {
-                                String toUri = to.getEndpointUri();
-                                RouteDefinition toBeInlined = 
directs.get(toUri);
-                                if (toBeInlined != null) {
-                                    toBeRemoved.add(toBeInlined);
-                                    // inline the source loc:line as starting 
from this direct input
-                                    FromDefinition inlinedFrom = 
toBeInlined.getInput();
-                                    
from.setLocation(inlinedFrom.getLocation());
-                                    
from.setLineNumber(inlinedFrom.getLineNumber());
-                                    // inline by replacing the outputs 
(preserve all abstracts such as interceptors)
-                                    List<ProcessorDefinition<?>> 
toBeRemovedOut = new ArrayList<>();
-                                    for (ProcessorDefinition<?> out : 
r.getOutputs()) {
-                                        // should be removed if to be added 
via inlined
-                                        boolean remove = 
toBeInlined.getOutputs().stream().anyMatch(o -> o == out);
-                                        if (!remove) {
-                                            remove = !out.isAbstract(); // 
remove all non abstract
-                                        }
-                                        if (remove) {
-                                            toBeRemovedOut.add(out);
-                                        }
-                                    }
-                                    r.getOutputs().removeAll(toBeRemovedOut);
-                                    
r.getOutputs().addAll(toBeInlined.getOutputs());
-                                    // inlined outputs should have re-assigned 
parent to this route
-                                    r.getOutputs().forEach(o -> 
o.setParent(r));
-                                    // and copy over various configurations
-                                    if (toBeInlined.getRouteId() != null) {
-                                        r.setId(toBeInlined.getRouteId());
-                                    }
-                                    
r.setNodePrefixId(toBeInlined.getNodePrefixId());
-                                    r.setGroup(toBeInlined.getGroup());
-                                    
r.setAutoStartup(toBeInlined.getAutoStartup());
-                                    r.setDelayer(toBeInlined.getDelayer());
-                                    r.setInputType(toBeInlined.getInputType());
-                                    
r.setOutputType(toBeInlined.getOutputType());
-                                    r.setLogMask(toBeInlined.getLogMask());
-                                    
r.setMessageHistory(toBeInlined.getMessageHistory());
-                                    if (toBeInlined.getStreamCache() != null) {
-                                        // keep stream caching from the rest 
verb unless the inlined route sets it
-                                        
r.setStreamCache(toBeInlined.getStreamCache());
-                                    }
-                                    r.setTrace(toBeInlined.getTrace());
-                                    
r.setStartupOrder(toBeInlined.getStartupOrder());
-                                    
r.setRoutePolicyRef(toBeInlined.getRoutePolicyRef());
-                                    
r.setRouteConfigurationId(toBeInlined.getRouteConfigurationId());
-                                    
r.setRoutePolicies(toBeInlined.getRoutePolicies());
-                                    
r.setShutdownRoute(toBeInlined.getShutdownRoute());
-                                    
r.setShutdownRunningTask(toBeInlined.getShutdownRunningTask());
-                                    
r.setErrorHandlerRef(toBeInlined.getErrorHandlerRef());
-                                    
r.setPrecondition(toBeInlined.getPrecondition());
-                                    if 
(toBeInlined.isErrorHandlerFactorySet()) {
-                                        
r.setErrorHandler(toBeInlined.getErrorHandler());
-                                    }
-                                    r.markInlined();
-                                }
+                            r.getOutputs().removeAll(toBeRemovedOut);
+                            r.getOutputs().addAll(toBeInlined.getOutputs());
+                            // inlined outputs should have re-assigned parent 
to this route
+                            r.getOutputs().forEach(o -> o.setParent(r));
+                            // and copy over various configurations
+                            if (toBeInlined.getRouteId() != null) {
+                                r.setId(toBeInlined.getRouteId());
                             }
+                            r.setNodePrefixId(toBeInlined.getNodePrefixId());
+                            r.setGroup(toBeInlined.getGroup());
+                            r.setAutoStartup(toBeInlined.getAutoStartup());
+                            r.setDelayer(toBeInlined.getDelayer());
+                            r.setInputType(toBeInlined.getInputType());
+                            r.setOutputType(toBeInlined.getOutputType());
+                            r.setLogMask(toBeInlined.getLogMask());
+                            
r.setMessageHistory(toBeInlined.getMessageHistory());
+                            if (toBeInlined.getStreamCache() != null) {
+                                // keep stream caching from the rest verb 
unless the inlined route sets it
+                                r.setStreamCache(toBeInlined.getStreamCache());
+                            }
+                            r.setTrace(toBeInlined.getTrace());
+                            r.setStartupOrder(toBeInlined.getStartupOrder());
+                            
r.setRoutePolicyRef(toBeInlined.getRoutePolicyRef());
+                            
r.setRouteConfigurationId(toBeInlined.getRouteConfigurationId());
+                            r.setRoutePolicies(toBeInlined.getRoutePolicies());
+                            r.setShutdownRoute(toBeInlined.getShutdownRoute());
+                            
r.setShutdownRunningTask(toBeInlined.getShutdownRunningTask());
+                            
r.setErrorHandlerRef(toBeInlined.getErrorHandlerRef());
+                            r.setPrecondition(toBeInlined.getPrecondition());
+                            if (toBeInlined.isErrorHandlerFactorySet()) {
+                                
r.setErrorHandler(toBeInlined.getErrorHandler());
+                            }
+                            r.markInlined();
                         }
                     }
                 }
@@ -323,6 +316,28 @@ public class DefaultModel implements Model {
         }
     }
 
+    /**
+     * The to of a rest route (not already inlined) that is a candidate for 
inlining, which is the first EIP in the
+     * outputs (skipping abstracts such as onException and intercept)
+     */
+    private static ToDefinition restRouteToInline(RouteDefinition r) {
+        FromDefinition from = r.getInput();
+        if (from == null || r.isInlined()) {
+            return null;
+        }
+        String uri = from.getEndpointUri();
+        if (uri == null || !uri.startsWith("rest:")) {
+            return null;
+        }
+        for (ProcessorDefinition<?> def : r.getOutputs()) {
+            if (def.isAbstract()) {
+                continue;
+            }
+            return def instanceof ToDefinition toDefinition ? toDefinition : 
null;
+        }
+        return null;
+    }
+
     @Override
     public void addRouteDefinition(RouteDefinition routeDefinition) throws 
Exception {
         addRouteDefinitions(Collections.singletonList(routeDefinition));
diff --git 
a/core/camel-core-model/src/main/java/org/apache/camel/model/RouteDefinitionHelper.java
 
b/core/camel-core-model/src/main/java/org/apache/camel/model/RouteDefinitionHelper.java
index df38a7ac03c4..658af1592e8d 100644
--- 
a/core/camel-core-model/src/main/java/org/apache/camel/model/RouteDefinitionHelper.java
+++ 
b/core/camel-core-model/src/main/java/org/apache/camel/model/RouteDefinitionHelper.java
@@ -139,6 +139,15 @@ public final class RouteDefinitionHelper {
         // generated ids
         Set<String> customIds = new HashSet<>();
 
+        // the custom ids of the routes that are not generated from a rest 
verb: a verb with the same id as such a route
+        // gets a generated id instead (a duplicate id between verbs is kept, 
so it is reported as a duplicate route id)
+        Set<String> routeIds = new HashSet<>();
+        for (final RouteDefinition route : routes) {
+            if (route.hasCustomIdAssigned()) {
+                
routeIds.add(context.resolvePropertyPlaceholders(route.getId()));
+            }
+        }
+
         for (final RouteDefinition route : routes) {
             // if there was a custom id assigned, then make sure to support
             // property placeholders
@@ -157,7 +166,7 @@ public final class RouteDefinitionHelper {
                     VerbDefinition verb = findVerbDefinition(context, rest, 
route.getInput().getEndpointUri());
                     if (verb != null) {
                         String id = 
context.resolvePropertyPlaceholders(verb.getId());
-                        if (verb.hasCustomIdAssigned() && 
ObjectHelper.isNotEmpty(id) && !customIds.contains(id)) {
+                        if (verb.hasCustomIdAssigned() && 
ObjectHelper.isNotEmpty(id) && !routeIds.contains(id)) {
                             route.setId(id);
                             customIds.add(id);
                         }
diff --git 
a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestBindingDefinition.java
 
b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestBindingDefinition.java
index 8b17d04cf5ae..b50b51f472d9 100644
--- 
a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestBindingDefinition.java
+++ 
b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestBindingDefinition.java
@@ -53,6 +53,8 @@ public class RestBindingDefinition extends 
OptionalIdentifiedDefinition<RestBind
     private Map<String, String> responseCodes;
     @XmlTransient
     private Set<String> responseHeaders;
+    @XmlTransient
+    private Map<String, Set<String>> responseCodeHeaders;
 
     @XmlAttribute
     @Metadata(description = "The content type the REST service accepts 
(consumes) as input, such as application/xml or application/json.")
@@ -170,7 +172,7 @@ public class RestBindingDefinition extends 
OptionalIdentifiedDefinition<RestBind
     }
 
     /**
-     * Adds a response code
+     * Adds a response header that is required on every response
      */
     public void addResponseHeader(String headerName) {
         // content-type header should be skipped
@@ -187,6 +189,27 @@ public class RestBindingDefinition extends 
OptionalIdentifiedDefinition<RestBind
         return responseHeaders;
     }
 
+    /**
+     * Adds a response header that is required on the responses of the given 
response code
+     */
+    public void addResponseHeader(String code, String headerName) {
+        // content-type header should be skipped
+        if ("content-type".equalsIgnoreCase(headerName)) {
+            return;
+        }
+        if (responseCodeHeaders == null) {
+            responseCodeHeaders = new HashMap<>();
+        }
+        responseCodeHeaders.computeIfAbsent(code, k -> new 
HashSet<>()).add(headerName);
+    }
+
+    /**
+     * The response headers that are required, per response code
+     */
+    public Map<String, Set<String>> getResponseCodeHeaders() {
+        return responseCodeHeaders;
+    }
+
     /**
      * Adds a required HTTP header
      *
diff --git 
a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestDefinition.java
 
b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestDefinition.java
index 0537f34a160b..5661149a7f9f 100644
--- 
a/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestDefinition.java
+++ 
b/core/camel-core-model/src/main/java/org/apache/camel/model/rest/RestDefinition.java
@@ -1198,6 +1198,20 @@ public class RestDefinition extends 
OptionalIdentifiedDefinition<RestDefinition>
             } else {
                 
binding.setEnableNoContentResponse(getEnableNoContentResponse());
             }
+            // the body parameter from the type must be added before the 
parameters are registered on the binding,
+            // so a required body is enforced
+            if (verb.getType() != null) {
+                String bodyType = parseText(camelContext, verb.getType());
+                ParamDefinition param = findBodyParam(verb);
+                if (param == null) {
+                    // must be body type and set the model class as data type
+                    
param(verb).name(RestParamType.body.name()).type(RestParamType.body).dataType(bodyType).endParam();
+                } else {
+                    // must be body type and set the model class as data type
+                    param.type(RestParamType.body).dataType(bodyType);
+                }
+            }
+
             for (ParamDefinition param : verb.getParams()) {
                 // register all the default values for the query and header 
parameters
                 RestParamType type = param.getType();
@@ -1228,7 +1242,8 @@ public class RestDefinition extends 
OptionalIdentifiedDefinition<RestDefinition>
                 if (rm.getHeaders() != null) {
                     for (var header : rm.getHeaders()) {
                         String name = parseText(camelContext, 
header.getName());
-                        binding.addResponseHeader(name);
+                        // the headers are only required on the responses of 
this code
+                        binding.addResponseHeader(rm.getCode(), name);
                     }
                 }
             }
@@ -1294,18 +1309,6 @@ public class RestDefinition extends 
OptionalIdentifiedDefinition<RestDefinition>
                 uriTemplating(camelContext, verb, allPath, false);
             }
 
-            if (verb.getType() != null) {
-                String bodyType = parseText(camelContext, verb.getType());
-                ParamDefinition param = findParam(verb, 
RestParamType.body.name());
-                if (param == null) {
-                    // must be body type and set the model class as data type
-                    
param(verb).name(RestParamType.body.name()).type(RestParamType.body).dataType(bodyType).endParam();
-                } else {
-                    // must be body type and set the model class as data type
-                    param.type(RestParamType.body).dataType(bodyType);
-                }
-            }
-
             // create the from endpoint uri which is using the rest component
             String from = buildFromUri(camelContext, verb);
 
@@ -1416,6 +1419,19 @@ public class RestDefinition extends 
OptionalIdentifiedDefinition<RestDefinition>
         return parseText(camelContext, answer);
     }
 
+    /**
+     * The body parameter of the verb, which is the parameter of type body 
(whatever its name), or else the parameter
+     * named body
+     */
+    private ParamDefinition findBodyParam(VerbDefinition verb) {
+        for (ParamDefinition param : verb.getParams()) {
+            if (param.getType() == RestParamType.body) {
+                return param;
+            }
+        }
+        return findParam(verb, RestParamType.body.name());
+    }
+
     private ParamDefinition findParam(VerbDefinition verb, String name) {
         for (ParamDefinition param : verb.getParams()) {
             if (name.equals(param.getName())) {
diff --git 
a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/rest/RestBindingReifier.java
 
b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/rest/RestBindingReifier.java
index 6fbe8c8df71c..0df14c8af849 100644
--- 
a/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/rest/RestBindingReifier.java
+++ 
b/core/camel-core-reifier/src/main/java/org/apache/camel/reifier/rest/RestBindingReifier.java
@@ -16,6 +16,10 @@
  */
 package org.apache.camel.reifier.rest;
 
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
 import org.apache.camel.Route;
 import org.apache.camel.model.rest.RestBindingDefinition;
 import org.apache.camel.model.rest.RestBindingMode;
@@ -85,6 +89,11 @@ public class RestBindingReifier extends AbstractReifier {
         rbc.setOutTypeClass(definition.getOutTypeClass());
         rbc.setResponseCodes(parseMap(definition.getResponseCodes()));
         rbc.setResponseHeaders(parseSet(definition.getResponseHeaders()));
+        if (definition.getResponseCodeHeaders() != null) {
+            Map<String, Set<String>> headers = new HashMap<>();
+            definition.getResponseCodeHeaders().forEach((k, v) -> 
headers.put(k, parseSet(v)));
+            rbc.setResponseCodeHeaders(headers);
+        }
 
         // use factory to create advice
         return RestBindingAdviceFactory.build(camelContext, rbc);
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/component/rest/RestDslEdgeCasesTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/component/rest/RestDslEdgeCasesTest.java
index bb3f44495867..e6e6b3557b92 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/component/rest/RestDslEdgeCasesTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/component/rest/RestDslEdgeCasesTest.java
@@ -23,10 +23,12 @@ import org.apache.camel.Exchange;
 import org.apache.camel.builder.RouteBuilder;
 import org.apache.camel.model.RouteDefinition;
 import org.apache.camel.model.rest.RestBindingMode;
+import org.apache.camel.model.rest.RestParamType;
 import org.apache.camel.spi.Registry;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 public class RestDslEdgeCasesTest extends ContextTestSupport {
 
@@ -128,4 +130,154 @@ public class RestDslEdgeCasesTest extends 
ContextTestSupport {
         
assertThat(out.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isNull();
         assertMockEndpointsSatisfied();
     }
+
+    @Test
+    public void testClientResponseValidationWithBindingOff() throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
restConfiguration().host("localhost").clientResponseValidation(true);
+                rest("/h").get()
+                        
.responseMessage().code(200).header("X-Id").endHeader().endResponseMessage()
+                        .to("direct:h");
+
+                from("direct:h").setBody(constant("Hello"));
+            }
+        });
+        context.start();
+
+        // the response is validated also when binding is off
+        Exchange out = template.request("seda:get-h", e -> {
+        });
+        
assertThat(out.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(500);
+        assertThat(out.getMessage().getBody(String.class)).isEqualTo("Some of 
the response HTTP headers are missing.");
+    }
+
+    @Test
+    public void testClientResponseValidationHeadersPerResponseCode() throws 
Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
restConfiguration().host("localhost").clientResponseValidation(true);
+                rest("/c").get()
+                        
.responseMessage().code(200).header("X-Id").endHeader().endResponseMessage()
+                        .responseMessage().code(404).message("Not 
found").endResponseMessage()
+                        .to("direct:c");
+
+                from("direct:c")
+                        .choice()
+                        .when(header("found"))
+                        .setHeader("X-Id", 
constant("123")).setBody(constant("Found"))
+                        .otherwise()
+                        .setHeader(Exchange.HTTP_RESPONSE_CODE, 
constant(404)).setBody(constant("Not found"));
+            }
+        });
+        context.start();
+
+        // X-Id is only required on the 200 response
+        Exchange out = template.request("seda:get-c", e -> {
+        });
+        
assertThat(out.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(404);
+        assertThat(out.getMessage().getBody(String.class)).isEqualTo("Not 
found");
+
+        out = template.request("seda:get-c", e -> 
e.getMessage().setHeader("found", true));
+        
assertThat(out.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isNull();
+        assertThat(out.getMessage().getBody(String.class)).isEqualTo("Found");
+    }
+
+    @Test
+    public void testTypeRequiresBody() throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
restConfiguration().host("localhost").clientRequestValidation(true);
+                rest("/t").post().type(String.class).to("mock:t");
+            }
+        });
+        context.start();
+
+        // the body parameter from type(...) is required (by default), so a 
missing body is a bad request
+        getMockEndpoint("mock:t").expectedMessageCount(0);
+        Exchange out = template.request("seda:post-t", e -> 
e.getMessage().setBody(null));
+        
assertThat(out.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isEqualTo(400);
+        assertThat(out.getMessage().getBody(String.class)).isEqualTo("The 
request body is missing.");
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    public void testTypeWithOptionalBodyParamOfAnotherName() throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                
restConfiguration().host("localhost").clientRequestValidation(true);
+                rest("/o").post().type(String.class)
+                        
.param().name("payload").type(RestParamType.body).required(false).endParam()
+                        .to("mock:o");
+            }
+        });
+        context.start();
+
+        // the body parameter (whatever its name) is not required, so a 
missing body is accepted
+        getMockEndpoint("mock:o").expectedMessageCount(1);
+        Exchange out = template.request("seda:post-o", e -> 
e.getMessage().setBody(null));
+        
assertThat(out.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE)).isNull();
+        assertMockEndpointsSatisfied();
+    }
+
+    @Test
+    public void testVerbIdSameAsRouteId() throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                restConfiguration().host("localhost").inlineRoutes(false);
+                rest("/v").get().id("hello").to("direct:hello");
+                
from("direct:hello").routeId("hello").transform().constant("Hello");
+            }
+        });
+        context.start();
+
+        // the route keeps its id, and the verb gets a generated id
+        
assertThat(context.getRoute("hello").getEndpoint().getEndpointUri()).isEqualTo("direct://hello");
+        assertThat(context.getRoutes()).hasSize(2);
+    }
+
+    @Test
+    public void testDuplicateVerbId() throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                restConfiguration().host("localhost");
+                rest("/d")
+                        .get("/a").id("dup").to("mock:a")
+                        .get("/b").id("dup").to("mock:b");
+            }
+        });
+
+        // the duplicate id is reported (and not replaced by a generated id)
+        assertThatThrownBy(() -> 
context.start()).hasStackTraceContaining("Duplicate id detected: dup");
+    }
+
+    @Test
+    public void testSameDirectUsedByTwoRests() throws Exception {
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                restConfiguration().host("localhost").inlineRoutes(true);
+                rest("/a").get().to("direct:x");
+                rest("/b").get().to("direct:x");
+
+                from("direct:x").routeId("x").to("mock:x");
+            }
+        });
+        context.start();
+
+        // the direct route is used by two rest services, so it is not inlined 
(into both of them)
+        assertThat(context.getRouteDefinition("x")).isNotNull();
+        assertThat(context.getRouteDefinitions()).hasSize(3);
+
+        getMockEndpoint("mock:x").expectedBodiesReceivedInAnyOrder("A", "B");
+        template.sendBody("seda:get-a", "A");
+        template.sendBody("seda:get-b", "B");
+        assertMockEndpointsSatisfied();
+    }
 }
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingAdvice.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingAdvice.java
index 3edc8138a30f..7371f6832412 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingAdvice.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingAdvice.java
@@ -17,6 +17,7 @@
 package org.apache.camel.support.processor;
 
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
@@ -82,6 +83,7 @@ public class RestBindingAdvice extends ServiceSupport 
implements CamelInternalPr
     private final Set<String> requiredHeaders;
     private final Map<String, String> responseCodes;
     private final Set<String> responseHeaders;
+    private Map<String, Set<String>> responseCodeHeaders;
 
     /**
      * Use {@link RestBindingAdviceFactory} to create.
@@ -338,6 +340,18 @@ public class RestBindingAdvice extends ServiceSupport 
implements CamelInternalPr
     }
 
     private void marshal(Exchange exchange, Map<String, Object> state) {
+        doMarshal(exchange, state);
+
+        // perform client response validation (also when the response body was 
not marshalled, such as when binding
+        // is off, the body is empty or binding was skipped for an error code)
+        RestClientResponseValidator.ValidationError error = 
doClientResponseValidation(exchange);
+        if (error != null) {
+            exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 
error.statusCode());
+            exchange.getMessage().setBody(error.body());
+        }
+    }
+
+    private void doMarshal(Exchange exchange, Map<String, Object> state) {
         // only marshal if there was no exception
         if (exchange.getException() != null) {
             return;
@@ -467,13 +481,6 @@ public class RestBindingAdvice extends ServiceSupport 
implements CamelInternalPr
                     exchange, e.getMessage(), e);
             exchange.setException(e);
         }
-
-        // perform client response validation
-        RestClientResponseValidator.ValidationError error = 
doClientResponseValidation(exchange);
-        if (error != null) {
-            exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 
error.statusCode());
-            exchange.getMessage().setBody(error.body());
-        }
     }
 
     /**
@@ -607,12 +614,50 @@ public class RestBindingAdvice extends ServiceSupport 
implements CamelInternalPr
     public RestClientResponseValidator.ValidationError 
doClientResponseValidation(Exchange exchange) {
         if (clientResponseValidation && clientResponseValidator != null && 
!exchange.isFailed()) {
             RestClientResponseValidator.ValidationContext vc = new 
RestClientResponseValidator.ValidationContext(
-                    consumes, produces, responseCodes, responseHeaders);
+                    consumes, produces, responseCodes, 
resolveResponseHeaders(exchange));
             return clientResponseValidator.validate(exchange, vc);
         }
         return null;
     }
 
+    /**
+     * The response headers that are required on the response, which are the 
headers of the response message of the
+     * response code (or the default response message when the code has none)
+     */
+    private Set<String> resolveResponseHeaders(Exchange exchange) {
+        if (responseCodeHeaders == null) {
+            return responseHeaders;
+        }
+        String code = 
exchange.getMessage().getHeader(Exchange.HTTP_RESPONSE_CODE, "200", 
String.class);
+        Set<String> headers = responseCodeHeaders.get(code);
+        if (headers == null && (responseCodes == null || 
!responseCodes.containsKey(code))) {
+            headers = responseCodeHeaders.get("default");
+        }
+        if (responseHeaders == null || responseHeaders.isEmpty()) {
+            return headers;
+        }
+        if (headers == null) {
+            return responseHeaders;
+        }
+        Set<String> answer = new HashSet<>(responseHeaders);
+        answer.addAll(headers);
+        return answer;
+    }
+
+    /**
+     * The response headers that are required, per response code
+     */
+    public Map<String, Set<String>> getResponseCodeHeaders() {
+        return responseCodeHeaders;
+    }
+
+    /**
+     * Sets the response headers that are required, per response code
+     */
+    public void setResponseCodeHeaders(Map<String, Set<String>> 
responseCodeHeaders) {
+        this.responseCodeHeaders = responseCodeHeaders;
+    }
+
     @Override
     protected void doStart() throws Exception {
         ServiceHelper.startService(jsonUnmarshal, xmlUnmarshal, jsonMarshal, 
xmlMarshal);
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingAdviceFactory.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingAdviceFactory.java
index 70977c46108c..cd6d58c6fe21 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingAdviceFactory.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingAdviceFactory.java
@@ -135,12 +135,14 @@ public class RestBindingAdviceFactory {
             responseValidator = 
lookupRestClientResponseValidator(camelContext);
         }
 
-        return new RestBindingAdvice(
+        RestBindingAdvice advice = new RestBindingAdvice(
                 camelContext, json, xml, outJson, outXml,
                 bc.getConsumes(), bc.getProduces(), mode, 
bc.isSkipBindingOnErrorCode(), bc.isClientRequestValidation(),
                 bc.isClientResponseValidation(), bc.isEnableCORS(), 
bc.isEnableNoContentResponse(), bc.getCorsHeaders(),
                 bc.getQueryDefaultValues(), bc.getQueryAllowedValues(), 
bc.isRequiredBody(), bc.getRequiredQueryParameters(),
                 bc.getRequiredHeaders(), bc.getResponseCodes(), 
bc.getResponseHeaders(), requestValidator, responseValidator);
+        advice.setResponseCodeHeaders(bc.getResponseCodeHeaders());
+        return advice;
     }
 
     protected static void setupJson(
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingConfiguration.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingConfiguration.java
index 4a9b55ab5eee..a7ed7f30907f 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingConfiguration.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/processor/RestBindingConfiguration.java
@@ -45,6 +45,7 @@ public class RestBindingConfiguration {
     private Class<?> outTypeClass;
     private Map<String, String> responseCodes;
     private Set<String> responseHeaders;
+    private Map<String, Set<String>> responseCodeHeaders;
 
     public String getConsumes() {
         return consumes;
@@ -213,4 +214,15 @@ public class RestBindingConfiguration {
     public void setResponseHeaders(Set<String> responseHeaders) {
         this.responseHeaders = responseHeaders;
     }
+
+    /**
+     * The response headers that are required, per response code
+     */
+    public Map<String, Set<String>> getResponseCodeHeaders() {
+        return responseCodeHeaders;
+    }
+
+    public void setResponseCodeHeaders(Map<String, Set<String>> 
responseCodeHeaders) {
+        this.responseCodeHeaders = responseCodeHeaders;
+    }
 }
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 a9d0ab1df4f3..3b55ad81d6ef 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
@@ -375,6 +375,25 @@ mode is only set with `restConfiguration()`, as it already 
did when it is set on
 With `clientRequestValidation` enabled, such a verb now rejects a request with 
another `Content-Type` (415) or
 `Accept` (406) header.
 
+=== Rest DSL - client validation, verb ids and inlined routes
+
+- `clientResponseValidation` now also validates the response when the binding 
mode is `off`, the response has no
+  body, or binding was skipped because of an error code 
(`skipBindingOnErrorCode`). Before, the response was only
+  validated after the body was marshalled.
+- The headers of a `responseMessage` are only required on the responses with 
that code (or on the responses of the
+  `default` response message when the code has none). Before, the headers of 
all the response messages were required
+  on every response.
+- A verb with `type` gets a body parameter that is required (unless the body 
parameter, whatever its name, is
+  declared with `required=false`).
+  With `clientRequestValidation` enabled, a request without a body is now 
rejected (400), as the body was already
+  marked as required in the OpenAPI specification.
+- Two verbs with the same `id` now fail with a duplicate route id error. 
Before, the second verb silently got a
+  generated route id. A verb with the same `id` as a route that is not a rest 
verb (such as the `direct` route it
+  calls) still gets a generated route id, as before.
+- A `direct` route that more than one rest service calls is no longer inlined 
(with `inlineRoutes` enabled, which is
+  the default). It stays a route of its own, and the rest services call it. 
Before, its outputs were inlined into all
+  the rest services, which then shared the same EIP definitions.
+
 === CamelEvent JSON serialization
 
 `CamelEvent` now provides `asJSon()` and `toJSon(int indent)` with default 
implementations that
diff --git a/docs/user-manual/modules/ROOT/pages/rest-dsl.adoc 
b/docs/user-manual/modules/ROOT/pages/rest-dsl.adoc
index c2ee39f773d2..cf037a63c6aa 100644
--- a/docs/user-manual/modules/ROOT/pages/rest-dsl.adoc
+++ b/docs/user-manual/modules/ROOT/pages/rest-dsl.adoc
@@ -325,6 +325,8 @@ WARNING: When using inline-routes, then each REST endpoint 
should link 1:1 to a
 The linked _direct_ routes are inlined and therefore does not **exists** as 
independent routes, and
 they cannot be called from other regular Camel routes. In other words the 
inlined routes are essentially
 moved inside the rest-dsl and does not exist as a route. See more detils 
further below.
+A `direct` route that is linked from more than one REST endpoint is not 
inlined, but stays a route of its own
+that the REST endpoints call.
 
 To do this you *MUST* use `direct` endpoints, and each endpoint must be unique 
name per service.
 And the option `inlineRoutes` must be enabled.

Reply via email to