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.support;
018
019import java.util.ArrayList;
020import java.util.Comparator;
021import java.util.Iterator;
022import java.util.List;
023import java.util.Locale;
024import java.util.stream.Collectors;
025
026/**
027 * A context path matcher when using rest-dsl that allows components to reuse the same matching logic.
028 * <p/>
029 * The component should use the {@link #matchBestPath(String, String, java.util.List)} with the request details
030 * and the matcher returns the best matched, or <tt>null</tt> if none could be determined.
031 * <p/>
032 * The {@link ConsumerPath} is used for the components to provide the details to the matcher.
033 */
034public final class RestConsumerContextPathMatcher {
035
036    private RestConsumerContextPathMatcher() {
037    }
038    
039    /**
040     * Consumer path details which must be implemented and provided by the components.
041     */
042    public interface ConsumerPath<T> {
043
044        /**
045         * Any HTTP restrict method that would not be allowed
046         */
047        String getRestrictMethod();
048
049        /**
050         * The consumer context-path which may include wildcards
051         */
052        String getConsumerPath();
053
054        /**
055         * The consumer implementation
056         */
057        T getConsumer();
058
059        /**
060         * Whether the consumer match on uri prefix
061         */
062        boolean isMatchOnUriPrefix();
063
064    }
065
066    /**
067     * Does the incoming request match the given consumer path (ignore case)
068     *
069     * @param requestPath      the incoming request context path
070     * @param consumerPath     a consumer path
071     * @param matchOnUriPrefix whether to use the matchOnPrefix option
072     * @return <tt>true</tt> if matched, <tt>false</tt> otherwise
073     */
074    public static boolean matchPath(String requestPath, String consumerPath, boolean matchOnUriPrefix) {
075        // deal with null parameters
076        if (requestPath == null && consumerPath == null) {
077            return true;
078        }
079        if (requestPath == null || consumerPath == null) {
080            return false;
081        }
082
083        // remove starting/ending slashes
084        if (requestPath.startsWith("/")) {
085            requestPath = requestPath.substring(1);
086        }
087        if (requestPath.endsWith("/")) {
088            requestPath = requestPath.substring(0, requestPath.length() - 1);
089        }
090        // remove starting/ending slashes
091        if (consumerPath.startsWith("/")) {
092            consumerPath = consumerPath.substring(1);
093        }
094        if (consumerPath.endsWith("/")) {
095            consumerPath = consumerPath.substring(0, consumerPath.length() - 1);
096        }
097
098        String p1 = requestPath.toLowerCase(Locale.ENGLISH);
099        String p2 = consumerPath.toLowerCase(Locale.ENGLISH);
100
101        if (p1.equals(p2)) {
102            return true;
103        }
104
105        if (matchOnUriPrefix && p1.startsWith(p2)) {
106            return true;
107        }
108
109        return false;
110    }
111
112    /**
113     * Finds the best matching of the list of consumer paths that should service the incoming request.
114     *
115     * @param requestMethod the incoming request HTTP method
116     * @param requestPath   the incoming request context path
117     * @param consumerPaths the list of consumer context path details
118     * @return the best matched consumer, or <tt>null</tt> if none could be determined.
119     */
120    public static ConsumerPath matchBestPath(String requestMethod, String requestPath, List<ConsumerPath> consumerPaths) {
121        ConsumerPath answer = null;
122
123        List<ConsumerPath> candidates = new ArrayList<ConsumerPath>();
124
125        // first match by http method
126        for (ConsumerPath entry : consumerPaths) {
127            if (matchRestMethod(requestMethod, entry.getRestrictMethod())) {
128                candidates.add(entry);
129            }
130        }
131
132        // then see if we got a direct match
133        Iterator<ConsumerPath> it = candidates.iterator();
134        while (it.hasNext()) {
135            ConsumerPath consumer = it.next();
136            if (matchRestPath(requestPath, consumer.getConsumerPath(), false)) {
137                answer = consumer;
138                break;
139            }
140        }
141
142        // we could not find a direct match, and if the request is OPTIONS then we need all candidates
143        if (answer == null && isOptionsMethod(requestMethod)) {
144            candidates.clear();
145            candidates.addAll(consumerPaths);
146
147            // then try again to see if we can find a direct match
148            it = candidates.iterator();
149            while (it.hasNext()) {
150                ConsumerPath consumer = it.next();
151                if (matchRestPath(requestPath, consumer.getConsumerPath(), false)) {
152                    answer = consumer;
153                    break;
154                }
155            }
156        }
157
158        // if there are no wildcards, then select the matching with the longest path
159        boolean noWildcards = candidates.stream().allMatch(p -> countWildcards(p.getConsumerPath()) == 0);
160        if (noWildcards) {
161            // grab first which is the longest that matched the request path
162            answer = candidates.stream()
163                .filter(c -> matchPath(requestPath, c.getConsumerPath(), c.isMatchOnUriPrefix()))
164                // sort by longest by inverting the sort by multiply with -1
165                .sorted(Comparator.comparingInt(o -> -1 * o.getConsumerPath().length())).findFirst().orElse(null);
166        }
167
168        // then match by wildcard path
169        if (answer == null) {
170            it = candidates.iterator();
171            while (it.hasNext()) {
172                ConsumerPath consumer = it.next();
173                // filter non matching paths
174                if (!matchRestPath(requestPath, consumer.getConsumerPath(), true)) {
175                    it.remove();
176                }
177            }
178
179            // if there is multiple candidates with wildcards then pick anyone with the least number of wildcards
180            int bestWildcard = Integer.MAX_VALUE;
181            ConsumerPath best = null;
182            if (candidates.size() > 1) {
183                it = candidates.iterator();
184                while (it.hasNext()) {
185                    ConsumerPath entry = it.next();
186                    int wildcards = countWildcards(entry.getConsumerPath());
187                    if (wildcards > 0) {
188                        if (best == null || wildcards < bestWildcard) {
189                            best = entry;
190                            bestWildcard = wildcards;
191                        }
192                    }
193                }
194
195                if (best != null) {
196                    // pick the best among the wildcards
197                    answer = best;
198                }
199            }
200
201            // if there is one left then its our answer
202            if (answer == null && candidates.size() == 1) {
203                answer = candidates.get(0);
204            }
205        }
206
207        return answer;
208    }
209
210    /**
211     * Matches the given request HTTP method with the configured HTTP method of the consumer.
212     *
213     * @param method   the request HTTP method
214     * @param restrict the consumer configured HTTP restrict method
215     * @return <tt>true</tt> if matched, <tt>false</tt> otherwise
216     */
217    private static boolean matchRestMethod(String method, String restrict) {
218        if (restrict == null) {
219            return true;
220        }
221
222        return restrict.toLowerCase(Locale.ENGLISH).contains(method.toLowerCase(Locale.ENGLISH));
223    }
224
225    /**
226     * Is the request method OPTIONS
227     *
228     * @return <tt>true</tt> if matched, <tt>false</tt> otherwise
229     */
230    private static boolean isOptionsMethod(String method) {
231        return "options".equalsIgnoreCase(method);
232    }
233
234    /**
235     * Matches the given request path with the configured consumer path
236     *
237     * @param requestPath  the request path
238     * @param consumerPath the consumer path which may use { } tokens
239     * @return <tt>true</tt> if matched, <tt>false</tt> otherwise
240     */
241    private static boolean matchRestPath(String requestPath, String consumerPath, boolean wildcard) {
242        // deal with null parameters
243        if (requestPath == null && consumerPath == null) {
244            return true;
245        }
246        if (requestPath == null || consumerPath == null) {
247            return false;
248        }
249
250        // remove starting/ending slashes
251        if (requestPath.startsWith("/")) {
252            requestPath = requestPath.substring(1);
253        }
254        if (requestPath.endsWith("/")) {
255            requestPath = requestPath.substring(0, requestPath.length() - 1);
256        }
257        // remove starting/ending slashes
258        if (consumerPath.startsWith("/")) {
259            consumerPath = consumerPath.substring(1);
260        }
261        if (consumerPath.endsWith("/")) {
262            consumerPath = consumerPath.substring(0, consumerPath.length() - 1);
263        }
264
265        // split using single char / is optimized in the jdk
266        String[] requestPaths = requestPath.split("/");
267        String[] consumerPaths = consumerPath.split("/");
268
269        // must be same number of path's
270        if (requestPaths.length != consumerPaths.length) {
271            return false;
272        }
273
274        for (int i = 0; i < requestPaths.length; i++) {
275            String p1 = requestPaths[i];
276            String p2 = consumerPaths[i];
277
278            if (wildcard && p2.startsWith("{") && p2.endsWith("}")) {
279                // always matches
280                continue;
281            }
282
283            if (!matchPath(p1, p2, false)) {
284                return false;
285            }
286        }
287
288        // assume matching
289        return true;
290    }
291
292    /**
293     * Counts the number of wildcards in the path
294     *
295     * @param consumerPath the consumer path which may use { } tokens
296     * @return number of wildcards, or <tt>0</tt> if no wildcards
297     */
298    private static int countWildcards(String consumerPath) {
299        int wildcards = 0;
300
301        // remove starting/ending slashes
302        if (consumerPath.startsWith("/")) {
303            consumerPath = consumerPath.substring(1);
304        }
305        if (consumerPath.endsWith("/")) {
306            consumerPath = consumerPath.substring(0, consumerPath.length() - 1);
307        }
308
309        String[] consumerPaths = consumerPath.split("/");
310        for (String p2 : consumerPaths) {
311            if (p2.startsWith("{") && p2.endsWith("}")) {
312                wildcards++;
313            }
314        }
315
316        return wildcards;
317    }
318
319}