diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/README.md b/core-maven-plugins/httproutes-generator-maven-plugin/README.md
index 6710a6dd46..b136674684 100644
--- a/core-maven-plugins/httproutes-generator-maven-plugin/README.md
+++ b/core-maven-plugins/httproutes-generator-maven-plugin/README.md
@@ -2,14 +2,20 @@
This module contains `com.netcracker.cloud.plugins:httproutes-generator-maven-plugin`.
It scans compiled Java classes and generates Gateway API `HTTPRoute` manifests
-for Istio deployments.
+and Istio `AuthorizationPolicy` manifests for Istio deployments.
## What It Does
- Scans Spring MVC and Quarkus/JAX-RS endpoints from compiled classes.
-- Includes only classes/methods annotated with `@Route`.
-- Supports gateway path remapping via `@Gateway` and `@GatewayRequestMapping`.
-- Groups generated routes by route type (`INTERNAL`, `PRIVATE`, `PUBLIC`, `FACADE`).
+- Includes only classes/methods annotated with `@Route`, `@Routes`, `@FacadeRoute` or `@ForbiddenRoute`.
+- Supports gateway path remapping via `@Gateway` and `@GatewayRequestMapping`, and facade gateway paths via
+ `@FacadeGateway` and `@FacadeGatewayRequestMapping`.
+- Generates only `PathPrefix` path matches (see [How Paths Are Matched](#how-paths-are-matched)).
+- Groups generated routes by route type (`PUBLIC`, `PRIVATE`, `INTERNAL`, `FACADE`).
+- Generates `AuthorizationPolicy` resources with `DENY` rules for paths that must not be reachable
+ through a gateway (see [Forbidden Routes and AuthorizationPolicies](#forbidden-routes-and-authorizationpolicies)).
+- Fails the build before writing anything when the generated routing can't keep the legacy behavior
+ (see [Route Errors](#route-errors)).
- Emits Helm-ready YAML wrapped in:
`{{- if eq .Values.SERVICE_MESH_TYPE "Istio" }}`.
- Runs as an **aggregator** mojo, so it collects routes across reactor modules.
@@ -31,7 +37,7 @@ Add the plugin to your `pom.xml`:
com.netcracker.cloud.pluginshttproutes-generator-maven-plugin
- 1.0.0
+ 1.0.0
@@ -45,7 +51,7 @@ Add the plugin to your `pom.xml`:
8080helm-templates/my-service/templates/annotations-httproutes.yaml
- {{ .Values.DEPLOYMENT_RESOURCE_NAME }}
+ {{ .Values.SERVICE_NAME }}
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.15
+
+
+
+ prepare-agent
+
+
+
+ report
+ verify
+
+ report
+
+
+
+
diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRenderer.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRenderer.java
new file mode 100644
index 0000000000..1e08fa6f3f
--- /dev/null
+++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRenderer.java
@@ -0,0 +1,193 @@
+package com.netcracker.routes.gateway.plugin;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Renders one DENY {@code AuthorizationPolicy} per external gateway, public then private. Exposure on the internal
+ * gateway needs no rule, like that of facade routes.
+ *
+ * Legacy gateways forbid a route on the gateways wider than its type, and don't route a path no route matches.
+ * Istio routes by the {@code PathPrefix} cut from each gateway path, so it routes some of these paths:
+ *
+ *
a narrower route's path below the prefix of a route exposed on the gateway;
+ *
the prefix itself, cut from a route with variables, if no route exposed on the gateway matches it.
+ *
+ * These paths need DENY rules. They are generated if {@code autoGenerateAuthorizationPolicies} is set, else they must
+ * be declared with {@code @ForbiddenRoute}, as all other forbidden paths.
+ */
+public class AuthorizationPolicyRenderer {
+
+ private static final List PORTS = List.of("8080");
+ private static final String ACTION_DENY = "DENY";
+ private static final List GATEWAYS =
+ List.of(HttpRoute.Type.PUBLIC, HttpRoute.Type.PRIVATE);
+
+ private final Map labels;
+ private final boolean autoGenerateAuthorizationPolicies;
+
+ public AuthorizationPolicyRenderer(Map labels, boolean autoGenerateAuthorizationPolicies) {
+ this.labels = labels == null ? Collections.emptyMap() : labels;
+ this.autoGenerateAuthorizationPolicies = autoGenerateAuthorizationPolicies;
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public record AuthorizationPolicyResource(String apiVersion, String kind, Metadata metadata, Spec spec) {
+
+ public AuthorizationPolicyResource(Metadata metadata, Spec spec) {
+ this("security.istio.io/v1", "AuthorizationPolicy", metadata, spec);
+ }
+
+ public record Metadata(String name, Map labels) {
+ }
+
+ public record Spec(List targetRefs, String action, List rules) {
+
+ public record TargetRef(String group, String kind, String name) {
+ }
+
+ public record Rule(List to) {
+
+ public record To(Operation operation) {
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ public record Operation(List ports, List paths, List notPaths) {
+ }
+ }
+ }
+ }
+
+ /**
+ * A path forbidden on a gateway that Istio would route without a DENY rule.
+ */
+ private record Missing(String reason, Set gateways) {
+ }
+
+ public String generateAuthorizationPoliciesYaml(Set routes, Set forbidden, Problems problems) {
+ List border = routes.stream().filter(r -> r.type() != HttpRoute.Type.FACADE).toList();
+ Map missing = new TreeMap<>();
+ Set inexpressible = new TreeSet<>();
+ StringBuilder yaml = new StringBuilder();
+ for (HttpRoute.Type gateway : GATEWAYS) {
+ Collection rules =
+ denyRules(gateway, border, forbidden, missing, inexpressible, problems);
+ if (!rules.isEmpty()) {
+ yaml.append(HttpRouteRenderer.writeYaml(toResource(gateway, List.copyOf(rules))));
+ }
+ }
+ missing.forEach((path, m) -> problems.error(path + " " + m.reason() + " on " + gatewayNames(m.gateways())
+ + ": add @ForbiddenRoute({" + m.gateways().stream().sorted(Comparator.reverseOrder()).map(Enum::name)
+ .collect(Collectors.joining(", ")) + "}) to the element mapped to " + path));
+ inexpressible.forEach(path -> problems.error(path + " is a forbidden path or overlaps one, and an "
+ + "AuthorizationPolicy can't express it: variables must take up whole path segments"));
+ return yaml.toString();
+ }
+
+ private Collection denyRules(HttpRoute.Type gateway, List border,
+ Set forbidden,
+ Map missing,
+ Set inexpressible, Problems problems) {
+ Set allowed = border.stream().filter(r -> r.type().compareTo(gateway) >= 0)
+ .map(HttpRoute::gatewayPath).collect(Collectors.toCollection(TreeSet::new));
+ Set allowedTemplates = allowed.stream().map(RoutePaths::template).collect(Collectors.toSet());
+ Set implicit = border.stream().filter(r -> r.type().compareTo(gateway) < 0)
+ .map(HttpRoute::gatewayPath).filter(p -> !allowedTemplates.contains(RoutePaths.template(p)))
+ .collect(Collectors.toCollection(TreeSet::new));
+ Set explicit = forbidden.stream().filter(f -> f.gateways().contains(gateway))
+ .map(ForbiddenPath::gatewayPath).collect(Collectors.toCollection(TreeSet::new));
+ explicit.stream().filter(p -> allowedTemplates.contains(RoutePaths.template(p))).forEach(p -> problems.error(
+ "@ForbiddenRoute forbids " + p + " on " + gateway.gatewayName() + ", where a route with this gateway path is exposed"));
+
+ Map needed = new TreeMap<>();
+ Set prefixes = allowed.stream().map(RoutePaths::cut).collect(Collectors.toCollection(TreeSet::new));
+ for (String path : implicit) {
+ if (legacyRoute(RoutePaths.sample(path, path), allowed, implicit).filter(allowed::contains).isPresent()) {
+ continue;
+ }
+ prefixes.stream().filter(prefix -> RoutePaths.covers(prefix, path)).findFirst().ifPresent(prefix -> needed.put(path,
+ "is forbidden by legacy, as its route type is narrower, but Istio routes it by PathPrefix " + prefix));
+ }
+ for (String path : allowed) {
+ String prefix = RoutePaths.cut(path);
+ if (!RoutePaths.hasVariable(path)) {
+ continue;
+ }
+ if (legacyRoute(prefix, allowed, implicit).filter(allowed::contains).isEmpty()) {
+ needed.putIfAbsent(prefix,
+ "is not routed by legacy, but Istio routes it by PathPrefix " + prefix + " cut from " + path);
+ }
+ }
+
+ Set explicitTemplates = explicit.stream().map(RoutePaths::template).collect(Collectors.toSet());
+ Set rulePaths = new TreeSet<>(explicit);
+ needed.forEach((path, reason) -> {
+ if (autoGenerateAuthorizationPolicies) {
+ rulePaths.add(path);
+ } else if (!explicitTemplates.contains(RoutePaths.template(path))) {
+ missing.computeIfAbsent(path, p -> new Missing(reason, EnumSet.noneOf(HttpRoute.Type.class)))
+ .gateways().add(gateway);
+ }
+ });
+
+ Map rules = new TreeMap<>();
+ for (String path : rulePaths) {
+ List overlapping = allowed.stream()
+ .filter(route -> route.length() > path.length() && RoutePaths.overlaps(path, route)).toList();
+ // Probes the rule path and its intersection with every overlapping allowed route: the route legacy routes
+ // a probe by, if the rule denies the probe, is excluded too, so that the rule denies nothing legacy routed
+ Set excluded = new TreeSet<>(overlapping);
+ Stream.concat(Stream.of(path), allowed.stream().filter(route -> RoutePaths.overlaps(path, route)))
+ .map(route -> RoutePaths.sample(path, route))
+ .forEach(request -> legacyRoute(request, allowed, implicit).filter(allowed::contains)
+ .ifPresent(excluded::add));
+ if (explicit.contains(path)) {
+ // An explicit rule denies its path even where legacy routed it by a wider route
+ excluded.removeIf(route -> RoutePaths.covers(route, RoutePaths.sample(path, path)));
+ }
+ List invalid = Stream.concat(Stream.of(path), excluded.stream())
+ .filter(p -> !RoutePaths.expressible(p)).toList();
+ if (!invalid.isEmpty()) {
+ inexpressible.addAll(invalid);
+ continue;
+ }
+ List notPaths = excluded.stream().map(RoutePaths::template).distinct().sorted()
+ .flatMap(t -> subtree(t).stream()).toList();
+ rules.putIfAbsent(RoutePaths.template(path), new AuthorizationPolicyResource.Spec.Rule(List.of(
+ new AuthorizationPolicyResource.Spec.Rule.To(new AuthorizationPolicyResource.Spec.Rule.Operation(
+ PORTS, subtree(RoutePaths.template(path)), notPaths)))));
+ }
+ return rules.values();
+ }
+
+ /**
+ * @return the route legacy matches a literal request path by: the longest covering one, the forbidden one on a tie
+ */
+ private static Optional legacyRoute(String request, Set allowed, Set implicit) {
+ return Stream.concat(allowed.stream(), implicit.stream()).filter(route -> RoutePaths.covers(route, request))
+ .max(Comparator.comparingInt(String::length).thenComparing(route -> !allowed.contains(route)));
+ }
+
+ private static List subtree(String template) {
+ return List.of(template, template.equals("/") ? "/{**}" : template + "/{**}");
+ }
+
+ private static String gatewayNames(Set gateways) {
+ return gateways.stream().sorted(Comparator.reverseOrder()).map(HttpRoute.Type::gatewayName)
+ .collect(Collectors.joining(", "));
+ }
+
+ private AuthorizationPolicyResource toResource(HttpRoute.Type gateway, List rules) {
+ AuthorizationPolicyResource.Metadata metadata = new AuthorizationPolicyResource.Metadata(
+ "{{ .Values.SERVICE_NAME }}-java-annotations-deny-" + gateway.name().toLowerCase(),
+ HttpRouteRenderer.buildRouteLabels(labels));
+ HttpRouteRenderer.HTTPRouteResource.Spec.ParentRef ref = HttpRouteRenderer.gatewayParentRef(gateway.gatewayName());
+ AuthorizationPolicyResource.Spec.TargetRef targetRef =
+ new AuthorizationPolicyResource.Spec.TargetRef(ref.group(), ref.kind(), ref.name());
+ return new AuthorizationPolicyResource(metadata,
+ new AuthorizationPolicyResource.Spec(List.of(targetRef), ACTION_DENY, rules));
+ }
+}
diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenPath.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenPath.java
new file mode 100644
index 0000000000..3fff4b3a14
--- /dev/null
+++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/ForbiddenPath.java
@@ -0,0 +1,14 @@
+package com.netcracker.routes.gateway.plugin;
+
+import java.util.Set;
+
+/**
+ * A gateway path that {@code @ForbiddenRoute} forbids on the given border gateways.
+ */
+public record ForbiddenPath(String gatewayPath, Set gateways) {
+
+ public ForbiddenPath {
+ gatewayPath = gatewayPath.startsWith("/") ? gatewayPath : "/" + gatewayPath;
+ gateways = Set.copyOf(gateways);
+ }
+}
diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java
index a85fa7299f..26a625baec 100644
--- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java
+++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/GenerateRoutesMojo.java
@@ -2,16 +2,17 @@
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Map;
-import java.util.Set;
import java.util.stream.Collectors;
@Mojo(
@@ -36,17 +37,45 @@ public class GenerateRoutesMojo extends AbstractMojo {
@Parameter(defaultValue = "gateway-httproutes.yaml")
private String outputFile;
- @Parameter(defaultValue = "{{ .Values.DEPLOYMENT_RESOURCE_NAME }}")
+ @Parameter(defaultValue = "{{ .Values.SERVICE_NAME }}")
private String backendRefVal;
@Parameter
private List labels = Collections.emptyList();
+ /**
+ * Whether to generate the DENY rules that the migration needs and {@code @ForbiddenRoute} doesn't declare.
+ */
+ @Parameter(defaultValue = "false")
+ private boolean autoGenerateAuthorizationPolicies;
+
@Override
- public void execute() throws MojoExecutionException {
- RouteScanner scanner = new RouteScanner(packages, getLog());
- Set allRoutes = scanner.collectRoutes(reactorProjects);
- writeRoutesFile(allRoutes);
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ execute(project.getBasedir().toPath(), scanner -> scanner.collect(reactorProjects));
+ }
+
+ /**
+ * Scans and renders the routes, and writes the output file only if there are no errors.
+ */
+ void execute(Path baseDir, Scan scan) throws MojoExecutionException, MojoFailureException {
+ RouteScanner.Declarations declarations = scan.declarations(new RouteScanner(packages, getLog()));
+ Problems problems = new Problems();
+ problems.errors().addAll(declarations.errors());
+ String yaml = new HttpRouteRenderer(backendRefVal, labelsAsMap())
+ .generateHttpRoutesYaml(servicePort, declarations.routes(), problems)
+ + new AuthorizationPolicyRenderer(labelsAsMap(), autoGenerateAuthorizationPolicies)
+ .generateAuthorizationPoliciesYaml(declarations.routes(), declarations.forbidden(), problems);
+ problems.warnings().forEach(getLog()::warn);
+ problems.errors().forEach(getLog()::error);
+ if (!problems.errors().isEmpty()) {
+ throw new MojoFailureException(problems.errors().size() + " route migration errors, see log");
+ }
+ writeRoutesFile(baseDir, yaml);
+ }
+
+ @FunctionalInterface
+ interface Scan {
+ RouteScanner.Declarations declarations(RouteScanner scanner) throws MojoExecutionException;
}
private Map labelsAsMap() {
@@ -58,14 +87,10 @@ private Map labelsAsMap() {
.collect(Collectors.toMap(Label::getKey, l -> l.getValue() != null ? l.getValue() : ""));
}
- private void writeRoutesFile(Set routes) throws MojoExecutionException {
+ private void writeRoutesFile(Path baseDir, String yaml) throws MojoExecutionException {
try {
- java.nio.file.Path file = project.getBasedir()
- .toPath()
- .resolve(outputFile);
-
+ Path file = baseDir.resolve(outputFile);
- String yaml = new HttpRouteRenderer(backendRefVal, labelsAsMap()).generateHttpRoutesYaml(servicePort, routes);
Files.createDirectories(file.getParent());
Files.writeString(file, prependYamlHeader(wrapWithEnabler(yaml)));
getLog().info(String.format("Generated gateway routes CRs at %s", outputFile));
diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/HttpRouteRenderer.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/HttpRouteRenderer.java
index bbf5303b15..189a2db4e3 100644
--- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/HttpRouteRenderer.java
+++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/HttpRouteRenderer.java
@@ -6,24 +6,24 @@
import java.util.*;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
public class HttpRouteRenderer {
private static final ObjectMapper YAML_MAPPER = yamlMapper();
private static final String MATCH_TYPE_PATH_PREFIX = "PathPrefix";
- private static final String MATCH_TYPE_REGULAR_EXPRESSION = "RegularExpression";
private static final String FILTER_TYPE_URL_REWRITE = "URLRewrite";
private static final String FILTER_PATH_TYPE_REPLACE_PREFIX_MATCH = "ReplacePrefixMatch";
- private static final String REGEX_MANUAL_REVIEW_WARNING = """
- # MANUAL REVIEW REQUIRED
- # RegularExpression path matches may conflict with sibling rules with Prefix matches.
- # Also, replacePrefixMatch only works with PathPrefix matches and cannot be
- # used with RegularExpression matches. Test regex matching routes carefully.
- """;
+ private static final List RESOURCE_ORDER =
+ List.of(HttpRoute.Type.PUBLIC, HttpRoute.Type.PRIVATE, HttpRoute.Type.INTERNAL, HttpRoute.Type.FACADE);
private static final long SECOND = 1_000;
private static final long MINUTE = 60_000;
private static final long HOUR = 3_600_000;
+ /**
+ * The gateway request timeout of a route without a timeout.
+ */
+ private static final long DEFAULT_TIMEOUT = 2 * MINUTE;
public static final String PARENT_REF_KIND_SERVICE = "Service";
public static final String PARENT_REF_KIND_GATEWAY = "Gateway";
public static final String PARENT_REF_GROUP_GATEWAY = "gateway.networking.k8s.io";
@@ -91,34 +91,88 @@ public record BackendRef(String group, String kind, String name, Integer port, I
}
}
- public String generateHttpRoutesYaml(int servicePort, Set httpRoutes) {
- List routes = createHttpRoutes(servicePort, httpRoutes);
-
- return routes.stream()
- .map(HttpRouteRenderer::renderValidatedYaml)
- .collect(Collectors.joining());
+ /**
+ * One rule of the generated HTTPRoutes.
+ *
+ * @param resource resource the rule goes into; {@code FACADE} is the service-bound HTTPRoute
+ * @param value {@code PathPrefix} value
+ * @param rewrite {@code ReplacePrefixMatch} value, or {@code null} for no rewrite
+ */
+ private record PlannedRule(HttpRoute.Type resource, String value, String rewrite, long timeout) {
}
- private static HTTPRouteResource.Spec.Match.Path convertSpringPathToHttpRoutePath(String springPath) {
- if (springPath == null || springPath.isEmpty()) {
- return new HTTPRouteResource.Spec.Match.Path(MATCH_TYPE_PATH_PREFIX, "/");
+ /**
+ * Cuts every gateway path at its first variable into a {@code PathPrefix} and merges the routes cut to one
+ * prefix into one rule. Merged timeouts are reported to {@code problems}.
+ */
+ public String generateHttpRoutesYaml(int servicePort, Set httpRoutes, Problems problems) {
+ List rules = planRules(httpRoutes.stream().filter(r -> r.type() != HttpRoute.Type.FACADE), problems);
+ List facadeRules = planRules(httpRoutes.stream().filter(r -> r.type() == HttpRoute.Type.FACADE), problems);
+ if (facadeRules.stream().anyMatch(r -> r.rewrite() != null)) {
+ rules.addAll(facadeRules);
+ } else if (facadeRules.stream().anyMatch(r -> r.timeout() > 0)) {
+ problems.warn("No facade or composite route has a rewrite, so no service-bound HTTPRoute is generated, "
+ + "and the timeouts of the facade and composite routes are not applied");
}
- if (springPath.contains("{")) {
- return new HTTPRouteResource.Spec.Match.Path(MATCH_TYPE_REGULAR_EXPRESSION, normalizePath(springPath.replaceAll("\\{([^/]+?)}", "([^/]+)")));
- } else {
- return new HTTPRouteResource.Spec.Match.Path(MATCH_TYPE_PATH_PREFIX, normalizePath(springPath));
+ List resources = new ArrayList<>();
+ for (HttpRoute.Type type : RESOURCE_ORDER) {
+ List typeRules = rules.stream().filter(r -> r.resource() == type).toList();
+ if (!typeRules.isEmpty()) {
+ resources.add(toResource(type, typeRules, servicePort));
+ }
}
+ return resources.stream()
+ .map(HttpRouteRenderer::writeYaml)
+ .collect(Collectors.joining());
}
- private static String normalizePath(String path) {
- if (path == null || path.isEmpty()) {
- return "/";
- }
- if (!path.startsWith("/")) {
- return "/" + path;
+ /**
+ * Groups the routes by cut gateway path across route types, because Istio can't tell apart two rules with one
+ * {@code PathPrefix} value. A group's rule goes into the widest type's resource; DENY rules take care of the
+ * gateways the narrower routes aren't exposed on.
+ */
+ private static List planRules(Stream routes, Problems problems) {
+ Map> groups = routes
+ .sorted(Comparator.comparing(HttpRoute::gatewayPath).thenComparing(HttpRoute::path)
+ .thenComparing(HttpRoute::type).thenComparingLong(HttpRoute::timeout))
+ .collect(Collectors.groupingBy(r -> RoutePaths.cut(r.gatewayPath()), TreeMap::new, Collectors.toList()));
+ List rules = new ArrayList<>();
+ groups.forEach((match, group) -> rules.add(prefixRule(match, group, problems)));
+ return rules;
+ }
+
+ /**
+ * @return the {@code ReplacePrefixMatch} value of a route cut to {@code match}, or empty for no rewrite
+ */
+ private static Optional rewriteOf(String match, HttpRoute route) {
+ String rewrite = RoutePaths.cut(route.path());
+ return rewrite.equals(match) ? Optional.empty() : Optional.of(rewrite);
+ }
+
+ /**
+ * Merges routes into one rule with the rewrite of the first route and the largest timeout, where a route without
+ * a timeout counts as {@link #DEFAULT_TIMEOUT}, and warns if their timeouts differ.
+ */
+ private static PlannedRule prefixRule(String match, List routes, Problems problems) {
+ HttpRoute.Type widest = routes.stream().map(HttpRoute::type).max(Comparator.naturalOrder()).orElseThrow();
+ long largest = routes.stream().mapToLong(HttpRoute::timeout).max().orElse(0);
+ boolean defaultWins = largest < DEFAULT_TIMEOUT && routes.stream().anyMatch(r -> r.timeout() == 0);
+ long timeout = defaultWins ? 0 : largest;
+ if (routes.stream().mapToLong(HttpRoute::timeout).distinct().count() > 1) {
+ problems.warn("Routes " + describe(match, routes) + " are merged into one rule PathPrefix " + match
+ + " with the largest timeout" + (defaultWins ? ", the " + formatDuration(DEFAULT_TIMEOUT) + " default"
+ : " " + formatDuration(timeout)));
}
- return path;
+ return new PlannedRule(widest, match, rewriteOf(match, routes.get(0)).orElse(null), timeout);
+ }
+
+ private static String describe(String match, List routes) {
+ return routes.stream()
+ .map(r -> r.gatewayPath() + " (" + r.type() + ", "
+ + rewriteOf(match, r).map(rewrite -> "ReplacePrefixMatch " + rewrite).orElse("no rewrite")
+ + (r.timeout() > 0 ? ", timeout " + formatDuration(r.timeout()) : "") + ")")
+ .collect(Collectors.joining(", "));
}
public static String formatDuration(long ms) {
@@ -134,18 +188,7 @@ public static String formatDuration(long ms) {
return ms + "ms";
}
- private List createHttpRoutes(int servicePort, Set httpRoutes) {
- Map> routesByType = httpRoutes
- .stream()
- .collect(Collectors.groupingBy(HttpRoute::type));
-
- return routesByType.entrySet().stream()
- .map(entry -> toResource(entry.getKey(), entry.getValue(), servicePort))
- .filter(Objects::nonNull)
- .toList();
- }
-
- private HTTPRouteResource toResource(HttpRoute.Type type, List routes, int servicePort) {
+ private HTTPRouteResource toResource(HttpRoute.Type type, List rules, int servicePort) {
HTTPRouteResource.Metadata metadata =
new HTTPRouteResource.Metadata(
"{{ .Values.SERVICE_NAME }}-java-annotations-" + type.name().toLowerCase(),
@@ -155,27 +198,16 @@ private HTTPRouteResource toResource(HttpRoute.Type type, List routes
List backendRefs =
List.of(serviceBackendRef(this.backendRefVal, servicePort));
- List sortedRoutes = routes.stream()
- .sorted(pathSpecificityComparator())
+ List ruleList = rules.stream()
+ .sorted(Comparator.comparing(PlannedRule::value, pathSpecificity()))
+ .map(rule -> toRule(rule, backendRefs))
.toList();
- List ruleList = new ArrayList<>(sortedRoutes.size());
- for (HttpRoute route : sortedRoutes) {
- if (route.type() == HttpRoute.Type.FACADE && route.path().equals(route.gatewayPath())) {
- continue;
- }
- ruleList.add(toRule(route, backendRefs));
- }
-
- if (ruleList.isEmpty()) {
- return null;
- }
-
HTTPRouteResource.Spec spec = new HTTPRouteResource.Spec(getParentRefs(type), ruleList);
return new HTTPRouteResource(metadata, spec);
}
- private static Map buildRouteLabels(Map customRouteLabels) {
+ static Map buildRouteLabels(Map customRouteLabels) {
if (customRouteLabels == null || customRouteLabels.isEmpty()) {
return new TreeMap<>(DEFAULT_ROUTE_LABELS);
}
@@ -203,7 +235,7 @@ private static Set getParentRefs(HttpRoute.Typ
return parentRefs;
}
- private static HTTPRouteResource.Spec.ParentRef gatewayParentRef(String name) {
+ static HTTPRouteResource.Spec.ParentRef gatewayParentRef(String name) {
return new HTTPRouteResource.Spec.ParentRef(PARENT_REF_GROUP_GATEWAY, PARENT_REF_KIND_GATEWAY, name);
}
@@ -221,21 +253,22 @@ private static HTTPRouteResource.Spec.BackendRef serviceBackendRef(String name,
);
}
- static Comparator pathSpecificityComparator() {
+ /**
+ * More segments first, then longer path, then lexical order.
+ */
+ static Comparator pathSpecificity() {
return (left, right) -> {
- int leftSegments = pathSegmentCount(left.gatewayPath());
- int rightSegments = pathSegmentCount(right.gatewayPath());
+ int leftSegments = pathSegmentCount(left);
+ int rightSegments = pathSegmentCount(right);
if (leftSegments != rightSegments) {
return Integer.compare(rightSegments, leftSegments);
}
- int leftLength = left.gatewayPath().length();
- int rightLength = right.gatewayPath().length();
- if (leftLength != rightLength) {
- return Integer.compare(rightLength, leftLength);
+ if (left.length() != right.length()) {
+ return Integer.compare(right.length(), left.length());
}
- return left.gatewayPath().compareTo(right.gatewayPath());
+ return left.compareTo(right);
};
}
@@ -252,12 +285,12 @@ static int pathSegmentCount(String path) {
return count;
}
- private static HTTPRouteResource.Spec.Rule toRule(HttpRoute route, List backendRefs) {
+ private static HTTPRouteResource.Spec.Rule toRule(PlannedRule rule, List backendRefs) {
HTTPRouteResource.Spec.Match match = new HTTPRouteResource.Spec.Match(
- convertSpringPathToHttpRoutePath(route.gatewayPath()));
+ new HTTPRouteResource.Spec.Match.Path(MATCH_TYPE_PATH_PREFIX, rule.value()));
- List filters = buildRewriteFilter(route);
- HTTPRouteResource.Spec.Rule.Timeouts timeouts = buildTimeout(route);
+ List filters = buildRewriteFilter(rule);
+ HTTPRouteResource.Spec.Rule.Timeouts timeouts = buildTimeout(rule);
return new HTTPRouteResource.Spec.Rule(
List.of(match),
@@ -267,15 +300,13 @@ private static HTTPRouteResource.Spec.Rule toRule(HttpRoute route, List buildRewriteFilter(HttpRoute route) {
- String normalizedGateway = normalizePath(route.gatewayPath());
- String normalizedService = normalizePath(route.path());
- if (normalizedGateway.equals(normalizedService)) {
+ private static List buildRewriteFilter(PlannedRule rule) {
+ if (rule.rewrite() == null) {
return List.of();
}
HTTPRouteResource.Spec.Filter.UrlRewrite.Path rewritePath =
- new HTTPRouteResource.Spec.Filter.UrlRewrite.Path(FILTER_PATH_TYPE_REPLACE_PREFIX_MATCH, normalizedService);
+ new HTTPRouteResource.Spec.Filter.UrlRewrite.Path(FILTER_PATH_TYPE_REPLACE_PREFIX_MATCH, rule.rewrite());
HTTPRouteResource.Spec.Filter.UrlRewrite urlRewrite =
new HTTPRouteResource.Spec.Filter.UrlRewrite(rewritePath);
HTTPRouteResource.Spec.Filter filter =
@@ -283,38 +314,18 @@ private static List buildRewriteFilter(HttpRoute
return List.of(filter);
}
- private static HTTPRouteResource.Spec.Rule.Timeouts buildTimeout(HttpRoute route) {
- if (route.timeout() <= 0) {
+ private static HTTPRouteResource.Spec.Rule.Timeouts buildTimeout(PlannedRule rule) {
+ if (rule.timeout() <= 0) {
return null;
}
- return new HTTPRouteResource.Spec.Rule.Timeouts(formatDuration(route.timeout()));
- }
-
- private static String renderValidatedYaml(HTTPRouteResource route) {
- String renderedYaml = writeYaml(route);
- return hasRegularExpressionMatch(route) ? REGEX_MANUAL_REVIEW_WARNING + renderedYaml : renderedYaml;
- }
-
- private static boolean hasRegularExpressionMatch(HTTPRouteResource route) {
- if (route == null || route.spec() == null || route.spec().rules() == null) {
- return false;
- }
- return route.spec().rules().stream()
- .filter(Objects::nonNull)
- .map(HTTPRouteResource.Spec.Rule::matches)
- .filter(Objects::nonNull)
- .flatMap(List::stream)
- .filter(Objects::nonNull)
- .map(HTTPRouteResource.Spec.Match::path)
- .filter(Objects::nonNull)
- .anyMatch(path -> MATCH_TYPE_REGULAR_EXPRESSION.equals(path.type()));
+ return new HTTPRouteResource.Spec.Rule.Timeouts(formatDuration(rule.timeout()));
}
- private static String writeYaml(HTTPRouteResource route) {
+ static String writeYaml(Object resource) {
try {
- return YAML_MAPPER.writeValueAsString(route);
+ return YAML_MAPPER.writeValueAsString(resource);
} catch (Exception e) {
- throw new RuntimeException("Failed to serialize HTTPRoute Resource to YAML", e);
+ throw new RuntimeException("Failed to serialize " + resource.getClass().getSimpleName() + " to YAML", e);
}
}
diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Problems.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Problems.java
new file mode 100644
index 0000000000..105a66c22e
--- /dev/null
+++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/Problems.java
@@ -0,0 +1,22 @@
+package com.netcracker.routes.gateway.plugin;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Errors and warnings of route migration. Any error fails the build before the output file is written.
+ */
+public record Problems(List errors, List warnings) {
+
+ public Problems() {
+ this(new ArrayList<>(), new ArrayList<>());
+ }
+
+ void error(String message) {
+ errors.add(message);
+ }
+
+ void warn(String message) {
+ warnings.add(message);
+ }
+}
diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RoutePaths.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RoutePaths.java
new file mode 100644
index 0000000000..3668fa512b
--- /dev/null
+++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RoutePaths.java
@@ -0,0 +1,128 @@
+package com.netcracker.routes.gateway.plugin;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * Helpers for gateway paths with {@code {name}} variables, where a variable matches one path segment.
+ */
+final class RoutePaths {
+
+ private static final Pattern VARIABLE = Pattern.compile("\\{[^/{}]*}");
+ /**
+ * A literal that fills a variable in {@link #sample} and matches no literal of a route.
+ */
+ private static final String ANY = "~";
+
+ private RoutePaths() {
+ }
+
+ /**
+ * Cuts the path before the segment that contains the first variable, without the trailing slash.
+ *
+ * @return the cut path, or {@code /} if nothing remains
+ */
+ static String cut(String path) {
+ int variable = path.indexOf('{');
+ String prefix = variable < 0 ? path : path.substring(0, path.lastIndexOf('/', variable) + 1);
+ while (prefix.endsWith("/")) {
+ prefix = prefix.substring(0, prefix.length() - 1);
+ }
+ return prefix.isEmpty() ? "/" : prefix;
+ }
+
+ static boolean hasVariable(String path) {
+ return path.contains("{");
+ }
+
+ static List segments(String path) {
+ return Arrays.stream(path.split("/")).filter(s -> !s.isEmpty()).toList();
+ }
+
+ /**
+ * Compares the paths segment by segment, up to the shorter one: two variables always overlap,
+ * a variable overlaps a literal it matches, and two literals overlap if they are equal.
+ *
+ * @return whether some request path matches both paths
+ */
+ static boolean overlaps(String a, String b) {
+ List left = segments(a);
+ List right = segments(b);
+ for (int i = 0; i < Math.min(left.size(), right.size()); i++) {
+ if (!segmentsOverlap(left.get(i), right.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * @return whether {@code route} has no more segments than {@code path} and overlaps it: for a request path, whether
+ * the {@code PathPrefix} or legacy route {@code route} matches it; for a pattern, whether it matches some request
+ * path that matches the pattern
+ */
+ static boolean covers(String route, String path) {
+ return segments(route).size() <= segments(path).size() && overlaps(route, path);
+ }
+
+ private static boolean segmentsOverlap(String a, String b) {
+ if (hasVariable(a) && hasVariable(b)) {
+ return true;
+ }
+ if (hasVariable(a) || hasVariable(b)) {
+ String variable = hasVariable(a) ? a : b;
+ String literal = hasVariable(a) ? b : a;
+ return segmentRegex(variable).matcher(literal).matches();
+ }
+ return a.equals(b);
+ }
+
+ private static Pattern segmentRegex(String segment) {
+ StringBuilder regex = new StringBuilder();
+ Matcher variables = VARIABLE.matcher(segment);
+ int last = 0;
+ while (variables.find()) {
+ regex.append(Pattern.quote(segment.substring(last, variables.start()))).append("[^/]+");
+ last = variables.end();
+ }
+ return Pattern.compile(regex.append(Pattern.quote(segment.substring(last))).toString());
+ }
+
+ /**
+ * @return a request path that matches both overlapping paths, deep as the deeper one, with {@link #ANY} for
+ * each variable that no literal of the other path fills
+ */
+ static String sample(String a, String b) {
+ List left = segments(a);
+ List right = segments(b);
+ List sample = new ArrayList<>();
+ for (int i = 0; i < Math.max(left.size(), right.size()); i++) {
+ String segment = i >= left.size() ? right.get(i)
+ : i >= right.size() || !hasVariable(left.get(i)) || VARIABLE.matcher(right.get(i)).matches()
+ ? left.get(i) : right.get(i);
+ sample.add(VARIABLE.matcher(segment).replaceAll(ANY));
+ }
+ return sample.stream().collect(Collectors.joining("/", "/", ""));
+ }
+
+ /**
+ * @return the path in AuthorizationPolicy form: each variable segment replaced with {@code {*}}, without the
+ * trailing slash
+ */
+ static String template(String path) {
+ return segments(path).stream()
+ .map(s -> VARIABLE.matcher(s).matches() ? "{*}" : s)
+ .collect(Collectors.joining("/", "/", ""));
+ }
+
+ /**
+ * @return whether {@link #template} can express the path: every variable takes up a whole segment
+ */
+ static boolean expressible(String path) {
+ return segments(path).stream().allMatch(s -> !hasVariable(s) || VARIABLE.matcher(s).matches());
+ }
+}
diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteScanner.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteScanner.java
index 0c1678bb87..1c79e1b705 100644
--- a/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteScanner.java
+++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/main/java/com/netcracker/routes/gateway/plugin/RouteScanner.java
@@ -1,7 +1,12 @@
package com.netcracker.routes.gateway.plugin;
+import com.netcracker.cloud.routesregistration.common.annotation.FacadeGateway;
+import com.netcracker.cloud.routesregistration.common.annotation.FacadeRoute;
+import com.netcracker.cloud.routesregistration.common.annotation.ForbiddenRoute;
import com.netcracker.cloud.routesregistration.common.annotation.Gateway;
import com.netcracker.cloud.routesregistration.common.annotation.Route;
+import com.netcracker.cloud.routesregistration.common.annotation.Routes;
+import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.FacadeGatewayRequestMapping;
import com.netcracker.cloud.routesregistration.common.spring.gateway.route.annotation.GatewayRequestMapping;
import io.github.classgraph.*;
import jakarta.ws.rs.*;
@@ -12,6 +17,8 @@
import java.io.File;
import java.util.*;
+import java.util.function.Function;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
public class RouteScanner {
@@ -25,9 +32,32 @@ public class RouteScanner {
GET.class, POST.class, PUT.class, DELETE.class, PATCH.class
);
+ /**
+ * The annotations that Quarkus resources inherit: {@code @Path} like in the Quarkus extension, and the HTTP methods,
+ * which the extension doesn't read.
+ */
+ private static final Set JAX_RS_INHERITED_ANNOTATIONS = Stream.concat(Stream.of(Path.class), JAX_RS_HTTP_ANNOTATIONS.stream())
+ .map(Class::getName)
+ .collect(Collectors.toSet());
+
private static final String ROUTE_ANNOTATION = Route.class.getName();
+ private static final String ROUTES_ANNOTATION = Routes.class.getName();
+ private static final String FACADE_ROUTE_ANNOTATION = FacadeRoute.class.getName();
+ private static final String FORBIDDEN_ROUTE_ANNOTATION = ForbiddenRoute.class.getName();
+ private static final Set FORBIDDEN_GATEWAY_NAMES =
+ Set.of(HttpRoute.Type.PUBLIC.name(), HttpRoute.Type.PRIVATE.name());
private static final String GATEWAY_ANNOTATION = Gateway.class.getName();
private static final String GATEWAY_REQUEST_MAPPING = GatewayRequestMapping.class.getName();
+ private static final String FACADE_GATEWAY_ANNOTATION = FacadeGateway.class.getName();
+ private static final String FACADE_GATEWAY_REQUEST_MAPPING = FacadeGatewayRequestMapping.class.getName();
+ private static final List ROUTE_SELECTING_ANNOTATIONS = List.of(
+ ROUTE_ANNOTATION, ROUTES_ANNOTATION, FACADE_ROUTE_ANNOTATION, FORBIDDEN_ROUTE_ANNOTATION
+ );
+ private static final Map BORDER_GATEWAY_NAMES = Map.of(
+ "public-gateway-service", HttpRoute.Type.PUBLIC,
+ "private-gateway-service", HttpRoute.Type.PRIVATE,
+ "internal-gateway-service", HttpRoute.Type.INTERNAL
+ );
private final String[] packages;
private final Log log;
@@ -38,29 +68,62 @@ public RouteScanner(String[] packages, Log log) {
}
public Set collectRoutes(List reactorProjects) throws MojoExecutionException {
- Set allRoutes = new HashSet<>();
+ return collect(reactorProjects).routes();
+ }
+
+ /**
+ * @return the routes and forbidden paths of the modules, and the errors of their route annotations
+ */
+ public Declarations collect(List reactorProjects) throws MojoExecutionException {
+ Declarations declarations = new Declarations();
for (MavenProject module : reactorProjects) {
log.info("Scanning module: " + module.getArtifactId());
- allRoutes.addAll(getRoutes(module));
+ scanModule(module, declarations);
}
- return allRoutes;
+ return declarations;
+ }
+
+ Declarations collect(File classesDir) throws MojoExecutionException {
+ Declarations declarations = new Declarations();
+ scanClassesDir(classesDir, declarations);
+ return declarations;
+ }
+
+ /**
+ * @return the declarations of Spring controllers
+ */
+ Declarations collect(Collection classes) {
+ Declarations declarations = new Declarations();
+ classes.stream()
+ .filter(classInfo -> hasRoute(classInfo, FrameworkType.SPRING))
+ .forEach(classInfo -> collectClass(classInfo, FrameworkType.SPRING, declarations));
+ return declarations;
}
public Set getRoutes(MavenProject module) throws MojoExecutionException {
- File classesDir = new File(module.getBuild().getOutputDirectory());
+ Declarations declarations = new Declarations();
+ scanModule(module, declarations);
+ return declarations.routes();
+ }
+
+ private void scanModule(MavenProject module, Declarations declarations) throws MojoExecutionException {
+ scanClassesDir(new File(module.getBuild().getOutputDirectory()), declarations);
+ }
+
+ private void scanClassesDir(File classesDir, Declarations declarations) throws MojoExecutionException {
if (!classesDir.exists()) {
log.warn("No classes to scan: outputDirectory does not exist.");
- return Collections.emptySet();
+ return;
}
try (ScanResult scan = createScanResult(classesDir)) {
FrameworkType framework = detectFramework(scan);
if (framework == FrameworkType.NONE) {
log.info("No supported framework detected (Spring or Quarkus)");
- return Collections.emptySet();
+ return;
}
- return scanClassesForRoutes(scan, framework);
+ scanClassesForRoutes(scan, framework, declarations);
} catch (Exception e) {
throw new MojoExecutionException("Failed scanning annotations", e);
}
@@ -85,108 +148,213 @@ private FrameworkType detectFramework(ScanResult scan) {
return FrameworkType.NONE;
}
- private Set scanClassesForRoutes(ScanResult scan, FrameworkType framework) {
- return getAnnotatedClasses(scan, framework)
- .distinct()
- .filter(this::hasRoute)
- .flatMap(classInfo -> getRequestMappingPaths(classInfo).stream())
- .collect(java.util.stream.Collectors.toSet());
+ private void scanClassesForRoutes(ScanResult scan, FrameworkType framework, Declarations declarations) {
+ scan.getAllClasses().stream()
+ .filter(classInfo -> hasRoute(classInfo, framework))
+ .forEach(classInfo -> collectClass(classInfo, framework, declarations));
}
- private Stream getAnnotatedClasses(ScanResult scan, FrameworkType framework) {
- Set> annotations = framework == FrameworkType.SPRING
- ? SPRING_HTTP_ANNOTATIONS
- : JAX_RS_HTTP_ANNOTATIONS;
+ /**
+ * @return the routes of a Spring controller
+ */
+ public Set getRequestMappingPaths(ClassInfo classInfo) {
+ Declarations declarations = new Declarations();
+ collectClass(classInfo, FrameworkType.SPRING, declarations);
+ return declarations.routes();
+ }
- if (framework == FrameworkType.QUARKUS) {
- return Stream.concat(
- getClassesWithAnnotations(scan, Set.of(Path.class)),
- getClassesWithAnnotations(scan, annotations)
- );
+ /**
+ * Like the legacy libs, maps all methods of a Spring controller with the controller's class annotations, and only
+ * the declared methods of a Quarkus resource.
+ */
+ private void collectClass(ClassInfo classInfo, FrameworkType framework, Declarations declarations) {
+ log.info("Get Request Mappings for Class: " + classInfo.getName());
+
+ int before = declarations.routes().size();
+ ClassContext classContext = extractClassContext(classInfo, framework);
+ MethodInfoList methods = framework == FrameworkType.SPRING ? classInfo.getMethodInfo() : classInfo.getDeclaredMethodInfo();
+ for (MethodInfo methodInfo : methods) {
+ AnnotationInfoList annotations = annotations(methodInfo, framework);
+ getHttpMappingAnnotations(annotations)
+ .forEach(mappingAnn -> collectMethod(classContext, methodInfo, annotations, mappingAnn, declarations));
}
+ collectClassLevel(classContext, declarations);
- return getClassesWithAnnotations(scan, annotations);
+ log.info("Found " + (declarations.routes().size() - before) + " routes");
}
- private Stream getClassesWithAnnotations(ScanResult scan, Set> annotations) {
- return annotations.stream()
- .flatMap(annotation -> Stream.concat(
- scan.getClassesWithMethodAnnotation(annotation.getName()).stream(),
- scan.getClassesWithAnnotation(annotation.getName()).stream()
- ));
+ private ClassContext extractClassContext(ClassInfo classInfo, FrameworkType framework) {
+ AnnotationInfoList annotations = annotations(classInfo, framework);
+ return new ClassContext(
+ classInfo.getName(),
+ resolveRequestMappings(annotations),
+ resolveGatewayMappings(annotations::get, PathKind.BORDER),
+ resolveGatewayMappings(annotations::get, PathKind.FACADE),
+ readRouteEntries(annotations),
+ annotations.get(FORBIDDEN_ROUTE_ANNOTATION)
+ );
}
- public Set getRequestMappingPaths(ClassInfo classInfo) {
- log.info("Get Request Mappings for Class: " + classInfo.getName());
+ private void collectClassLevel(ClassContext classContext, Declarations declarations) {
+ for (RouteEntry entry : classContext.routes()) {
+ HttpRoute.Type type = entry.type().orElse(HttpRoute.Type.INTERNAL);
+ long timeout = entry.timeout().orElse(0L);
+ for (Target target : resolveTargets(entry, type)) {
+ classPairs(classContext, target.pathKind()).forEach(pair -> declarations.routes().add(
+ new HttpRoute(pair.servicePath(), pair.gatewayPath(), target.type(), timeout)));
+ }
+ }
+
+ readForbiddenGateways(classContext.forbiddenRoute(), classContext.name(), declarations)
+ .ifPresent(gateways -> classPairs(classContext, PathKind.BORDER).forEach(pair ->
+ declarations.forbidden().add(new ForbiddenPath(pair.gatewayPath(), gateways))));
+ }
- RouteMetadata classMetadata = extractClassMetadata(classInfo);
- Set routes = processMethodRoutes(classInfo, classMetadata);
- routes.addAll(buildClassLevelRoutes(classInfo, classMetadata));
+ /**
+ * @param annotations the annotations of the method, including the inherited ones
+ */
+ private void collectMethod(ClassContext classContext, MethodInfo methodInfo, AnnotationInfoList annotations,
+ AnnotationInfo mappingAnn, Declarations declarations) {
+ List mappingPaths = resolveMappingPaths(annotations, mappingAnn);
- if (classInfo.getSuperclass() != null) {
- routes.addAll(getRequestMappingPaths(classInfo.getSuperclass()));
+ for (RouteEntry entry : readRouteEntries(annotations)) {
+ HttpRoute.Type type = entry.type().orElse(HttpRoute.Type.INTERNAL);
+ long timeout = entry.timeout().orElse(0L);
+ for (Target target : resolveTargets(entry, type)) {
+ methodPairs(classContext, annotations, target.pathKind(), mappingPaths).forEach(pair -> declarations.routes().add(
+ new HttpRoute(pair.servicePath(), pair.gatewayPath(), target.type(), timeout)));
+ }
}
- log.info("Found " + routes.size() + " routes");
- return routes;
+ readForbiddenGateways(annotations.get(FORBIDDEN_ROUTE_ANNOTATION),
+ classContext.name() + "#" + methodInfo.getName(), declarations)
+ .ifPresent(gateways -> methodPairs(classContext, annotations, PathKind.BORDER, mappingPaths).forEach(pair ->
+ declarations.forbidden().add(new ForbiddenPath(pair.gatewayPath(), gateways))));
}
- private RouteMetadata extractClassMetadata(ClassInfo classInfo) {
- return new RouteMetadata(
- getRouteType(classInfo.getAnnotationInfo(ROUTE_ANNOTATION)),
- getRouteTimeout(classInfo.getAnnotationInfo(ROUTE_ANNOTATION)),
- resolveGatewayMappings(classInfo),
- resolveRequestMappings(classInfo)
- );
+ /**
+ * Gateway paths of the class itself: {@code @Gateway}-style mappings of the kind combined with the class request mappings.
+ */
+ private List classPairs(ClassContext classContext, PathKind kind) {
+ List gatewayMappings = classContext.gatewayMappings(kind);
+ List requestMappings = classContext.requestMappings();
+ if (requestMappings.isEmpty() && gatewayMappings.isEmpty()) {
+ return List.of();
+ }
+ if (!gatewayMappings.isEmpty()) {
+ return buildClassGatewayPairs(gatewayMappings, List.of(), requestMappings, List.of(""));
+ }
+ return requestMappings.stream().map(path -> new PathPair(path, path)).toList();
}
- private Set processMethodRoutes(ClassInfo classInfo, RouteMetadata classMetadata) {
- return classInfo.getMethodInfo().stream()
- .flatMap(methodInfo -> getHttpMappingAnnotations(methodInfo)
- .map(mappingAnn -> Map.entry(methodInfo, mappingAnn)))
- .flatMap(entry -> buildRoutesForMethod(
- entry.getKey(),
- entry.getValue(),
- classMetadata
- ).stream())
- .collect(java.util.stream.Collectors.toSet());
+ private List methodPairs(ClassContext classContext, AnnotationInfoList annotations, PathKind kind, List mappingPaths) {
+ List methodGatewayMappings = resolveGatewayMappings(annotations::get, kind);
+ if (!classContext.gatewayMappings(kind).isEmpty()) {
+ return buildClassGatewayPairs(
+ classContext.gatewayMappings(kind),
+ methodGatewayMappings,
+ classContext.requestMappings(),
+ mappingPaths
+ );
+ }
+ if (!methodGatewayMappings.isEmpty()) {
+ return buildMethodGatewayPairs(methodGatewayMappings, classContext.requestMappings(), mappingPaths);
+ }
+ return buildStandardPairs(classContext.requestMappings(), mappingPaths);
+ }
+
+ /**
+ * Legacy resolution of one route annotation into targets: empty {@code gateways} goes by type, a border gateway name
+ * gives a border route of that gateway's type, and every other name is a composite gateway.
+ */
+ private List resolveTargets(RouteEntry entry, HttpRoute.Type type) {
+ if (entry.gateways().isEmpty()) {
+ return List.of(type == HttpRoute.Type.FACADE
+ ? new Target(HttpRoute.Type.FACADE, PathKind.FACADE)
+ : new Target(type, PathKind.BORDER));
+ }
+ return entry.gateways().stream()
+ .map(name -> Optional.ofNullable(BORDER_GATEWAY_NAMES.get(name))
+ .map(borderType -> new Target(borderType, PathKind.BORDER))
+ .orElse(new Target(HttpRoute.Type.FACADE, PathKind.BORDER)))
+ .toList();
}
- private Set buildClassLevelRoutes(ClassInfo classInfo, RouteMetadata classMetadata) {
- if (!classInfo.hasAnnotation(ROUTE_ANNOTATION)) {
- return Collections.emptySet();
+ private Optional> readForbiddenGateways(AnnotationInfo forbiddenRoute, String element, Declarations declarations) {
+ if (forbiddenRoute == null) {
+ return Optional.empty();
}
-
- if (classMetadata.requestMappings().isEmpty() && classMetadata.gatewayMappings().isEmpty()) {
- return Collections.emptySet();
+ List names = enumValueNames(forbiddenRoute.getParameterValues(false).getValue("value"));
+ if (names.isEmpty() || !FORBIDDEN_GATEWAY_NAMES.containsAll(names)) {
+ declarations.errors().add("@ForbiddenRoute of " + element + " must list PUBLIC and/or PRIVATE, found " + names);
+ return Optional.empty();
}
-
- HttpRoute.Type routeType = classMetadata.routeType().orElse(HttpRoute.Type.INTERNAL);
- long routeTimeout = classMetadata.routeTimeout().orElse(0L);
-
- if (!classMetadata.gatewayMappings().isEmpty()) {
- return buildClassGatewayRoutes(
- classMetadata.gatewayMappings(),
- List.of(),
- classMetadata.requestMappings(),
- List.of(""),
- routeType,
- routeTimeout
- );
+ return Optional.of(names.stream().map(HttpRoute.Type::valueOf).collect(Collectors.toCollection(() -> EnumSet.noneOf(HttpRoute.Type.class))));
+ }
+
+ /**
+ * Reads every route annotation of an element: each {@code @Route} (classgraph may unwrap the {@code @Routes}
+ * container into several of them), the {@code @Routes} container when it is not unwrapped, and {@code @FacadeRoute}.
+ */
+ private List readRouteEntries(AnnotationInfoList annotations) {
+ List entries = new ArrayList<>();
+ for (AnnotationInfo annotation : annotations) {
+ String name = annotation.getName();
+ if (ROUTE_ANNOTATION.equals(name)) {
+ entries.add(routeEntry(annotation));
+ } else if (ROUTES_ANNOTATION.equals(name)
+ && annotation.getParameterValues(false).getValue("value") instanceof Object[] values) {
+ Arrays.stream(values)
+ .filter(AnnotationInfo.class::isInstance)
+ .map(AnnotationInfo.class::cast)
+ .map(this::routeEntry)
+ .forEach(entries::add);
+ } else if (FACADE_ROUTE_ANNOTATION.equals(name)) {
+ entries.add(new RouteEntry(
+ Optional.of(HttpRoute.Type.FACADE),
+ getRouteTimeout(annotation),
+ stringValues(annotation.getParameterValues(false).getValue("gateways"))
+ ));
+ }
}
+ return entries;
+ }
- return classMetadata.requestMappings().stream()
- .map(path -> new HttpRoute(path, routeType, routeTimeout))
- .collect(java.util.stream.Collectors.toSet());
+ private RouteEntry routeEntry(AnnotationInfo route) {
+ AnnotationParameterValueList parameters = route.getParameterValues(false);
+ return new RouteEntry(
+ getRouteType(route),
+ getRouteTimeout(route),
+ stringValues(parameters.getValue("gateways"))
+ );
}
- private boolean hasMethodRouteAnnotation(MethodInfo methodInfo) {
- return methodInfo.hasAnnotation(ROUTE_ANNOTATION);
+ private static Stream> values(Object value) {
+ return switch (value) {
+ case null -> Stream.empty();
+ case Object[] objects -> Arrays.stream(objects);
+ default -> Stream.of(value);
+ };
}
- private Stream getHttpMappingAnnotations(MethodInfo methodInfo) {
- AnnotationInfoList annotations = methodInfo.getAnnotationInfo();
+ private static List stringValues(Object value) {
+ return values(value)
+ .filter(String.class::isInstance)
+ .map(String.class::cast)
+ .filter(s -> !s.isEmpty())
+ .distinct()
+ .toList();
+ }
+ private static List enumValueNames(Object value) {
+ return values(value)
+ .filter(AnnotationEnumValue.class::isInstance)
+ .map(AnnotationEnumValue.class::cast)
+ .map(AnnotationEnumValue::getValueName)
+ .toList();
+ }
+
+ private Stream getHttpMappingAnnotations(AnnotationInfoList annotations) {
List specificMappings = annotations.stream()
.filter(this::isHttpMappingAnnotation)
.toList();
@@ -199,35 +367,92 @@ private Stream getHttpMappingAnnotations(MethodInfo methodInfo)
.filter(this::isRequestMappingAnnotation);
}
- private boolean hasRoute(ClassInfo classInfo) {
- return classInfo.hasAnnotation(Route.class) || classInfo.hasMethodAnnotation(Route.class);
+ /**
+ * Like the legacy libs: a Spring controller is a concrete class with a route annotation in its hierarchy, a Quarkus
+ * resource is a class or interface with a route annotation of its own.
+ */
+ private boolean hasRoute(ClassInfo classInfo, FrameworkType framework) {
+ if (framework == FrameworkType.QUARKUS) {
+ return hasOwnRoute(classInfo);
+ }
+ return classInfo.isStandardClass() && !classInfo.isAbstract() && hierarchy(classInfo).anyMatch(this::hasOwnRoute);
}
- private boolean isRequestMappingAnnotation(AnnotationInfo annotationInfo) {
- return RequestMapping.class.getName().equals(annotationInfo.getName());
+ private boolean hasOwnRoute(ClassInfo classInfo) {
+ return ROUTE_SELECTING_ANNOTATIONS.stream()
+ .anyMatch(annotation -> classInfo.hasAnnotation(annotation) || classInfo.hasDeclaredMethodAnnotation(annotation));
}
- private List resolveGatewayMappings(ClassInfo classInfo) {
- if (classInfo.hasAnnotation(GATEWAY_REQUEST_MAPPING)) {
- return getAnnotationPathFor(classInfo.getAnnotationInfo(GATEWAY_REQUEST_MAPPING));
- }
- return getAnnotationPathFor(classInfo.getAnnotationInfo(GATEWAY_ANNOTATION));
+ /**
+ * @return the class, then each of its interfaces with their superinterfaces, then its superclass in the same way:
+ * the order in which Spring's {@code AnnotationUtils.findAnnotation} searches
+ */
+ private static Stream hierarchy(ClassInfo classInfo) {
+ return Stream.of(
+ Stream.of(classInfo),
+ classInfo.getInterfaces().directOnly().stream().flatMap(RouteScanner::hierarchy),
+ Stream.ofNullable(classInfo.getSuperclass()).flatMap(RouteScanner::hierarchy)
+ ).flatMap(Function.identity());
+ }
+
+ private static AnnotationInfoList annotations(ClassInfo classInfo, FrameworkType framework) {
+ return inherit(hierarchy(classInfo).map(ClassInfo::getAnnotationInfo), framework);
+ }
+
+ /**
+ * @return the annotations of the method and of the methods it overrides
+ */
+ private static AnnotationInfoList annotations(MethodInfo methodInfo, FrameworkType framework) {
+ return inherit(hierarchy(methodInfo.getClassInfo())
+ .flatMap(classInfo -> classInfo.getDeclaredMethodInfo(methodInfo.getName()).stream())
+ .filter(method -> method.getTypeDescriptorStr().equals(methodInfo.getTypeDescriptorStr()))
+ .map(MethodInfo::getAnnotationInfo), framework);
+ }
+
+ /**
+ * Adds to the annotations of an element the annotations that it inherits: each annotation type comes from the
+ * first element of the hierarchy that has it, like in Spring's {@code AnnotationUtils.findAnnotation}. Spring
+ * controllers inherit every annotation, Quarkus resources only {@code JAX_RS_INHERITED_ANNOTATIONS}.
+ *
+ * @param hierarchy the annotations of the element, then of the elements it inherits from
+ */
+ private static AnnotationInfoList inherit(Stream hierarchy, FrameworkType framework) {
+ Iterator elements = hierarchy.iterator();
+ AnnotationInfoList result = new AnnotationInfoList(elements.next());
+ Set found = result.stream().map(AnnotationInfo::getName).collect(Collectors.toCollection(HashSet::new));
+ elements.forEachRemaining(annotations -> {
+ // all annotations of a type, which repeat when classgraph unwraps @Routes
+ List inherited = annotations.stream()
+ .filter(annotation -> !found.contains(annotation.getName()))
+ .filter(annotation -> framework == FrameworkType.SPRING || JAX_RS_INHERITED_ANNOTATIONS.contains(annotation.getName()))
+ .toList();
+ result.addAll(inherited);
+ inherited.forEach(annotation -> found.add(annotation.getName()));
+ });
+ return result;
}
- private List resolveGatewayMappings(MethodInfo methodInfo) {
- if (methodInfo.hasAnnotation(GATEWAY_REQUEST_MAPPING)) {
- return getAnnotationPathFor(methodInfo.getAnnotationInfo(GATEWAY_REQUEST_MAPPING));
+ private boolean isRequestMappingAnnotation(AnnotationInfo annotationInfo) {
+ return RequestMapping.class.getName().equals(annotationInfo.getName());
+ }
+
+ private List resolveGatewayMappings(Function annotations, PathKind kind) {
+ String requestMapping = kind == PathKind.BORDER ? GATEWAY_REQUEST_MAPPING : FACADE_GATEWAY_REQUEST_MAPPING;
+ String gateway = kind == PathKind.BORDER ? GATEWAY_ANNOTATION : FACADE_GATEWAY_ANNOTATION;
+ AnnotationInfo requestMappingInfo = annotations.apply(requestMapping);
+ if (requestMappingInfo != null) {
+ return getAnnotationPathFor(requestMappingInfo);
}
- return getAnnotationPathFor(methodInfo.getAnnotationInfo(GATEWAY_ANNOTATION));
+ return getAnnotationPathFor(annotations.apply(gateway));
}
- private List resolveRequestMappings(ClassInfo classInfo) {
+ private List resolveRequestMappings(AnnotationInfoList annotations) {
return Stream.of(
RequestMapping.class, GetMapping.class, PostMapping.class,
PutMapping.class, DeleteMapping.class, PatchMapping.class, Path.class
)
.map(Class::getName)
- .map(classInfo::getAnnotationInfo)
+ .map(annotations::get)
.filter(Objects::nonNull)
.findFirst()
.map(this::getAnnotationPathFor)
@@ -248,81 +473,32 @@ private boolean isHttpMappingAnnotation(AnnotationInfo annotationInfo) {
PATCH.class.getName().equals(name);
}
- private Set buildRoutesForMethod(
- MethodInfo methodInfo,
- AnnotationInfo mappingAnn,
- RouteMetadata classMetadata
- ) {
- if (!hasMethodRouteAnnotation(methodInfo)) {
- return Collections.emptySet();
- }
-
- HttpRoute.Type routeType = getRouteType(methodInfo.getAnnotationInfo(ROUTE_ANNOTATION))
- .orElse(classMetadata.routeType().orElse(HttpRoute.Type.INTERNAL));
-
- long routeTimeout = getRouteTimeout(methodInfo.getAnnotationInfo(ROUTE_ANNOTATION))
- .orElse(classMetadata.routeTimeout().orElse(0L));
-
- List methodGatewayMappings = resolveGatewayMappings(methodInfo);
- List mappingPaths = resolveMappingPaths(methodInfo, mappingAnn);
-
- if (!classMetadata.gatewayMappings().isEmpty()) {
- return buildClassGatewayRoutes(
- classMetadata.gatewayMappings(),
- methodGatewayMappings,
- classMetadata.requestMappings(),
- mappingPaths,
- routeType,
- routeTimeout
- );
- }
-
- if (!methodGatewayMappings.isEmpty()) {
- return buildMethodGatewayRoutes(
- methodGatewayMappings,
- classMetadata.requestMappings(),
- mappingPaths,
- routeType,
- routeTimeout
- );
- }
-
- return buildStandardRoutes(classMetadata.requestMappings(), mappingPaths, routeType, routeTimeout);
- }
-
- private List resolveMappingPaths(MethodInfo methodInfo, AnnotationInfo mappingAnn) {
+ private List resolveMappingPaths(AnnotationInfoList annotations, AnnotationInfo mappingAnn) {
if (JAX_RS_HTTP_ANNOTATIONS.stream().map(Class::getName).anyMatch(s -> s.equals(mappingAnn.getClassInfo().getName()))) {
- List paths = getAnnotationPathFor(methodInfo.getAnnotationInfo(Path.class.getName()));
+ List paths = getAnnotationPathFor(annotations.get(Path.class.getName()));
return paths.isEmpty() ? List.of("") : paths;
}
return getAnnotationPathFor(mappingAnn);
}
- private Set buildStandardRoutes(
- List classMappings,
- List methodMappings,
- HttpRoute.Type routeType,
- long routeTimeout
- ) {
+ private List buildStandardPairs(List classMappings, List methodMappings) {
if (classMappings.isEmpty()) {
return methodMappings.stream()
- .map(path -> new HttpRoute(path, routeType, routeTimeout))
- .collect(java.util.stream.Collectors.toSet());
+ .map(path -> new PathPair(path, path))
+ .toList();
}
return classMappings.stream()
.flatMap(classPrefix -> methodMappings.stream()
- .map(methodPath -> new HttpRoute(classPrefix + methodPath, routeType, routeTimeout)))
- .collect(java.util.stream.Collectors.toSet());
+ .map(methodPath -> new PathPair(classPrefix + methodPath, classPrefix + methodPath)))
+ .toList();
}
- private Set buildClassGatewayRoutes(
+ private List buildClassGatewayPairs(
List classGatewayMappings,
List methodGatewayMappings,
List classMappings,
- List methodMappings,
- HttpRoute.Type routeType,
- long routeTimeout
+ List methodMappings
) {
List effectiveMethodMappings = methodMappings.isEmpty() ? List.of("/") : methodMappings;
List effectiveMethodGatewayMappings = methodGatewayMappings.isEmpty()
@@ -334,37 +510,25 @@ private Set buildClassGatewayRoutes(
return classGatewayMappings.stream()
.flatMap(classPrefix -> effectiveMethodGatewayMappings.stream()
- .map(methodPath -> new HttpRoute(
- servicePrefix + mappingPath,
- classPrefix + methodPath,
- routeType,
- routeTimeout
- )))
- .collect(java.util.stream.Collectors.toSet());
+ .map(methodPath -> new PathPair(servicePrefix + mappingPath, classPrefix + methodPath)))
+ .toList();
}
- private Set buildMethodGatewayRoutes(
+ private List buildMethodGatewayPairs(
List methodGatewayMappings,
List classMappings,
- List methodMappings,
- HttpRoute.Type routeType,
- long routeTimeout
+ List methodMappings
) {
if (methodGatewayMappings.isEmpty() || methodMappings.isEmpty()) {
- return Collections.emptySet();
+ return List.of();
}
String servicePrefix = classMappings.isEmpty() ? "" : classMappings.get(0);
String mappingPath = methodMappings.get(0);
return methodGatewayMappings.stream()
- .map(methodPath -> new HttpRoute(
- servicePrefix + mappingPath,
- methodPath,
- routeType,
- routeTimeout
- ))
- .collect(java.util.stream.Collectors.toSet());
+ .map(methodPath -> new PathPair(servicePrefix + mappingPath, methodPath))
+ .toList();
}
private List getAnnotationPathFor(AnnotationInfo annotationInfo) {
@@ -433,10 +597,10 @@ private Optional getRouteTimeout(AnnotationInfo annotationInfo) {
private Optional getRouteType(AnnotationInfo annotationInfo) {
return Optional.ofNullable(annotationInfo)
.map(annInfo -> annInfo.getParameterValues(false))
- .flatMap(params ->
- Optional.ofNullable(params.getValue("type"))
- .or(() -> Optional.ofNullable(params.getValue("value")))
- )
+ .flatMap(params -> Optional.ofNullable(params.getValue("value"))
+ .filter(value -> !(value instanceof AnnotationEnumValue e
+ && e.getValueName().equals(HttpRoute.Type.INTERNAL.name())))
+ .or(() -> Optional.ofNullable(params.getValue("type"))))
.filter(AnnotationEnumValue.class::isInstance)
.map(AnnotationEnumValue.class::cast)
.map(enumVal -> HttpRoute.Type.valueOf(enumVal.getValueName()));
@@ -459,10 +623,44 @@ private enum FrameworkType {
SPRING, QUARKUS, NONE
}
- private record RouteMetadata(
- Optional routeType,
- Optional routeTimeout,
- List gatewayMappings,
- List requestMappings
+ /**
+ * Which gateway path annotations apply: {@code @Gateway}/{@code @GatewayRequestMapping} for border and composite
+ * routes, {@code @FacadeGateway}/{@code @FacadeGatewayRequestMapping} for facade routes.
+ */
+ private enum PathKind {
+ BORDER, FACADE
+ }
+
+ private record PathPair(String servicePath, String gatewayPath) {}
+
+ private record Target(HttpRoute.Type type, PathKind pathKind) {}
+
+ private record RouteEntry(
+ Optional type,
+ Optional timeout,
+ List gateways
) {}
+
+ private record ClassContext(
+ String name,
+ List requestMappings,
+ List borderGatewayMappings,
+ List facadeGatewayMappings,
+ List routes,
+ AnnotationInfo forbiddenRoute
+ ) {
+ List gatewayMappings(PathKind kind) {
+ return kind == PathKind.BORDER ? borderGatewayMappings : facadeGatewayMappings;
+ }
+ }
+
+ /**
+ * @param errors errors of the route annotations
+ */
+ public record Declarations(Set routes, Set forbidden, List errors) {
+
+ Declarations() {
+ this(new HashSet<>(), new HashSet<>(), new ArrayList<>());
+ }
+ }
}
diff --git a/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRendererTest.java b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRendererTest.java
new file mode 100644
index 0000000000..57c5fa244d
--- /dev/null
+++ b/core-maven-plugins/httproutes-generator-maven-plugin/src/test/java/com/netcracker/routes/gateway/plugin/AuthorizationPolicyRendererTest.java
@@ -0,0 +1,412 @@
+package com.netcracker.routes.gateway.plugin;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static com.netcracker.routes.gateway.plugin.HttpRoute.Type.INTERNAL;
+import static com.netcracker.routes.gateway.plugin.HttpRoute.Type.PRIVATE;
+import static com.netcracker.routes.gateway.plugin.HttpRoute.Type.PUBLIC;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AuthorizationPolicyRendererTest {
+
+ private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory());
+ private static final String RESOURCE = "/api/v1/svc/resource";
+ private static final String ORDER = "/api/v1/svc/order";
+
+ private static HttpRoute route(String gatewayPath, HttpRoute.Type type) {
+ return new HttpRoute(gatewayPath, gatewayPath, type);
+ }
+
+ private static ForbiddenPath forbidden(String gatewayPath, HttpRoute.Type... gateways) {
+ return new ForbiddenPath(gatewayPath, Set.of(gateways));
+ }
+
+ private static String render(boolean auto, Problems problems, Set routes, Set forbidden) {
+ return new AuthorizationPolicyRenderer(Map.of(), auto).generateAuthorizationPoliciesYaml(routes, forbidden, problems);
+ }
+
+ private static String render(boolean auto, Set routes, Set forbidden) {
+ Problems problems = new Problems();
+ String yaml = render(auto, problems, routes, forbidden);
+ assertEquals(List.of(), problems.errors());
+ return yaml;
+ }
+
+ /**
+ * @return the rules of each policy by gateway, for example {@code /a/{*} !/a/{*}/b}: the first path of the rule
+ * followed by the first path of each notPaths subtree
+ */
+ @SuppressWarnings("unchecked")
+ private static Map> rules(String yaml) throws Exception {
+ Map> result = new LinkedHashMap<>();
+ try (var values = YAML.readerFor(Map.class).readValues(yaml)) {
+ while (values.hasNext()) {
+ Map policy = (Map) values.next();
+ String name = (String) ((Map) policy.get("metadata")).get("name");
+ List rules = new ArrayList<>();
+ for (Object rule : (List