Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@
import io.gravitee.definition.model.v4.flow.Flow;
import io.gravitee.definition.model.v4.flow.selector.ChannelSelector;
import io.gravitee.definition.model.v4.flow.selector.HttpSelector;
import io.gravitee.definition.model.v4.flow.selector.McpSelector;
import io.gravitee.definition.model.v4.flow.selector.Selector;
import io.gravitee.definition.model.v4.flow.selector.SelectorType;
import io.gravitee.definition.model.v4.flow.step.Step;
import io.gravitee.definition.model.v4.nativeapi.NativeFlow;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
Expand All @@ -40,7 +40,6 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -227,7 +226,7 @@ public void validatePathParameters(ApiType apiType, Stream<Flow> apiFlows, Strea
planFlows = planFlows == null ? Stream.empty() : planFlows;
// group all flows in one stream
final Stream<Flow> flowsWithPathParam = filterFlowsWithPathParam(apiType, apiFlows, planFlows);
validatePathParamOverlapping(apiType, flowsWithPathParam);
checkOverlappingPaths(apiType, flowsWithPathParam);
}

private Stream<Flow> filterFlowsWithPathParam(ApiType apiType, Stream<Flow> apiFlows, Stream<Flow> planFlows) {
Expand All @@ -236,57 +235,120 @@ private Stream<Flow> filterFlowsWithPathParam(ApiType apiType, Stream<Flow> apiF
.filter(flow -> containsPathParam(apiType, flow));
}

private void validatePathParamOverlapping(ApiType apiType, Stream<Flow> flows) {
Map<String, Integer> paramWithPosition = new HashMap<>();
Map<String, List<String>> pathsByParam = new HashMap<>();
final AtomicBoolean hasOverlap = new AtomicBoolean(false);

flows.forEach(flow -> {
final String path = extractPath(apiType, flow);
String[] branches = SEPARATOR_SPLITTER.split(path);
for (int i = 0; i < branches.length; i++) {
final String currentBranch = branches[i];
if (currentBranch.startsWith(PATH_PARAM_PREFIX)) {
// Store every path for a path param in a map
prepareOverlapsMap(pathsByParam, path, currentBranch);
if (isOverlapping(paramWithPosition, currentBranch, i)) {
// Exception is thrown later to be able to provide every overlapping case to the end user
hasOverlap.set(true);
} else {
paramWithPosition.put(currentBranch, i);
}
private void checkOverlappingPaths(ApiType apiType, Stream<Flow> flows) {
// Extract unique, non-empty paths from enabled flows
List<String> uniquePaths = flows
.map(flow -> extractPath(apiType, flow))
.map(this::normalizePath) // normalize to avoid ambiguity due to slashes/case
.filter(path -> !path.isEmpty())
.distinct()
.toList();

Map<String, Set<String>> overlappingPaths = new HashMap<>();
int pathCount = uniquePaths.size();

for (int i = 0; i < pathCount; i++) {
String path1 = uniquePaths.get(i);
String[] segments1 = splitPathSegments(path1);

for (int j = i + 1; j < pathCount; j++) {
String path2 = uniquePaths.get(j);
String[] segments2 = splitPathSegments(path2);

if (segments1.length != segments2.length) continue;

if (arePathsAmbiguous(segments1, segments2)) {
// Use a deterministic grouping key to avoid merging unrelated conflicts
String key = buildAmbiguitySignature(segments1);
Set<String> paths = overlappingPaths.computeIfAbsent(key, k -> new HashSet<>());
paths.add(path1);
paths.add(path2);
}
}
});
}

if (hasOverlap.get()) {
throw new ValidationDomainException(
"Some path parameters are used at different position across different flows.",
pathsByParam
.entrySet()
.stream()
// Only keep params with overlap
.filter(entry -> entry.getValue().size() > 1)
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().toString()))
);
if (!overlappingPaths.isEmpty()) {
// Sort lists for stable output
Map<String, String> payload = overlappingPaths
.entrySet()
.stream()
.collect(
Collectors.toMap(Map.Entry::getKey, entry -> {
List<String> sortedPaths = new ArrayList<>(entry.getValue());
sortedPaths.sort(String::compareTo);
return sortedPaths.toString();
})
);

throw new ValidationDomainException("Invalid path parameters", payload);
}
}

private static void prepareOverlapsMap(Map<String, List<String>> pathsByParam, String path, String branches) {
pathsByParam.compute(branches, (key, value) -> {
if (value == null) {
value = new ArrayList<>();
}
// Add the path only once to the error message
if (!value.contains(path)) {
value.add(path);
}
return value;
});
/**
* Returns true if the two paths (split into segments) are ambiguous per OpenAPI 3.0:
* - Same number of segments
* - For each segment: both are parameters, or both are static and equal
*/
private boolean arePathsAmbiguous(String[] segments1, String[] segments2) {
for (int i = 0; i < segments1.length; i++) {
boolean isParam1 = segments1[i].startsWith(PATH_PARAM_PREFIX);
boolean isParam2 = segments2[i].startsWith(PATH_PARAM_PREFIX);

if (isParam1 && isParam2) continue;

if (!isParam1 && !isParam2 && segments1[i].equals(segments2[i])) continue;

return false;
}

return true;
}

/**
* Normalize path:
* - Collapse multiple slashes
* - Remove trailing slash (except root "/")
* - Lowercase literals if routing is case-insensitive; keeping case as-is here
*/
private String normalizePath(String raw) {
if (raw == null) return "";
String p = raw.trim();

if (p.isEmpty()) return "";
// Collapse multiple slashes
p = p.replaceAll("/{2,}", PATH_SEPARATOR);
// Remove trailing slash except root
if (p.length() > 1 && p.endsWith(PATH_SEPARATOR)) {
p = p.substring(0, p.length() - 1);
}
// Ensure leading slash for consistency
if (!p.startsWith(PATH_SEPARATOR)) {
p = PATH_SEPARATOR + p;
}

return p;
}

/**
* Split path into non-empty segments after normalization.
*/
private String[] splitPathSegments(String path) {
return Arrays.stream(SEPARATOR_SPLITTER.split(path))
.filter(s -> !s.isEmpty())
.toArray(String[]::new);
}

private static boolean isOverlapping(Map<String, Integer> paramWithPosition, String param, Integer i) {
return paramWithPosition.containsKey(param) && !paramWithPosition.get(param).equals(i);
/**
* Build a deterministic ambiguity signature by replacing any parameter segment with ":" and keeping literals.
* Example: /users/:id/orders -> /users/:/orders
*/
private String buildAmbiguitySignature(String[] segments) {
return (
PATH_SEPARATOR +
Arrays.stream(segments)
.map(s -> s.startsWith(PATH_PARAM_PREFIX) ? PATH_PARAM_PREFIX : s)
.collect(Collectors.joining(PATH_SEPARATOR))
);
}

private static Boolean containsPathParam(ApiType apiType, Flow flow) {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import io.gravitee.apim.core.audit.model.AuditInfo;
import io.gravitee.apim.core.flow.crud_service.FlowCrudService;
import io.gravitee.apim.core.flow.domain_service.FlowValidationDomainService;
import io.gravitee.apim.core.subscription.domain_service.CloseSubscriptionDomainService;
import io.gravitee.definition.model.v4.ApiType;
import io.gravitee.definition.model.v4.flow.Flow;
Expand Down Expand Up @@ -83,7 +84,6 @@
import io.gravitee.rest.api.service.v4.mapper.GenericPlanMapper;
import io.gravitee.rest.api.service.v4.mapper.PlanMapper;
import io.gravitee.rest.api.service.v4.validation.FlowValidationService;
import io.gravitee.rest.api.service.v4.validation.PathParametersValidationService;
import io.gravitee.rest.api.service.v4.validation.TagsValidationService;
import java.util.Arrays;
import java.util.Collection;
Expand Down Expand Up @@ -176,7 +176,7 @@ public class PlanServiceImpl extends AbstractService implements PlanService {
private FlowValidationService flowValidationService;

@Autowired
private PathParametersValidationService pathParametersValidationService;
private FlowValidationDomainService flowValidationDomainService;

@Autowired
private GroupService groupService;
Expand Down Expand Up @@ -265,7 +265,7 @@ private void validatePathParameters(Api api, List<Flow> newPlanFlows) throws Tec
.flatMap(Collection::stream);
planFlows = Stream.concat(planFlows, newPlanFlows.stream());

pathParametersValidationService.validate(api.getType(), apiFlows, planFlows);
flowValidationDomainService.validatePathParameters(api.getType(), apiFlows, planFlows);
}

private void validateTags(Set<String> tags, Api api) {
Expand Down Expand Up @@ -435,7 +435,7 @@ private void validatePathParameters(Api api, UpdatePlanEntity updatePlan) throws
})
.flatMap(Collection::stream);

pathParametersValidationService.validate(api.getType(), apiFlows, planFlows);
flowValidationDomainService.validatePathParameters(api.getType(), apiFlows, planFlows);
}

private void checkStatusOfGeneralConditions(Plan plan) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import static io.gravitee.rest.api.model.api.ApiLifecycleState.UNPUBLISHED;
import static org.apache.commons.lang3.StringUtils.isBlank;

import io.gravitee.apim.core.flow.domain_service.FlowValidationDomainService;
import io.gravitee.definition.model.DefinitionVersion;
import io.gravitee.definition.model.v4.ApiType;
import io.gravitee.definition.model.v4.flow.Flow;
Expand Down Expand Up @@ -51,7 +52,6 @@
import io.gravitee.rest.api.service.v4.validation.FlowValidationService;
import io.gravitee.rest.api.service.v4.validation.GroupValidationService;
import io.gravitee.rest.api.service.v4.validation.ListenerValidationService;
import io.gravitee.rest.api.service.v4.validation.PathParametersValidationService;
import io.gravitee.rest.api.service.v4.validation.PlanValidationService;
import io.gravitee.rest.api.service.v4.validation.ResourcesValidationService;
import io.gravitee.rest.api.service.v4.validation.TagsValidationService;
Expand All @@ -78,8 +78,8 @@ public class ApiValidationServiceImpl extends TransactionalService implements Ap
private final AnalyticsValidationService analyticsValidationService;
private final PlanSearchService planSearchService;
private final PlanValidationService planValidationService;
private final PathParametersValidationService pathParametersValidationService;
private final ApiServicePluginService apiServicePluginService;
private final FlowValidationDomainService flowValidationDomainService;

public ApiValidationServiceImpl(
final TagsValidationService tagsValidationService,
Expand All @@ -91,8 +91,8 @@ public ApiValidationServiceImpl(
final AnalyticsValidationService loggingValidationService,
final PlanSearchService planSearchService,
final PlanValidationService planValidationService,
final PathParametersValidationService pathParametersValidationService,
ApiServicePluginService apiServicePluginService
ApiServicePluginService apiServicePluginService,
FlowValidationDomainService flowValidationDomainService
) {
this.tagsValidationService = tagsValidationService;
this.groupValidationService = groupValidationService;
Expand All @@ -103,8 +103,8 @@ public ApiValidationServiceImpl(
this.analyticsValidationService = loggingValidationService;
this.planSearchService = planSearchService;
this.planValidationService = planValidationService;
this.pathParametersValidationService = pathParametersValidationService;
this.apiServicePluginService = apiServicePluginService;
this.flowValidationDomainService = flowValidationDomainService;
}

@Override
Expand Down Expand Up @@ -143,7 +143,7 @@ public void validateAndSanitizeNewApi(
// Validate and clean flow
newApiEntity.setFlows(flowValidationService.validateAndSanitize(newApiEntity.getType(), newApiEntity.getFlows()));

pathParametersValidationService.validate(
flowValidationDomainService.validatePathParameters(
newApiEntity.getType(),
(newApiEntity.getFlows() != null ? newApiEntity.getFlows().stream() : Stream.empty()),
Stream.empty()
Expand Down Expand Up @@ -204,7 +204,7 @@ public void validateAndSanitizeUpdateApi(
updateApiEntity.setPlans(planValidationService.validateAndSanitize(updateApiEntity.getType(), updateApiEntity.getPlans()));

// Validate path parameters
pathParametersValidationService.validate(
flowValidationDomainService.validatePathParameters(
updateApiEntity.getType(),
(updateApiEntity.getFlows() != null ? updateApiEntity.getFlows().stream() : Stream.empty()),
getPlansFlows(updateApiEntity.getPlans())
Expand Down Expand Up @@ -258,7 +258,7 @@ public void validateAndSanitizeImportApiForCreation(
apiEntity.setPlans(planValidationService.validateAndSanitize(apiEntity.getType(), apiEntity.getPlans()));

// Validate path parameters
pathParametersValidationService.validate(
flowValidationDomainService.validatePathParameters(
apiEntity.getType(),
(apiEntity.getFlows() != null ? apiEntity.getFlows().stream() : Stream.empty()),
getPlansFlows(apiEntity.getPlans())
Expand Down
Loading