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

org.sonar.java.checks.spring.MissingPathVariableAnnotationCheck Maven / Gradle / Ivy

The newest version!
/*
 * SonarQube Java
 * Copyright (C) 2012-2025 SonarSource SA
 * mailto:info AT sonarsource DOT com
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the Sonar Source-Available License for more details.
 *
 * You should have received a copy of the Sonar Source-Available License
 * along with this program; if not, see https://sonarsource.com/license/ssal/
 */
package org.sonar.java.checks.spring;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nullable;
import org.sonar.check.Rule;
import org.sonar.plugins.java.api.IssuableSubscriptionVisitor;
import org.sonar.plugins.java.api.semantic.SymbolMetadata;
import org.sonar.plugins.java.api.semantic.Type;
import org.sonar.plugins.java.api.tree.AnnotationTree;
import org.sonar.plugins.java.api.tree.ClassTree;
import org.sonar.plugins.java.api.tree.MethodTree;
import org.sonar.plugins.java.api.tree.Tree;
import org.sonar.plugins.java.api.tree.VariableTree;

@Rule(key = "S6856")
public class MissingPathVariableAnnotationCheck extends IssuableSubscriptionVisitor {
  private static final String PATH_VARIABLE_ANNOTATION = "org.springframework.web.bind.annotation.PathVariable";
  private static final String MAP = "java.util.Map";
  private static final String MODEL_ATTRIBUTE_ANNOTATION = "org.springframework.web.bind.annotation.ModelAttribute";
  private static final String REQUEST_MAPPING_ANNOTATION = "org.springframework.web.bind.annotation.RequestMapping";

  private static final Set MAPPING_ANNOTATIONS = Set.of(
    REQUEST_MAPPING_ANNOTATION,
    "org.springframework.web.bind.annotation.GetMapping",
    "org.springframework.web.bind.annotation.PostMapping",
    "org.springframework.web.bind.annotation.PutMapping",
    "org.springframework.web.bind.annotation.DeleteMapping",
    "org.springframework.web.bind.annotation.PatchMapping");

  @Override
  public List nodesToVisit() {
    return List.of(Tree.Kind.CLASS);
  }

  @Override
  public void visitNode(Tree tree) {
    ClassTree clazzTree = (ClassTree) tree;

    List methods = clazzTree.members().stream()
      .filter(member -> member.is(Tree.Kind.METHOD))
      .map(MethodTree.class::cast)
      .toList();

    // request @RequestMapping can be put on top of a class, path template inside it will affect all the class methods
    var requestMappingArguments = clazzTree.symbol().metadata().valuesForAnnotation(REQUEST_MAPPING_ANNOTATION);
    Set requestMappingTemplateVariables = new HashSet<>();
    if (requestMappingArguments != null) {
      try {
        requestMappingTemplateVariables = templateVariablesFromMapping(requestMappingArguments);
      } catch (DoNotReport ignored) {
        return;
      }
    }

    Set modelAttributeMethodParameter = extractModelAttributeMethodParameter(methods);

    checkParametersAndPathTemplate(methods, modelAttributeMethodParameter, requestMappingTemplateVariables);
  }

  private static Set extractModelAttributeMethodParameter(List methods){
    Set modelAttributeMethodParameter = new HashSet<>();
    for (var method : methods) {
      if (!method.symbol().metadata().isAnnotatedWith(MODEL_ATTRIBUTE_ANNOTATION)) {
        continue;
      }
      for (var parameter : method.parameters()) {
        SymbolMetadata metadata = parameter.symbol().metadata();
        var arguments = metadata.valuesForAnnotation(PATH_VARIABLE_ANNOTATION);
        if (arguments != null) {
          modelAttributeMethodParameter.add(extractPathMethodParameters(parameter, arguments).value);
        }
      }
    }
    return modelAttributeMethodParameter;
  }

  private void checkParametersAndPathTemplate(List methods, Set modelAttributeMethodParameter, Set requestMappingTemplateVariables) {
    for (var method : methods) {
      if (!method.symbol().metadata().isAnnotatedWith(MODEL_ATTRIBUTE_ANNOTATION)) {
        try {
          checkParametersAndPathTemplate(method, modelAttributeMethodParameter, requestMappingTemplateVariables);
        } catch (DoNotReport ignored) {
          // We don't want to report when semantics is broken or we were unable to parse the path template
        }
      }
    }
  }

  private void checkParametersAndPathTemplate(MethodTree method, Set modelAttributeMethodParameters, Set requestMappingTemplateVars) {
    // we find path variable annotations and extract the name
    // example find : @PathVariable() String id and extract id
    List methodParameters = new ArrayList<>();
    for (var parameter : method.parameters()) {
      SymbolMetadata metadata = parameter.symbol().metadata();

      if (metadata.annotations().stream().anyMatch(ann -> ann.symbol().isUnknown())) {
        throw new DoNotReport();
      }

      var arguments = metadata.valuesForAnnotation(PATH_VARIABLE_ANNOTATION);
      if (arguments != null) {
        methodParameters.add(extractPathMethodParameters(parameter, arguments));
      }

    }

    // we find mapping annotation and extract path
    // example find @GetMapping("/{id}") and extract "/{id}"
    List>> templateVariables = new ArrayList<>();
    for (var ann : method.modifiers().annotations()) {
      if (ann.symbolType().isUnknown()) {
        throw new DoNotReport();
      }

      String fullyQualifiedName = ann.annotationType().symbolType().fullyQualifiedName();
      var values = method.symbol().metadata().valuesForAnnotation(fullyQualifiedName);
      if (values == null || !MAPPING_ANNOTATIONS.contains(fullyQualifiedName)) {
        continue;
      }

      templateVariables.add(new UriInfo<>(ann, templateVariablesFromMapping(values)));
    }

    // we handle the case where a path variable doesn't match to an uri parameter (/{aParam}/)
    Set allTemplateVariables = templateVariables.stream()
      .flatMap(uri -> uri.value().stream())
      .collect(Collectors.toSet());
    allTemplateVariables.addAll(requestMappingTemplateVars);

    methodParameters.stream()
      .filter(v -> !allTemplateVariables.contains(v.value()))
      .filter(v -> !v.parameter().symbol().type().is(MAP))
      .forEach(v -> reportIssue(v.parameter(), String.format("Bind method parameter \"%s\" to a template variable.", v.value())));

    if (containsTypeMapAsParameter(method)) {
      /*
       * If any of the method parameters is a map, we assume all path variables are captured
       * and there is no mismatch with path variables in the request mapping.
       */
      return;
    }

    Set allPathVariables = methodParameters.stream()
      .map(ParameterInfo::value)
      .collect(Collectors.toSet());
    allPathVariables.addAll(modelAttributeMethodParameters);

    templateVariables.stream()
      .filter(uri -> !allPathVariables.containsAll(uri.value()))
      .forEach(uri -> {
        Set unbind = new HashSet<>(uri.value());
        unbind.removeAll(allPathVariables);
        reportIssue(
          uri.request(),
          "Bind template variable \"" + String.join("\", \"", unbind) + "\" to a method parameter.");
      });

  }

  private static boolean containsTypeMapAsParameter(MethodTree method) {
    return method.parameters().stream()
      .filter(parameter -> parameter.symbol().metadata().isAnnotatedWith(PATH_VARIABLE_ANNOTATION))
      .anyMatch(parameter -> {
        Type type = parameter.type().symbolType();
        return type.isSubtypeOf(MAP);
      });
  }

  private static Set templateVariablesFromMapping(List values) {
    Map nameToValue = values.stream()
      .collect(Collectors.toMap(SymbolMetadata.AnnotationValue::name, SymbolMetadata.AnnotationValue::value));
    List path = arrayOrString(nameToValue.get("path"));
    List value = arrayOrString(nameToValue.get("value"));

    if (path != null || value != null) {
      List paths = path != null ? path : value;
      return paths.stream()
        .map(PathPatternParser::parsePathVariables)
        .flatMap(Collection::stream)
        .collect(Collectors.toSet());
    } else {
      return Set.of();
    }
  }

  private static ParameterInfo extractPathMethodParameters(VariableTree parameter, List arguments) {
    Map argNameToValue = arguments.stream().collect(
      Collectors.toMap(SymbolMetadata.AnnotationValue::name, SymbolMetadata.AnnotationValue::value));

    String value = (String) argNameToValue.get("value");
    String name = (String) argNameToValue.get("name");
    if (value != null) {
      return new ParameterInfo(parameter, value);
    } else if (name != null) {
      return new ParameterInfo(parameter, name);
    } else {
      return new ParameterInfo(parameter, parameter.simpleName().name());
    }
  }

  @Nullable
  private static List arrayOrString(@Nullable Object value) {
    if (value == null) {
      return null;
    }

    Object[] array = (Object[]) value;
    return Stream.of(array)
      .map(el -> (String) el)
      .toList();
  }

  static class DoNotReport extends RuntimeException {
  }

  private record ParameterInfo(VariableTree parameter, String value) {
  }
  private record UriInfo(AnnotationTree request, A value) {
  }

  static class PathPatternParser {
    private PathPatternParser() {
    }

    private static final String REST_PATH_WILDCARD = "{**}";
    private static final String PREFIX_REST_PATH_VARIABLE = "{*";
    private static final String PREFIX_REGEX_PATH_VARIABLE = "{";

    private static int pos;
    private static String path;
    private static Set vars;

    public static Set parsePathVariables(String urlPath) {
      pos = 0;
      path = urlPath;
      vars = new HashSet<>();

      while (pos < path.length()) {

        if (!matchPrefix("{")) {
          consumeCurrentChar();
        } else if (!ifMatchConsumeRestPathWildcard() && !ifMatchConsumeRestPathVariable()) {

          consumeRegexPathVariable();
        }
      }
      return vars;
    }

    // match and consume exactly "{**}"
    private static boolean ifMatchConsumeRestPathWildcard() {
      if (matchPrefix(REST_PATH_WILDCARD)) {
        consumePrefix(REST_PATH_WILDCARD);
        return true;
      } else {
        return false;
      }
    }

    // match and consume "{*name}"
    private static boolean ifMatchConsumeRestPathVariable() {
      if (!matchPrefix(PREFIX_REST_PATH_VARIABLE)) {
        return false;
      }

      consumePrefix(PREFIX_REST_PATH_VARIABLE);
      int startTemplateVar = pos;

      while (pos < path.length()) {
        char current = consumeCurrentChar();
        if (current == '}') {
          vars.add(substringToCurrentChar(startTemplateVar));
          return true;
        }
      }
      throw new DoNotReport();
    }

    // consume "{name}" or "{name:regex}"
    private static void consumeRegexPathVariable() {

      if (!matchPrefix(PREFIX_REGEX_PATH_VARIABLE)) {
        throw new DoNotReport();
      }

      if (matchPrefix("{}")) {
        throw new DoNotReport();
      }

      consumePrefix(PREFIX_REGEX_PATH_VARIABLE);
      int startTemplateVar = pos;

      while (pos < path.length()) {
        char current = consumeCurrentChar();

        if (current == '}') {
          vars.add(substringToCurrentChar(startTemplateVar));
          return;
        } else if (current == ':') {
          vars.add(substringToCurrentChar(startTemplateVar));
          consumeRegex();
          return;
        }

      }
      throw new DoNotReport();
    }

    // consume "regex}"
    // the regular expression can be written as regex = "([^{}]*regex)*}"
    // remark it is a recursive definition
    private static void consumeRegex() {
      while (pos < path.length()) {
        char current = consumeCurrentChar();

        if (current == '}') {
          return;
        } else if (current == '{') {
          consumeRegex();
        }
      }
      throw new DoNotReport();
    }

    private static boolean matchPrefix(String prefix) {
      int endPosPrefix = pos + prefix.length();
      if (endPosPrefix <= path.length()) {
        return prefix.equals(path.substring(pos, endPosPrefix));
      } else {
        return false;
      }
    }

    // for consumeCurrentChar, consumePrefix. We assume bound check on the path were done. We may add assert to ensure it.
    private static char consumeCurrentChar() {
      ++pos;
      return path.charAt(pos - 1);
    }

    private static void consumePrefix(String prefix) {
      pos += prefix.length();
    }

    // return substring from start up to the last consumed character (excluded)
    private static String substringToCurrentChar(int start) {
      return path.substring(start, pos - 1);
    }

  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy