diff --git a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiProcessor.java b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiProcessor.java index 29b6bd222b4bb..8304028d1eb7c 100644 --- a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiProcessor.java +++ b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/RestOpenApiProcessor.java @@ -118,7 +118,6 @@ public boolean process(Exchange exchange, AsyncCallback callback) { // binding mode RestConfiguration config = camelContext.getRestConfiguration(); - RestConfiguration.RestBindingMode bindingMode = config.getBindingMode(); // map path-parameters from operation to camel headers HttpHelper.evalPlaceholders(exchange.getMessage().getHeaders(), uri, rcp.getConsumerPath()); @@ -178,7 +177,7 @@ private RestBindingAdvice createRestBinding(Operation o) throws Exception { bc.setBindingMode(mode.name()); bc.setEnableCORS(config.isEnableCORS()); bc.setCorsHeaders(config.getCorsHeaders()); - bc.setClientRequestValidation(config.isClientRequestValidation()); + bc.setClientRequestValidation(config.isClientRequestValidation() || endpoint.isClientRequestValidation()); bc.setEnableNoContentResponse(config.isEnableNoContentResponse()); bc.setSkipBindingOnErrorCode(config.isSkipBindingOnErrorCode()); diff --git a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/validator/DefaultRequestValidator.java b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/validator/DefaultRequestValidator.java index 7a3474db65553..af20dc487bf4f 100644 --- a/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/validator/DefaultRequestValidator.java +++ b/components/camel-rest-openapi/src/main/java/org/apache/camel/component/rest/openapi/validator/DefaultRequestValidator.java @@ -84,11 +84,13 @@ public Set validate(Exchange exchange, RestOpenApiOperation o) { Object body = message.getBody(); if (body != null) { String text = MessageHelper.extractBodyAsString(message); - JsonMapper om = new JsonMapper(); - try { - om.readTree(text); - } catch (Exception e) { - validationErrors.add("Unable to parse JSON"); + if (text != null) { + JsonMapper om = new JsonMapper(); + try { + om.readTree(text); + } catch (Exception e) { + validationErrors.add("Unable to parse JSON"); + } } } } diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/RestClientRequestValidator.java b/core/camel-api/src/main/java/org/apache/camel/spi/RestClientRequestValidator.java new file mode 100644 index 0000000000000..a07ed516eeb19 --- /dev/null +++ b/core/camel-api/src/main/java/org/apache/camel/spi/RestClientRequestValidator.java @@ -0,0 +1,70 @@ +/* + * 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.spi; + +import java.util.Map; +import java.util.Set; + +import org.apache.camel.Exchange; + +/** + * Used for validating incoming client requests with Camel Rest DSL. + *

+ * This allows to plugin different validators. + */ +public interface RestClientRequestValidator { + + String FACTORY = "rest-client-validator-factory"; + + /** + * Validation error + * + * @param statusCode to use a specific HTTP status code for this validation error + * @param body to use a specific message body for this validation error + */ + record ValidationError(int statusCode, String body) { + } + + /** + * Validation context to use during validation + * + * @param consumes content-type this service can consume + * @param produces content-type this service can produce + * @param requiredBody whether the message body is required + * @param queryDefaultValues default values for HTTP query parameters + * @param queryAllowedValues allowed values for HTTP query parameters + * @param requiredQueryParameters names of HTTP query parameters that are required + * @param requiredHeaders names of HTTP headers parameters that are required + */ + record ValidationContext(String consumes, String produces, + boolean requiredBody, + Map queryDefaultValues, + Map queryAllowedValues, + Set requiredQueryParameters, + Set requiredHeaders) { + } + + /** + * Validates the incoming client request + * + * @param exchange the current exchange + * @param validationContent validation context + * @return the validation error, or null if success + */ + ValidationError validate(Exchange exchange, ValidationContext validationContent); + +} diff --git a/core/camel-support/src/main/java/org/apache/camel/support/processor/DefaultRestClientRequestValidator.java b/core/camel-support/src/main/java/org/apache/camel/support/processor/DefaultRestClientRequestValidator.java new file mode 100644 index 0000000000000..756cfe0a426f4 --- /dev/null +++ b/core/camel-support/src/main/java/org/apache/camel/support/processor/DefaultRestClientRequestValidator.java @@ -0,0 +1,103 @@ +/* + * 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.support.processor; + +import java.util.Arrays; + +import org.apache.camel.Exchange; +import org.apache.camel.spi.RestClientRequestValidator; +import org.apache.camel.support.ExchangeHelper; +import org.apache.camel.support.MessageHelper; +import org.apache.camel.util.ObjectHelper; +import org.apache.camel.util.json.DeserializationException; +import org.apache.camel.util.json.Jsoner; + +import static org.apache.camel.support.http.RestUtil.isValidOrAcceptedContentType; + +public class DefaultRestClientRequestValidator implements RestClientRequestValidator { + + @Override + public ValidationError validate(Exchange exchange, ValidationContext validationContext) { + String contentType = ExchangeHelper.getContentType(exchange); + + // check if the content-type is accepted according to consumes + if (!isValidOrAcceptedContentType(validationContext.consumes(), contentType)) { + return new ValidationError(415, null); + } + // check if what is produces is accepted by the client + String accept = exchange.getMessage().getHeader("Accept", String.class); + if (!isValidOrAcceptedContentType(validationContext.produces(), accept)) { + return new ValidationError(406, null); + } + // check for required query parameters + if (validationContext.requiredQueryParameters() != null + && !exchange.getIn().getHeaders().keySet().containsAll(validationContext.requiredQueryParameters())) { + // this is a bad request, the client did not include some required query parameters + return new ValidationError(400, "Some of the required query parameters are missing."); + } + // check for required http headers + if (validationContext.requiredHeaders() != null + && !exchange.getIn().getHeaders().keySet().containsAll(validationContext.requiredHeaders())) { + // this is a bad request, the client did not include some required query parameters + return new ValidationError(400, "Some of the required HTTP headers are missing."); + } + // allowed values for query/header parameters + if (validationContext.queryAllowedValues() != null) { + for (var e : validationContext.queryAllowedValues().entrySet()) { + String k = e.getKey(); + Object v = exchange.getMessage().getHeader(k); + if (v != null) { + String[] parts = e.getValue().split(","); + if (Arrays.stream(parts).noneMatch(v::equals)) { + // this is a bad request, the client did not include some required query parameters + return new ValidationError(400, "Some of the query parameters or HTTP headers has a not-allowed value."); + } + } + } + } + + Object body = exchange.getMessage().getBody(); + if (validationContext.requiredBody()) { + // the body is required, so we need to know if we have a body or not + // so force reading the body as a String which we can work with + body = MessageHelper.extractBodyAsString(exchange.getIn()); + if (ObjectHelper.isNotEmpty(body)) { + exchange.getIn().setBody(body); + } + if (ObjectHelper.isEmpty(body)) { + // this is a bad request, the client did not include a message body + return new ValidationError(400, "The request body is missing."); + } + } + // if content-type is json then lets validate the message body can be parsed to json + if (body != null && contentType != null && isValidOrAcceptedContentType("application/json", contentType)) { + String json = MessageHelper.extractBodyAsString(exchange.getIn()); + if (json != null) { + try { + Jsoner.deserialize(json); + } catch (DeserializationException e) { + // request payload is not json + return new ValidationError(400, "Invalid JSon payload."); + } + } + } + + // success + return null; + } + +} 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 24a9752433e73..32b5bc2777535 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 @@ -16,7 +16,6 @@ */ package org.apache.camel.support.processor; -import java.util.Arrays; import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -31,6 +30,7 @@ import org.apache.camel.spi.DataFormat; import org.apache.camel.spi.DataType; import org.apache.camel.spi.DataTypeAware; +import org.apache.camel.spi.RestClientRequestValidator; import org.apache.camel.spi.RestConfiguration; import org.apache.camel.support.ExchangeHelper; import org.apache.camel.support.MessageHelper; @@ -40,8 +40,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static org.apache.camel.support.http.RestUtil.isValidOrAcceptedContentType; - /** * Used for Rest DSL with binding to json/xml for incoming requests and outgoing responses. *

@@ -60,6 +58,7 @@ public class RestBindingAdvice extends ServiceSupport implements CamelInternalPr private static final String STATE_JSON = "json"; private static final String STATE_XML = "xml"; + private final RestClientRequestValidator clientRequestValidator; private final AsyncProcessor jsonUnmarshal; private final AsyncProcessor xmlUnmarshal; private final AsyncProcessor jsonMarshal; @@ -90,7 +89,8 @@ public RestBindingAdvice(CamelContext camelContext, DataFormat jsonDataFormat, D Map queryDefaultValues, Map queryAllowedValues, boolean requiredBody, Set requiredQueryParameters, - Set requiredHeaders) throws Exception { + Set requiredHeaders, + RestClientRequestValidator clientRequestValidator) throws Exception { if (jsonDataFormat != null) { this.jsonUnmarshal = new UnmarshalProcessor(jsonDataFormat); @@ -144,6 +144,7 @@ public RestBindingAdvice(CamelContext camelContext, DataFormat jsonDataFormat, D this.requiredQueryParameters = requiredQueryParameters; this.requiredHeaders = requiredHeaders; this.enableNoContentResponse = enableNoContentResponse; + this.clientRequestValidator = clientRequestValidator; } @Override @@ -212,28 +213,22 @@ private void unmarshal(Exchange exchange, Map state) { String accept = exchange.getMessage().getHeader("Accept", String.class); state.put(STATE_KEY_ACCEPT, accept); - // perform client request validation - if (clientRequestValidation) { - // check if the content-type is accepted according to consumes - if (!isValidOrAcceptedContentType(consumes, contentType)) { - LOG.trace("Consuming content type does not match contentType header {}. Stopping routing.", contentType); - // the content-type is not something we can process so its a HTTP_ERROR 415 - exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 415); - // set empty response body as http error code indicate the problem - exchange.getMessage().setBody(null); - // stop routing and return - exchange.setRouteStop(true); - return; + // add missing default values which are mapped as headers + if (queryDefaultValues != null) { + for (Map.Entry entry : queryDefaultValues.entrySet()) { + if (exchange.getIn().getHeader(entry.getKey()) == null) { + exchange.getIn().setHeader(entry.getKey(), entry.getValue()); + } } + } - // check if what is produces is accepted by the client - if (!isValidOrAcceptedContentType(produces, accept)) { - LOG.trace("Produced content type does not match accept header {}. Stopping routing.", contentType); - // the response type is not accepted by the client so its a HTTP_ERROR 406 - exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 406); - // set empty response body as http error code indicate the problem - exchange.getMessage().setBody(null); - // stop routing and return + // perform client request validation + if (clientRequestValidation) { + RestClientRequestValidator.ValidationContext vc = new RestClientRequestValidator.ValidationContext(consumes, produces, requiredBody, queryDefaultValues, queryAllowedValues, requiredQueryParameters, requiredHeaders); + RestClientRequestValidator.ValidationError error = clientRequestValidator.validate(exchange, vc); + if (error != null) { + exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, error.statusCode()); + exchange.getMessage().setBody(error.body()); exchange.setRouteStop(true); return; } @@ -253,7 +248,6 @@ private void unmarshal(Exchange exchange, Map state) { } else { exchange.getIn().setBody(body); } - if (isXml && isJson) { // we have still not determined between xml or json, so check the body if its xml based or not isXml = body.startsWith("<"); @@ -263,80 +257,6 @@ private void unmarshal(Exchange exchange, Map state) { } } - // add missing default values which are mapped as headers - if (queryDefaultValues != null) { - for (Map.Entry entry : queryDefaultValues.entrySet()) { - if (exchange.getIn().getHeader(entry.getKey()) == null) { - exchange.getIn().setHeader(entry.getKey(), entry.getValue()); - } - } - } - - // check for required - if (clientRequestValidation) { - if (requiredBody) { - // the body is required so we need to know if we have a body or not - // so force reading the body as a String which we can work with - if (body == null) { - body = MessageHelper.extractBodyAsString(exchange.getIn()); - if (ObjectHelper.isNotEmpty(body)) { - exchange.getIn().setBody(body); - } - } - if (ObjectHelper.isEmpty(body)) { - // this is a bad request, the client did not include a message body - exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 400); - exchange.getMessage().setBody("The request body is missing."); - // stop routing and return - exchange.setRouteStop(true); - return; - } - // special check if binding mode is off and then incoming body is json based - // then we still want to ensure the body can be parsed as json - if (bindingMode.equals("off") && !isXml) { - if (isValidOrAcceptedContentType("application/json", contentType)) { - isJson = true; - } - } - } - if (requiredQueryParameters != null - && !exchange.getIn().getHeaders().keySet().containsAll(requiredQueryParameters)) { - // this is a bad request, the client did not include some required query parameters - exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 400); - exchange.getMessage().setBody("Some of the required query parameters are missing."); - // stop routing and return - exchange.setRouteStop(true); - return; - } - if (requiredHeaders != null && !exchange.getIn().getHeaders().keySet().containsAll(requiredHeaders)) { - // this is a bad request, the client did not include some required http headers - exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 400); - exchange.getMessage().setBody("Some of the required HTTP headers are missing."); - // stop routing and return - exchange.setRouteStop(true); - return; - } - // allowed values for query/header parameters - if (queryAllowedValues != null) { - for (var e : queryAllowedValues.entrySet()) { - String k = e.getKey(); - Object v = exchange.getMessage().getHeader(k); - if (v != null) { - String[] parts = e.getValue().split(","); - if (Arrays.stream(parts).noneMatch(v::equals)) { - // this is a bad request, the client did not include some required query parameters - exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 400); - exchange.getMessage() - .setBody("Some of the query parameters or HTTP headers has a not-allowed value."); - // stop routing and return - exchange.setRouteStop(true); - return; - } - } - } - } - } - // favor json over xml if (isJson && jsonUnmarshal != null) { // add reverse operation 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 4a909f83027fc..969f7dc01365c 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 @@ -18,14 +18,18 @@ import java.util.HashMap; import java.util.Map; +import java.util.Optional; import org.apache.camel.CamelContext; import org.apache.camel.spi.BeanIntrospection; import org.apache.camel.spi.DataFormat; +import org.apache.camel.spi.RestClientRequestValidator; import org.apache.camel.spi.RestConfiguration; +import org.apache.camel.support.CamelContextHelper; import org.apache.camel.support.EndpointHelper; import org.apache.camel.support.PluginHelper; import org.apache.camel.support.PropertyBindingSupport; +import org.apache.camel.support.ResolverHelper; /** * Factory to create {@link RestBindingAdvice} from the given configuration. @@ -114,13 +118,18 @@ public static RestBindingAdvice build(CamelContext camelContext, RestBindingConf } } + RestClientRequestValidator validator = null; + if (bc.isClientRequestValidation()) { + validator = lookupRestClientRequestValidator(camelContext); + } + return new RestBindingAdvice( camelContext, json, jaxb, outJson, outJaxb, bc.getConsumes(), bc.getProduces(), mode, bc.isSkipBindingOnErrorCode(), bc.isClientRequestValidation(), bc.isEnableCORS(), bc.isEnableNoContentResponse(), bc.getCorsHeaders(), bc.getQueryDefaultValues(), bc.getQueryAllowedValues(), bc.isRequiredBody(), bc.getRequiredQueryParameters(), - bc.getRequiredHeaders()); + bc.getRequiredHeaders(), validator); } protected static void setupJson( @@ -171,6 +180,21 @@ protected static void setupJson( setAdditionalConfiguration(camelContext, config, outJson, "json.out."); } + protected static RestClientRequestValidator lookupRestClientRequestValidator(CamelContext camelContext) { + RestClientRequestValidator answer = CamelContextHelper.findSingleByType(camelContext, RestClientRequestValidator.class); + if (answer == null) { + // lookup via classpath to find custom factory + Optional result = ResolverHelper.resolveService( + camelContext, + camelContext.getCamelContextExtension().getBootstrapFactoryFinder(), + RestClientRequestValidator.FACTORY, + RestClientRequestValidator.class); + // else use a default implementation + answer = result.orElseGet(DefaultRestClientRequestValidator::new); + } + return answer; + } + private static void setAdditionalConfiguration( CamelContext camelContext, RestConfiguration config, DataFormat dataFormat, String prefix) { if (config.getDataFormatProperties() != null && !config.getDataFormatProperties().isEmpty()) {