Skip to content

Commit 6667318

Browse files
authored
Fix for #823 (#824)
* Fix for #823 * Fixes #823 * docs: enhance README.md with detailed explanation of path matching behavior
1 parent f24b930 commit 6667318

File tree

5 files changed

+151
-99
lines changed

5 files changed

+151
-99
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,21 @@ public class Main {
193193
}
194194
```
195195
196+
### Path Matching while comparing two OpenAPI paths
197+
198+
Path matching controls how paths from the old and new specs are paired during comparison (PathsDiff.java). The default matcher (DefaultPathMatcher) obfuscates path parameter names, meaning `/users/{id}` matches `/users/{userId}`. Default matcher fails on ambiguous signatures if spec contains few paths semantically identical. In case this behaviour is not fitting your use case, you can implement your own matching strategy.
199+
200+
You can plug in a custom matcher via `OpenApiDiffOptions` implementing the `PathMatcher` interface.:
201+
202+
```java
203+
OpenApiDiffOptions options = OpenApiDiffOptions
204+
.builder()
205+
.pathMatcher(new MyCustomPathMatcher())
206+
.build();
207+
208+
ChangedOpenApi diff = OpenApiCompare.fromLocations(oldSpec, newSpec, null, options);
209+
```
210+
196211
### Render difference
197212
198213
---

core/src/main/java/org/openapitools/openapidiff/core/compare/OpenApiDiffOptions.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
import org.apache.commons.configuration2.CompositeConfiguration;
77
import org.apache.commons.configuration2.YAMLConfiguration;
88
import org.apache.commons.configuration2.ex.ConfigurationException;
9+
import org.openapitools.openapidiff.core.compare.matchers.DefaultPathMatcher;
10+
import org.openapitools.openapidiff.core.compare.matchers.PathMatcher;
911

1012
public class OpenApiDiffOptions {
1113
private final CompositeConfiguration config;
14+
private PathMatcher pathMatcher;
1215

1316
private OpenApiDiffOptions(CompositeConfiguration config) {
1417
this.config = config;
@@ -18,6 +21,10 @@ public CompositeConfiguration getConfig() {
1821
return config;
1922
}
2023

24+
public PathMatcher getPathMatcher() {
25+
return pathMatcher != null ? pathMatcher : new DefaultPathMatcher();
26+
}
27+
2128
public static Builder builder() {
2229
return new Builder();
2330
}
@@ -45,5 +52,10 @@ public Builder configProperty(String propKey, String propVal) {
4552
public OpenApiDiffOptions build() {
4653
return built;
4754
}
55+
56+
public Builder pathMatcher(PathMatcher matcher) {
57+
built.pathMatcher = matcher;
58+
return this;
59+
}
4860
}
4961
}
Lines changed: 23 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
package org.openapitools.openapidiff.core.compare;
22

3-
import io.swagger.v3.oas.models.Operation;
43
import io.swagger.v3.oas.models.PathItem;
54
import io.swagger.v3.oas.models.Paths;
6-
import io.swagger.v3.oas.models.parameters.Parameter;
75
import java.util.*;
86
import java.util.regex.Matcher;
97
import java.util.regex.Pattern;
10-
import java.util.stream.IntStream;
118
import org.openapitools.openapidiff.core.model.Changed;
129
import org.openapitools.openapidiff.core.model.ChangedPaths;
1310
import org.openapitools.openapidiff.core.model.DiffContext;
@@ -22,135 +19,62 @@ public PathsDiff(OpenApiDiff openApiDiff) {
2219
this.openApiDiff = openApiDiff;
2320
}
2421

25-
private static String normalizePath(String path) {
26-
return path.replaceAll(REGEX_PATH, "{}");
27-
}
28-
29-
private static List<String> extractParameters(String path) {
30-
ArrayList<String> params = new ArrayList<>();
31-
Pattern pattern = Pattern.compile(REGEX_PATH);
32-
Matcher matcher = pattern.matcher(path);
33-
while (matcher.find()) {
34-
params.add(matcher.group(1));
35-
}
36-
return params;
37-
}
38-
3922
public DeferredChanged<ChangedPaths> diff(
4023
final Map<String, PathItem> left, final Map<String, PathItem> right) {
4124
DeferredBuilder<Changed> builder = new DeferredBuilder<>();
4225

4326
ChangedPaths changedPaths = new ChangedPaths(left, right, openApiDiff.getOptions());
4427
changedPaths.getIncreased().putAll(right);
4528

46-
left.keySet()
29+
left.entrySet()
4730
.forEach(
48-
(String url) -> {
49-
PathItem leftPath = left.get(url);
50-
String template = normalizePath(url);
31+
pathEntry -> {
32+
String leftUrl = pathEntry.getKey();
33+
PathItem leftPath = pathEntry.getValue();
5134
Optional<Map.Entry<String, PathItem>> result =
52-
changedPaths.getIncreased().entrySet().stream()
53-
.filter(item -> normalizePath(item.getKey()).equals(template))
54-
.min(
55-
(a, b) -> {
56-
if (methodsAndParametersIntersect(a.getValue(), b.getValue())) {
57-
throw new IllegalArgumentException(
58-
"Two path items have the same signature: " + template);
59-
}
60-
if (a.getKey().equals(url)) {
61-
return -1;
62-
} else if (b.getKey().equals((url))) {
63-
return 1;
64-
} else {
65-
HashSet<PathItem.HttpMethod> methodsA =
66-
new HashSet<>(a.getValue().readOperationsMap().keySet());
67-
methodsA.retainAll(leftPath.readOperationsMap().keySet());
68-
HashSet<PathItem.HttpMethod> methodsB =
69-
new HashSet<>(b.getValue().readOperationsMap().keySet());
70-
methodsB.retainAll(leftPath.readOperationsMap().keySet());
71-
return Integer.compare(methodsB.size(), methodsA.size());
72-
}
73-
});
35+
openApiDiff
36+
.getOptions()
37+
.getPathMatcher()
38+
.find(pathEntry, changedPaths.getIncreased());
7439
if (result.isPresent()) {
7540
String rightUrl = result.get().getKey();
7641
PathItem rightPath = changedPaths.getIncreased().remove(rightUrl);
7742
Map<String, String> params = new LinkedHashMap<>();
78-
if (!url.equals(rightUrl)) {
79-
List<String> oldParams = extractParameters(url);
43+
if (!leftUrl.equals(rightUrl)) {
44+
List<String> oldParams = extractParameters(leftUrl);
8045
List<String> newParams = extractParameters(rightUrl);
8146
for (int i = 0; i < oldParams.size(); i++) {
8247
params.put(oldParams.get(i), newParams.get(i));
8348
}
8449
}
8550
DiffContext context = new DiffContext(openApiDiff.getOptions());
86-
context.setUrl(url);
51+
context.setUrl(leftUrl);
8752
context.setParameters(params);
88-
context.setLeftAndRightUrls(url, rightUrl);
53+
context.setLeftAndRightUrls(leftUrl, rightUrl);
8954
builder
9055
.with(openApiDiff.getPathDiff().diff(leftPath, rightPath, context))
9156
.ifPresent(path -> changedPaths.getChanged().put(rightUrl, path));
9257
} else {
93-
changedPaths.getMissing().put(url, leftPath);
58+
changedPaths.getMissing().put(leftUrl, leftPath);
9459
}
9560
});
9661
return builder.buildIsChanged(changedPaths);
9762
}
9863

64+
private List<String> extractParameters(String path) {
65+
ArrayList<String> params = new ArrayList<>();
66+
Pattern pattern = Pattern.compile(REGEX_PATH);
67+
Matcher matcher = pattern.matcher(path);
68+
while (matcher.find()) {
69+
params.add(matcher.group(1));
70+
}
71+
return params;
72+
}
73+
9974
public static Paths valOrEmpty(Paths path) {
10075
if (path == null) {
10176
path = new Paths();
10277
}
10378
return path;
10479
}
105-
106-
/**
107-
* @param a a path form the open api spec
108-
* @param b another path from the same open api spec
109-
* @return <code>true</code> in case both paths are of the same method AND their templated
110-
* parameters are of the same type; <code>false</code> otherwise
111-
*/
112-
private static boolean methodsAndParametersIntersect(PathItem a, PathItem b) {
113-
Set<PathItem.HttpMethod> methodsA = a.readOperationsMap().keySet();
114-
for (PathItem.HttpMethod method : b.readOperationsMap().keySet()) {
115-
if (methodsA.contains(method)) {
116-
Operation left = a.readOperationsMap().get(method);
117-
Operation right = b.readOperationsMap().get(method);
118-
if (left.getParameters().size() == right.getParameters().size()) {
119-
return parametersIntersect(left.getParameters(), right.getParameters());
120-
}
121-
return false;
122-
}
123-
}
124-
return false;
125-
}
126-
127-
/**
128-
* Checks if provided parameter pairs are equal by type and format
129-
*
130-
* @param left parameters from the first compared method
131-
* @param right parameters from the second compared method
132-
* @return <code>true</code> in case each parameter pair is of the same type; <code>false</code>
133-
* otherwise
134-
*/
135-
private static boolean parametersIntersect(List<Parameter> left, List<Parameter> right) {
136-
int parametersSize = left.size();
137-
long intersectedParameters =
138-
IntStream.range(0, left.size())
139-
.filter(i -> parametersTypeEquals(left.get(i), right.get(i)))
140-
.count();
141-
return parametersSize == intersectedParameters;
142-
}
143-
144-
/**
145-
* Checks if provided parameter pair is equal by type and format
146-
*
147-
* @param left parameter from the first compared method
148-
* @param right parameter from the second compared method
149-
* @return <code>true</code> in case parameter pair is of the same type; <code>false</code>
150-
* otherwise
151-
*/
152-
private static boolean parametersTypeEquals(Parameter left, Parameter right) {
153-
return Objects.equals(left.getSchema().getType(), right.getSchema().getType())
154-
&& Objects.equals(left.getSchema().getFormat(), right.getSchema().getFormat());
155-
}
15680
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package org.openapitools.openapidiff.core.compare.matchers;
2+
3+
import io.swagger.v3.oas.models.Operation;
4+
import io.swagger.v3.oas.models.PathItem;
5+
import io.swagger.v3.oas.models.parameters.Parameter;
6+
import java.util.HashSet;
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.Objects;
10+
import java.util.Optional;
11+
import java.util.Set;
12+
import java.util.stream.IntStream;
13+
14+
/** Default implementation of PathMatcher */
15+
public class DefaultPathMatcher implements PathMatcher {
16+
17+
private static final String REGEX_PATH = "\\{([^/{}]+)}";
18+
19+
@Override
20+
public Optional<Map.Entry<String, PathItem>> find(
21+
Map.Entry<String, PathItem> what, Map<String, PathItem> candidates) {
22+
String leftUrl = what.getKey();
23+
PathItem leftPath = what.getValue();
24+
25+
final String template = normalizePath(leftUrl);
26+
return candidates.entrySet().stream()
27+
.filter(item -> normalizePath(item.getKey()).equals(template))
28+
.min(
29+
(a, b) -> {
30+
if (methodsAndParametersIntersect(a.getValue(), b.getValue())) {
31+
throw new IllegalArgumentException(
32+
"Two path items have the same signature: " + template);
33+
}
34+
if (a.getKey().equals(leftUrl)) {
35+
return -1;
36+
} else if (b.getKey().equals(leftUrl)) {
37+
return 1;
38+
} else {
39+
HashSet<PathItem.HttpMethod> methodsA =
40+
new HashSet<>(a.getValue().readOperationsMap().keySet());
41+
methodsA.retainAll(leftPath.readOperationsMap().keySet());
42+
HashSet<PathItem.HttpMethod> methodsB =
43+
new HashSet<>(b.getValue().readOperationsMap().keySet());
44+
methodsB.retainAll(leftPath.readOperationsMap().keySet());
45+
return Integer.compare(methodsB.size(), methodsA.size());
46+
}
47+
});
48+
}
49+
50+
private static String normalizePath(String path) {
51+
return path.replaceAll(REGEX_PATH, "{}");
52+
}
53+
54+
private static boolean methodsAndParametersIntersect(PathItem a, PathItem b) {
55+
Set<PathItem.HttpMethod> methodsA = a.readOperationsMap().keySet();
56+
for (PathItem.HttpMethod method : b.readOperationsMap().keySet()) {
57+
if (methodsA.contains(method)) {
58+
Operation left = a.readOperationsMap().get(method);
59+
Operation right = b.readOperationsMap().get(method);
60+
if (left.getParameters().size() == right.getParameters().size()) {
61+
return parametersIntersect(left.getParameters(), right.getParameters());
62+
}
63+
return false;
64+
}
65+
}
66+
return false;
67+
}
68+
69+
private static boolean parametersIntersect(List<Parameter> left, List<Parameter> right) {
70+
int parametersSize = left.size();
71+
long intersectedParameters =
72+
IntStream.range(0, left.size())
73+
.filter(i -> parametersTypeEquals(left.get(i), right.get(i)))
74+
.count();
75+
return parametersSize == intersectedParameters;
76+
}
77+
78+
private static boolean parametersTypeEquals(Parameter left, Parameter right) {
79+
return Objects.equals(left.getSchema().getType(), right.getSchema().getType())
80+
&& Objects.equals(left.getSchema().getFormat(), right.getSchema().getFormat());
81+
}
82+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package org.openapitools.openapidiff.core.compare.matchers;
2+
3+
import io.swagger.v3.oas.models.PathItem;
4+
import java.util.Map;
5+
import java.util.Optional;
6+
7+
/** Strategy to find a matching path. */
8+
public interface PathMatcher {
9+
10+
/**
11+
* Finds a matching path entry.
12+
*
13+
* @param what entry of the path to find
14+
* @param candidates map of right spec paths to search in
15+
* @return Optional entry of the matching right path
16+
*/
17+
Optional<Map.Entry<String, PathItem>> find(
18+
Map.Entry<String, PathItem> what, Map<String, PathItem> candidates);
19+
}

0 commit comments

Comments
 (0)