001/**
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.camel.processor;
018
019import java.util.HashMap;
020import java.util.Locale;
021import java.util.Map;
022import java.util.Set;
023
024import org.apache.camel.AsyncProcessor;
025import org.apache.camel.CamelContext;
026import org.apache.camel.CamelContextAware;
027import org.apache.camel.Exchange;
028import org.apache.camel.Message;
029import org.apache.camel.processor.binding.BindingException;
030import org.apache.camel.spi.DataFormat;
031import org.apache.camel.spi.DataType;
032import org.apache.camel.spi.DataTypeAware;
033import org.apache.camel.spi.RestConfiguration;
034import org.apache.camel.util.ExchangeHelper;
035import org.apache.camel.util.MessageHelper;
036import org.apache.camel.util.ObjectHelper;
037import org.slf4j.Logger;
038import org.slf4j.LoggerFactory;
039
040/**
041 * A {@link org.apache.camel.processor.CamelInternalProcessorAdvice} that binds the REST DSL incoming
042 * and outgoing messages from sources of json or xml to Java Objects.
043 * <p/>
044 * The binding uses {@link org.apache.camel.spi.DataFormat} for the actual work to transform
045 * from xml/json to Java Objects and reverse again.
046 * <p/>
047 * The rest producer side is implemented in {@link org.apache.camel.component.rest.RestProducerBindingProcessor}
048 *
049 * @see CamelInternalProcessor
050 */
051public class RestBindingAdvice implements CamelInternalProcessorAdvice<Map<String, Object>> {
052
053    private static final Logger LOG = LoggerFactory.getLogger(RestBindingAdvice.class);
054    private static final String STATE_KEY_DO_MARSHAL = "doMarshal";
055    private static final String STATE_KEY_ACCEPT = "accept";
056    private static final String STATE_JSON = "json";
057    private static final String STATE_XML = "xml";
058
059    private final AsyncProcessor jsonUnmarshal;
060    private final AsyncProcessor xmlUnmarshal;
061    private final AsyncProcessor jsonMarshal;
062    private final AsyncProcessor xmlMarshal;
063    private final String consumes;
064    private final String produces;
065    private final String bindingMode;
066    private final boolean skipBindingOnErrorCode;
067    private final boolean clientRequestValidation;
068    private final boolean enableCORS;
069    private final Map<String, String> corsHeaders;
070    private final Map<String, String> queryDefaultValues;
071    private final boolean requiredBody;
072    private final Set<String> requiredQueryParameters;
073    private final Set<String> requiredHeaders;
074
075    public RestBindingAdvice(CamelContext camelContext, DataFormat jsonDataFormat, DataFormat xmlDataFormat,
076                             DataFormat outJsonDataFormat, DataFormat outXmlDataFormat,
077                             String consumes, String produces, String bindingMode,
078                             boolean skipBindingOnErrorCode, boolean clientRequestValidation, boolean enableCORS,
079                             Map<String, String> corsHeaders,
080                             Map<String, String> queryDefaultValues,
081                             boolean requiredBody, Set<String> requiredQueryParameters, Set<String> requiredHeaders) throws Exception {
082
083        if (jsonDataFormat != null) {
084            this.jsonUnmarshal = new UnmarshalProcessor(jsonDataFormat);
085        } else {
086            this.jsonUnmarshal = null;
087        }
088        if (outJsonDataFormat != null) {
089            this.jsonMarshal = new MarshalProcessor(outJsonDataFormat);
090        } else if (jsonDataFormat != null) {
091            this.jsonMarshal = new MarshalProcessor(jsonDataFormat);
092        } else {
093            this.jsonMarshal = null;
094        }
095
096        if (xmlDataFormat != null) {
097            this.xmlUnmarshal = new UnmarshalProcessor(xmlDataFormat);
098        } else {
099            this.xmlUnmarshal = null;
100        }
101        if (outXmlDataFormat != null) {
102            this.xmlMarshal = new MarshalProcessor(outXmlDataFormat);
103        } else if (xmlDataFormat != null) {
104            this.xmlMarshal = new MarshalProcessor(xmlDataFormat);
105        } else {
106            this.xmlMarshal = null;
107        }
108
109        if (jsonMarshal != null) {
110            camelContext.addService(jsonMarshal, true);
111        }
112        if (jsonUnmarshal != null) {
113            camelContext.addService(jsonUnmarshal, true);
114        }
115        if (xmlMarshal instanceof CamelContextAware) {
116            camelContext.addService(xmlMarshal, true);
117        }
118        if (xmlUnmarshal instanceof CamelContextAware) {
119            camelContext.addService(xmlUnmarshal, true);
120        }
121
122        this.consumes = consumes;
123        this.produces = produces;
124        this.bindingMode = bindingMode;
125        this.skipBindingOnErrorCode = skipBindingOnErrorCode;
126        this.clientRequestValidation = clientRequestValidation;
127        this.enableCORS = enableCORS;
128        this.corsHeaders = corsHeaders;
129        this.queryDefaultValues = queryDefaultValues;
130        this.requiredBody = requiredBody;
131        this.requiredQueryParameters = requiredQueryParameters;
132        this.requiredHeaders = requiredHeaders;
133    }
134    
135    @Override
136    public Map<String, Object> before(Exchange exchange) throws Exception {
137        Map<String, Object> state = new HashMap<>();
138        if (isOptionsMethod(exchange, state)) {
139            return state;
140        }
141        unmarshal(exchange, state);
142        return state;
143    }
144    
145    @Override
146    public void after(Exchange exchange, Map<String, Object> state) throws Exception {
147        if (enableCORS) {
148            setCORSHeaders(exchange, state);
149        }
150        if (state.get(STATE_KEY_DO_MARSHAL) != null) {
151            marshal(exchange, state);
152        }
153    }
154
155    private boolean isOptionsMethod(Exchange exchange, Map<String, Object> state) {
156        String method = exchange.getIn().getHeader(Exchange.HTTP_METHOD, String.class);
157        if ("OPTIONS".equalsIgnoreCase(method)) {
158            // for OPTIONS methods then we should not route at all as its part of CORS
159            exchange.setProperty(Exchange.ROUTE_STOP, true);
160            return true;
161        }
162        return false;
163    }
164
165    private void unmarshal(Exchange exchange, Map<String, Object> state) throws Exception {
166        boolean isXml = false;
167        boolean isJson = false;
168
169        String contentType = ExchangeHelper.getContentType(exchange);
170        if (contentType != null) {
171            isXml = contentType.toLowerCase(Locale.ENGLISH).contains("xml");
172            isJson = contentType.toLowerCase(Locale.ENGLISH).contains("json");
173        }
174        // if content type could not tell us if it was json or xml, then fallback to if the binding was configured with
175        // that information in the consumes
176        if (!isXml && !isJson) {
177            isXml = consumes != null && consumes.toLowerCase(Locale.ENGLISH).contains("xml");
178            isJson = consumes != null && consumes.toLowerCase(Locale.ENGLISH).contains("json");
179        }
180
181        // set data type if in use
182        if (exchange.getContext().isUseDataType()) {
183            if (exchange.getIn() instanceof DataTypeAware && (isJson || isXml)) {
184                ((DataTypeAware) exchange.getIn()).setDataType(new DataType(isJson ? "json" : "xml"));
185            }
186        }
187
188        // only allow xml/json if the binding mode allows that
189        isXml &= bindingMode.equals("auto") || bindingMode.contains("xml");
190        isJson &= bindingMode.equals("auto") || bindingMode.contains("json");
191
192        // if we do not yet know if its xml or json, then use the binding mode to know the mode
193        if (!isJson && !isXml) {
194            isXml = bindingMode.equals("auto") || bindingMode.contains("xml");
195            isJson = bindingMode.equals("auto") || bindingMode.contains("json");
196        }
197
198        String accept = exchange.getMessage().getHeader("Accept", String.class);
199        state.put(STATE_KEY_ACCEPT, accept);
200
201        // perform client request validation
202        if (clientRequestValidation) {
203            // check if the content-type is accepted according to consumes
204            if (!isValidOrAcceptedContentType(consumes, contentType)) {
205                LOG.trace("Consuming content type does not match contentType header {}. Stopping routing.", contentType);
206                // the content-type is not something we can process so its a HTTP_ERROR 415
207                exchange.getOut().setHeader(Exchange.HTTP_RESPONSE_CODE, 415);
208                // stop routing and return
209                exchange.setProperty(Exchange.ROUTE_STOP, true);
210                return;
211            }
212
213            // check if what is produces is accepted by the client
214            if (!isValidOrAcceptedContentType(produces, accept)) {
215                LOG.trace("Produced content type does not match accept header {}. Stopping routing.", contentType);
216                // the response type is not accepted by the client so its a HTTP_ERROR 406
217                exchange.getOut().setHeader(Exchange.HTTP_RESPONSE_CODE, 406);
218                // stop routing and return
219                exchange.setProperty(Exchange.ROUTE_STOP, true);
220                return;
221            }
222        }
223
224        String body = null;
225        if (exchange.getIn().getBody() != null) {
226
227            // okay we have a binding mode, so need to check for empty body as that can cause the marshaller to fail
228            // as they assume a non-empty body
229            if (isXml || isJson) {
230                // we have binding enabled, so we need to know if there body is empty or not
231                // so force reading the body as a String which we can work with
232                body = MessageHelper.extractBodyAsString(exchange.getIn());
233                if (body != null) {
234                    if (exchange.getIn() instanceof DataTypeAware) {
235                        ((DataTypeAware)exchange.getIn()).setBody(body, new DataType(isJson ? "json" : "xml"));
236                    } else {
237                        exchange.getIn().setBody(body);
238                    }
239
240                    if (isXml && isJson) {
241                        // we have still not determined between xml or json, so check the body if its xml based or not
242                        isXml = body.startsWith("<");
243                        isJson = !isXml;
244                    }
245                }
246            }
247        }
248
249        // add missing default values which are mapped as headers
250        if (queryDefaultValues != null) {
251            for (Map.Entry<String, String> entry : queryDefaultValues.entrySet()) {
252                if (exchange.getIn().getHeader(entry.getKey()) == null) {
253                    exchange.getIn().setHeader(entry.getKey(), entry.getValue());
254                }
255            }
256        }
257
258        // check for required
259        if (clientRequestValidation) {
260            if (requiredBody) {
261                // the body is required so we need to know if we have a body or not
262                // so force reading the body as a String which we can work with
263                if (body == null) {
264                    body = MessageHelper.extractBodyAsString(exchange.getIn());
265                    if (body != null) {
266                        exchange.getIn().setBody(body);
267                    }
268                }
269                if (body == null) {
270                    // this is a bad request, the client did not include a message body
271                    exchange.getOut().setHeader(Exchange.HTTP_RESPONSE_CODE, 400);
272                    exchange.getOut().setBody("The request body is missing.");
273                    // stop routing and return
274                    exchange.setProperty(Exchange.ROUTE_STOP, true);
275                    return;
276                }
277            }
278            if (requiredQueryParameters != null && !exchange.getIn().getHeaders().keySet().containsAll(requiredQueryParameters)) {
279                // this is a bad request, the client did not include some of the required query parameters
280                exchange.getOut().setHeader(Exchange.HTTP_RESPONSE_CODE, 400);
281                exchange.getOut().setBody("Some of the required query parameters are missing.");
282                // stop routing and return
283                exchange.setProperty(Exchange.ROUTE_STOP, true);
284                return;
285            }
286            if (requiredHeaders != null && !exchange.getIn().getHeaders().keySet().containsAll(requiredHeaders)) {
287                // this is a bad request, the client did not include some of the required http headers
288                exchange.getOut().setHeader(Exchange.HTTP_RESPONSE_CODE, 400);
289                exchange.getOut().setBody("Some of the required HTTP headers are missing.");
290                // stop routing and return
291                exchange.setProperty(Exchange.ROUTE_STOP, true);
292                return;
293            }
294        }
295
296        // favor json over xml
297        if (isJson && jsonUnmarshal != null) {
298            // add reverse operation
299            state.put(STATE_KEY_DO_MARSHAL, STATE_JSON);
300            if (ObjectHelper.isNotEmpty(body)) {
301                jsonUnmarshal.process(exchange);
302                ExchangeHelper.prepareOutToIn(exchange);
303            }
304            return;
305        } else if (isXml && xmlUnmarshal != null) {
306            // add reverse operation
307            state.put(STATE_KEY_DO_MARSHAL, STATE_XML);
308            if (ObjectHelper.isNotEmpty(body)) {
309                xmlUnmarshal.process(exchange);
310                ExchangeHelper.prepareOutToIn(exchange);
311            }
312            return;
313        }
314
315        // we could not bind
316        if ("off".equals(bindingMode) || bindingMode.equals("auto")) {
317            // okay for auto we do not mind if we could not bind
318            state.put(STATE_KEY_DO_MARSHAL, STATE_JSON);
319        } else {
320            if (bindingMode.contains("xml")) {
321                exchange.setException(new BindingException("Cannot bind to xml as message body is not xml compatible", exchange));
322            } else {
323                exchange.setException(new BindingException("Cannot bind to json as message body is not json compatible", exchange));
324            }
325        }
326        
327    }
328
329    private void marshal(Exchange exchange, Map<String, Object> state) {
330        // only marshal if there was no exception
331        if (exchange.getException() != null) {
332            return;
333        }
334
335        if (skipBindingOnErrorCode) {
336            Integer code = exchange.hasOut() ? exchange.getOut().getHeader(Exchange.HTTP_RESPONSE_CODE, Integer.class) : exchange.getIn().getHeader(Exchange.HTTP_RESPONSE_CODE, Integer.class);
337            // if there is a custom http error code then skip binding
338            if (code != null && code >= 300) {
339                return;
340            }
341        }
342
343        boolean isXml = false;
344        boolean isJson = false;
345
346        // accept takes precedence
347        String accept = (String)state.get(STATE_KEY_ACCEPT);
348        if (accept != null) {
349            isXml = accept.toLowerCase(Locale.ENGLISH).contains("xml");
350            isJson = accept.toLowerCase(Locale.ENGLISH).contains("json");
351        }
352        // fallback to content type if still undecided
353        if (!isXml && !isJson) {
354            String contentType = ExchangeHelper.getContentType(exchange);
355            if (contentType != null) {
356                isXml = contentType.toLowerCase(Locale.ENGLISH).contains("xml");
357                isJson = contentType.toLowerCase(Locale.ENGLISH).contains("json");
358            }
359        }
360        // if content type could not tell us if it was json or xml, then fallback to if the binding was configured with
361        // that information in the consumes
362        if (!isXml && !isJson) {
363            isXml = produces != null && produces.toLowerCase(Locale.ENGLISH).contains("xml");
364            isJson = produces != null && produces.toLowerCase(Locale.ENGLISH).contains("json");
365        }
366
367        // only allow xml/json if the binding mode allows that (when off we still want to know if its xml or json)
368        if (bindingMode != null) {
369            isXml &= bindingMode.equals("off") || bindingMode.equals("auto") || bindingMode.contains("xml");
370            isJson &= bindingMode.equals("off") || bindingMode.equals("auto") || bindingMode.contains("json");
371
372            // if we do not yet know if its xml or json, then use the binding mode to know the mode
373            if (!isJson && !isXml) {
374                isXml = bindingMode.equals("auto") || bindingMode.contains("xml");
375                isJson = bindingMode.equals("auto") || bindingMode.contains("json");
376            }
377        }
378
379        // in case we have not yet been able to determine if xml or json, then use the same as in the unmarshaller
380        if (isXml && isJson) {
381            isXml = state.get(STATE_KEY_DO_MARSHAL).equals(STATE_XML);
382            isJson = !isXml;
383        }
384
385        // need to prepare exchange first
386        ExchangeHelper.prepareOutToIn(exchange);
387
388        // ensure there is a content type header (even if binding is off)
389        ensureHeaderContentType(produces, isXml, isJson, exchange);
390
391        if (bindingMode == null || "off".equals(bindingMode)) {
392            // binding is off, so no message body binding
393            return;
394        }
395
396        // is there any marshaller at all
397        if (jsonMarshal == null && xmlMarshal == null) {
398            return;
399        }
400
401        // is the body empty
402        if ((exchange.hasOut() && exchange.getOut().getBody() == null) || (!exchange.hasOut() && exchange.getIn().getBody() == null)) {
403            return;
404        }
405
406        String contentType = exchange.getIn().getHeader(Exchange.CONTENT_TYPE, String.class);
407        // need to lower-case so the contains check below can match if using upper case
408        contentType = contentType.toLowerCase(Locale.US);
409        try {
410            // favor json over xml
411            if (isJson && jsonMarshal != null) {
412                // only marshal if its json content type
413                if (contentType.contains("json")) {
414                    jsonMarshal.process(exchange);
415                    setOutputDataType(exchange, new DataType("json"));
416                }
417            } else if (isXml && xmlMarshal != null) {
418                // only marshal if its xml content type
419                if (contentType.contains("xml")) {
420                    xmlMarshal.process(exchange);
421                    setOutputDataType(exchange, new DataType("xml"));
422                }
423            } else {
424                // we could not bind
425                if (bindingMode.equals("auto")) {
426                    // okay for auto we do not mind if we could not bind
427                } else {
428                    if (bindingMode.contains("xml")) {
429                        exchange.setException(new BindingException("Cannot bind to xml as message body is not xml compatible", exchange));
430                    } else {
431                        exchange.setException(new BindingException("Cannot bind to json as message body is not json compatible", exchange));
432                    }
433                }
434            }
435        } catch (Throwable e) {
436            exchange.setException(e);
437        }
438    }
439
440    private void setOutputDataType(Exchange exchange, DataType type) {
441        Message target = exchange.hasOut() ? exchange.getOut() : exchange.getIn();
442        if (target instanceof DataTypeAware) {
443            ((DataTypeAware)target).setDataType(type);
444        }
445    }
446
447    private void ensureHeaderContentType(String contentType, boolean isXml, boolean isJson, Exchange exchange) {
448        // favor given content type
449        if (contentType != null) {
450            String type = ExchangeHelper.getContentType(exchange);
451            if (type == null) {
452                exchange.getIn().setHeader(Exchange.CONTENT_TYPE, contentType);
453            }
454        }
455
456        // favor json over xml
457        if (isJson) {
458            // make sure there is a content-type with json
459            String type = ExchangeHelper.getContentType(exchange);
460            if (type == null) {
461                exchange.getIn().setHeader(Exchange.CONTENT_TYPE, "application/json");
462            }
463        } else if (isXml) {
464            // make sure there is a content-type with xml
465            String type = ExchangeHelper.getContentType(exchange);
466            if (type == null) {
467                exchange.getIn().setHeader(Exchange.CONTENT_TYPE, "application/xml");
468            }
469        }
470    }
471
472    private void setCORSHeaders(Exchange exchange, Map<String, Object> state) {
473        // add the CORS headers after routing, but before the consumer writes the response
474        Message msg = exchange.hasOut() ? exchange.getOut() : exchange.getIn();
475
476        // use default value if none has been configured
477        String allowOrigin = corsHeaders != null ? corsHeaders.get("Access-Control-Allow-Origin") : null;
478        if (allowOrigin == null) {
479            allowOrigin = RestConfiguration.CORS_ACCESS_CONTROL_ALLOW_ORIGIN;
480        }
481        String allowMethods = corsHeaders != null ? corsHeaders.get("Access-Control-Allow-Methods") : null;
482        if (allowMethods == null) {
483            allowMethods = RestConfiguration.CORS_ACCESS_CONTROL_ALLOW_METHODS;
484        }
485        String allowHeaders = corsHeaders != null ? corsHeaders.get("Access-Control-Allow-Headers") : null;
486        if (allowHeaders == null) {
487            allowHeaders = RestConfiguration.CORS_ACCESS_CONTROL_ALLOW_HEADERS;
488        }
489        String maxAge = corsHeaders != null ? corsHeaders.get("Access-Control-Max-Age") : null;
490        if (maxAge == null) {
491            maxAge = RestConfiguration.CORS_ACCESS_CONTROL_MAX_AGE;
492        }
493        String allowCredentials = corsHeaders != null ? corsHeaders.get("Access-Control-Allow-Credentials") : null;
494
495        // Restrict the origin if credentials are allowed.
496        // https://www.w3.org/TR/cors/ - section 6.1, point 3
497        String origin = exchange.getIn().getHeader("Origin", String.class);
498        if ("true".equalsIgnoreCase(allowCredentials) && "*".equals(allowOrigin) && origin != null) {
499            allowOrigin = origin;
500        }
501
502        msg.setHeader("Access-Control-Allow-Origin", allowOrigin);
503        msg.setHeader("Access-Control-Allow-Methods", allowMethods);
504        msg.setHeader("Access-Control-Allow-Headers", allowHeaders);
505        msg.setHeader("Access-Control-Max-Age", maxAge);
506        if (allowCredentials != null) {
507            msg.setHeader("Access-Control-Allow-Credentials", allowCredentials);
508        }
509    }
510
511    private static boolean isValidOrAcceptedContentType(String valid, String target) {
512        if (valid == null || target == null) {
513            return true;
514        }
515
516        // Any MIME type
517        // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept#Directives
518        if ("*/*".equals(target)) {
519            return true;
520        }
521
522        boolean isXml = valid.toLowerCase(Locale.ENGLISH).contains("xml");
523        boolean isJson = valid.toLowerCase(Locale.ENGLISH).contains("json");
524
525        String type = target.toLowerCase(Locale.ENGLISH);
526
527        if (isXml && !type.contains("xml")) {
528            return false;
529        }
530        if (isJson && !type.contains("json")) {
531            return false;
532        }
533
534        return true;
535    }
536
537}