All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.micronaut.http.uri.UriTemplateMatcher Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2017-2024 original authors
 *
 * Licensed 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
 *
 * https://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 io.micronaut.http.uri;

import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.util.CollectionUtils;
import io.micronaut.core.util.StringUtils;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Implementation of the paths matching rfc6570.
 *
 * @author Denis Stepanov
 * @since 4.6.0
 */
@Internal
public final class UriTemplateMatcher implements UriMatcher, Comparable {

    private final String templateString;
    private final List parts;
    private final List variables;
    private final Segment[] segments;
    private final boolean isRoot;

    // Matches cache
    private UriMatchInfo rootMatchInfo;
    private UriMatchInfo exactMatchInfo;

    /**
     * Construct a new URI template for the given template.
     *
     * @param templateString The template string
     */
    public UriTemplateMatcher(String templateString) {
        this(templateString, UriTemplateParser.parse(templateString));
    }

    /**
     * Construct a new URI template for the given template.
     *
     * @param parts The parsed parts
     */
    private UriTemplateMatcher(String templateString, List parts) {
        this.templateString = templateString;
        this.parts = parts;
        List variables = new ArrayList<>();
        this.segments = provideMatchSegments(parts, variables);
        this.isRoot = segments.length == 0 || segments.length == 1 && segments[0].type == SegmentType.LITERAL && isRoot(segments[0].value);
        this.variables = Collections.unmodifiableList(variables);
    }

    private static Segment[] provideMatchSegments(List parts, List variables) {
        List segments = new ArrayList<>();
        List regexpVariables = new ArrayList<>();
        StringBuilder regexp = null;
        for (int i = 0; i < parts.size(); i++) {
            UriTemplateParser.Part part = parts.get(i);
            if (part instanceof UriTemplateParser.Literal literal) {
                if (regexp == null) {
                    segments.add(new Segment(SegmentType.LITERAL, literal.text(), null, null));
                } else {
                    regexp.append(Pattern.quote(literal.text()));
                }
            } else if (part instanceof UriTemplateParser.Expression expression) {
                if (regexp == null && allowPathSegment(expression, parts, i)) {
                    for (UriTemplateParser.Variable variable : expression.variables()) {
                        variables.add(new UriMatchVariable(
                                variable.name(),
                                variable.explode() ? '*' : '0',
                                expression.type().getOperator()
                            )
                        );
                        segments.add(new Segment(SegmentType.PATH, variable.name(), null, null));
                    }
                    continue;
                }
                if (regexp == null) {
                    regexp = new StringBuilder();
                }
                for (UriTemplateParser.Variable variable : expression.variables()) {
                    variables.add(new UriMatchVariable(
                            variable.name(),
                            variable.explode() ? '*' : '0',
                            expression.type().getOperator()
                        )
                    );
                    appendRegexp(regexp, expression.type(), variable, regexpVariables);
                }
            }
        }
        if (regexp != null) {
            segments.add(new Segment(SegmentType.REGEXP, null, Pattern.compile(regexp.toString()), regexpVariables.toArray(String[]::new)));
        }

        return segments.toArray(Segment[]::new);
    }

    private static boolean allowPathSegment(UriTemplateParser.Expression expression,
                                     List parts,
                                     int index) {
        if (expression.type() != UriTemplateParser.ExpressionType.NONE) {
            return false; // Only this on is supported
        }
        if (!expression.variables().stream().allMatch(v -> v.modifier() == null)) {
            return false; // Cannot have any kind of pattern
        }
        if (parts.size() == index + 1) {
            return true; // Last path
        }
        if (parts.get(index + 1) instanceof UriTemplateParser.Literal literal && literal.text().startsWith("/")) {
            return true; // It can absorb everything till the next one
        }
        return false;
    }

    @SuppressWarnings("MissingSwitchDefault")
    private static void appendRegexp(StringBuilder regexpBuilder,
                                     UriTemplateParser.ExpressionType type,
                                     UriTemplateParser.Variable variable,
                                     List variables) {

        switch (type) {
            case PATH_STYLE_PARAMETER_EXPANSION:
            case FORM_STYLE_PARAMETER_EXPANSION:
            case FORM_STYLE_QUERY_CONTINUATION:
            case FRAGMENT_EXPANSION:
                return; // Unsupported types
        }

        Integer limit = null;
        String pattern = null;
        String modifier = variable.modifier();
        if (StringUtils.isNotEmpty(modifier)) {
            try {
                limit = Integer.parseInt(modifier);
            } catch (Exception ignore) {
                // Ignore
            }
            if (limit == null) {
                pattern = modifier;
            }
        }

        // Code originally from UriMatchTemplate

        String operatorPrefix = "";
        String operatorQuantifier = "";
        String variableQuantifier = "+?)";
        String variablePattern = null;
        if (pattern != null) {
            char firstChar = pattern.charAt(0);
            if (firstChar == '?') {
                operatorQuantifier = "";
            } else {
                int patternLength = pattern.length();
                char lastChar = pattern.charAt(patternLength - 1);
                if (lastChar == '*' ||
                    (patternLength > 1 && lastChar == '?'
                        && (pattern.charAt(patternLength - 2) == '*' || pattern.charAt(patternLength - 2) == '+'))) {
                    operatorQuantifier = "?";
                }
                String s = (firstChar == '^') ? pattern.substring(1) : pattern;
                char operator = type.getOperator();
                if (operator == '/' || operator == '.') {
                    variablePattern = "(" + s + ")";
                } else {
                    operatorPrefix = "(";
                    variablePattern = s + ")";
                }
                variableQuantifier = StringUtils.EMPTY_STRING;
            }
        } else if (limit != null) {
            variableQuantifier = "{1," + limit + "})";
        }

        variables.add(variable.name());

        boolean operatorAppended = false;
        switch (type) {
            case LABEL_EXPANSION:
            case PATH_SEGMENT_EXPANSION:
                regexpBuilder
                    .append('(')
                    .append(operatorPrefix)
                    .append('\\')
                    .append(type.getOperator())
                    .append(operatorQuantifier);
                operatorAppended = true;
                // fall through
            case RESERVED_EXPANSION:
            case NONE:
                if (!operatorAppended) {
                    regexpBuilder.append('(').append(operatorPrefix);
                }
                if (variablePattern == null) {
                    if (type == UriTemplateParser.ExpressionType.RESERVED_EXPANSION) {
                        // Allow reserved characters. See https://tools.ietf.org/html/rfc6570#section-3.2.3
                        variablePattern = "([\\S]";
                    } else {
                        variablePattern = "([^/?#(!{)&;+]";
                    }
                }
                regexpBuilder
                    .append(variablePattern)
                    .append(variableQuantifier)
                    .append(')');
                break;
            default:
                throw new IllegalStateException("Unsupported regexp expression type: " + type);
        }
        if (type == UriTemplateParser.ExpressionType.PATH_SEGMENT_EXPANSION || pattern != null && pattern.equals("?")) {
            regexpBuilder.append('?');
        }
    }

    /**
     * Match the given URI string.
     *
     * @param uri The uRI
     * @return an optional match
     */
    @Override
    public Optional match(String uri) {
        return Optional.ofNullable(tryMatch(uri));
    }

    /**
     * Match the given URI string.
     *
     * @param uri The uRI
     * @return a match or null
     */
    @Nullable
    public UriMatchInfo tryMatch(@NonNull String uri) {
        int length = uri.length();
        if (length > 1 && uri.charAt(length - 1) == '/') {
            uri = uri.substring(0, length - 1);
        }
        if (isRoot && isRoot(uri)) {
            if (rootMatchInfo == null) {
                rootMatchInfo = new DefaultUriMatchInfo(uri, Collections.emptyMap(), variables);
            }
            return rootMatchInfo;
        }
        // Remove any url parameters before matching
        int parameterIndex = uri.indexOf('?');
        if (parameterIndex > -1) {
            uri = uri.substring(0, parameterIndex);
            length = uri.length();
            if (length > 1 && uri.charAt(length - 1) == '/') {
                uri = uri.substring(0, length - 1);
            }
        }
        if (variables.isEmpty()) {
            if (uri.equals(templateString)) {
                if (exactMatchInfo == null) {
                    exactMatchInfo = new DefaultUriMatchInfo(uri, Collections.emptyMap(), variables);
                }
                return exactMatchInfo;
            }
            return null;
        }
        Map variableMap = CollectionUtils.newLinkedHashMap(variables.size());
        if (match(uri, variableMap)) {
            return new DefaultUriMatchInfo(uri, variableMap, variables);
        }
        return null;
    }

    private boolean match(String uri, Map variableMap) {
        for (int i = 0; i < segments.length; i++) {
            Segment segment = segments[i];
            switch (segment.type) {
                case LITERAL -> {
                    if (uri.startsWith(segment.value)) {
                        uri = uri.substring(segment.value.length());
                    } else {
                        return false;
                    }
                }
                case PATH -> {
                    boolean requiresSlash = i + 1 != segments.length;
                    int index = readText(uri, requiresSlash);
                    if (index > 0) { // Deny empty path
                        String path = uri.substring(0, index);
                        variableMap.put(segment.value, path);
                        uri = uri.substring(index);
                    } else {
                        return false;
                    }
                }
                case REGEXP -> {
                    Matcher matcher = segment.pattern.matcher(uri);
                    if (matcher.matches()) {
                        int groupInx = 2;
                        for (String matchingVariable : segment.regexpVariables) {
                            String group = matcher.group(groupInx);
                            variableMap.put(matchingVariable, group);
                            groupInx += 2;
                        }
                        return true;
                    } else {
                        return false;
                    }
                }
                default -> throw new IllegalStateException("Unsupported segment type: " + segment.type);
            }
        }
        return uri.isEmpty();
    }

    private static int readText(String input, boolean requiresSlash) {
        // NOTE: Micronaut doesn't allow some of the character in the path value
        int length = input.length();
        for (int i = 0; i < length; i++) {
            char c = input.charAt(i);
            if (requiresSlash && c == '/') {
                return i;
            }
            if (rejectCharacter(c, input, i)) {
                return -1;
            }
        }
        return length;
    }

    private static boolean rejectCharacter(char c, String input, int i) {
        switch (c) {
            case '/':
            case '?':
            case '{':
            case '}':
            case '&':
            case ';':
            case '+':
                return true;
            case '#':
                if (i + 1 < input.length()) {
                    c = input.charAt(i + 1);
                    if (c != '{') {
                        return true;
                    }
                }
            default:
                return false;
        }
    }

    /**
     * Nests another URI template with this template.
     *
     * @param uriTemplate The URI template. If it does not begin with forward slash it will automatically be appended with forward slash
     * @return The new URI template
     */
    public UriTemplateMatcher nest(CharSequence uriTemplate) {
        List newParts = UriTemplateParser.parse(uriTemplate.toString());
        return new UriTemplateMatcher(templateString + uriTemplate, UriTemplateParser.concat(parts, newParts));
    }

    /**
     * Returns the path string excluding any query variables.
     *
     * @return The path string
     */
    public String toPathString() {
        StringBuilder builder = new StringBuilder();
        visitParts(parts, new UriTemplateParser.PartVisitor() {
            @Override
            public void visitLiteral(String literal) {
                builder.append(literal);
            }

            @Override
            public void visitExpression(UriTemplateParser.ExpressionType type, List variables) {
                builder.append('{');
                if (type != UriTemplateParser.ExpressionType.NONE) {
                    builder.append(type.getOperator());
                }
                for (Iterator iterator = variables.iterator(); iterator.hasNext(); ) {
                    UriTemplateParser.Variable variable = iterator.next();
                    builder.append(variable.name());
                    if (variable.explode()) {
                        builder.append('*');
                    }
                    if (variable.modifier() != null) {
                        builder.append(':');
                        builder.append(variable.modifier());
                    }
                    if (iterator.hasNext()) {
                        builder.append(',');
                    }
                }
                builder.append('}');
            }
        });
        return builder.toString();
    }

    /**
     * Expand the string with the given parameters.
     *
     * @param parameters The parameters
     * @return The expanded URI
     */
    public String expand(Map parameters) {
        UriTemplateExpander uriTemplateExpander = new UriTemplateExpander(parameters);
        visitParts(parts, uriTemplateExpander);
        return uriTemplateExpander.toString();
    }

    @Override
    public int compareTo(UriTemplateMatcher o) {
        if (this == o) {
            return 0;
        }

        PathEvaluator thisEvaluator = new PathEvaluator();
        PathEvaluator thatEvaluator = new PathEvaluator();

        visitParts(parts, thisEvaluator);
        visitParts(o.parts, thatEvaluator);

        // using that.compareTo because more raw length should have higher precedence
        int rawCompare = Integer.compare(thatEvaluator.rawLength, thisEvaluator.rawLength);
        if (rawCompare == 0) {
            return Integer.compare(thisEvaluator.variableCount, thatEvaluator.variableCount);
        }
        return rawCompare;
    }

    @Override
    public String toString() {
        return toPathString();
    }

    private static void visitParts(List parts, UriTemplateParser.PartVisitor visitor) {
        for (UriTemplateParser.Part part : parts) {
            part.visit(visitor);
        }
    }

    private boolean isRoot(String uri) {
        int length = uri.length();
        return length == 0 || length == 1 && uri.charAt(0) == '/';
    }

    /**
     * /**
     * Create a new {@link UriTemplate} for the given URI.
     *
     * @param uri The URI
     * @return The template
     */
    public static UriTemplateMatcher of(String uri) {
        return new UriTemplateMatcher(uri);
    }

    private static final class PathEvaluator implements UriTemplateParser.PartVisitor {

        int variableCount = 0;
        int rawLength = 0;

        @Override
        public void visitLiteral(String literal) {
            rawLength += literal.length();
        }

        @Override
        public void visitExpression(UriTemplateParser.ExpressionType type, List variables) {
            if (!type.isQueryPart()) {
                variableCount += variables.size();
            }
        }
    }

    private record Segment(SegmentType type, String value,
                           Pattern pattern, String[] regexpVariables) {
    }

    private enum SegmentType {
        LITERAL, PATH, REGEXP
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy