Skip to content

Commit c207587

Browse files
authored
[GH-1319] Add Support for Path Style Parameter Expansion (#1537)
* [GH-1319] Add Support for Path Style Parameter Expansion Fixes #1319 This change adds limited Path Style support to Feign URI template-style templates. Variable expressions that start with a semi-colon `;` are now expanded in accordance to [RFC 6570 Section 3.2.7](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.7) with the following modifications: * Maps and Lists are expanded by default. * Only Single variable templates are supported. Examples: ``` {;who} ;who=fred {;half} ;half=50%25 {;empty} ;empty {;list} ;list=red;list=green;list=blue {;keys} ;semi=%3B;dot=.;comma=%2C ``` * Export Path Style Expression as an Expander for use with custom contracts * Added example to ReadMe * Additional Test Cases.
1 parent 8c8710c commit c207587

File tree

4 files changed

+213
-43
lines changed

4 files changed

+213
-43
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,38 @@ resolved values. *Example* `owner` must be alphabetic. `{owner:[a-zA-Z]*}`
171171
* Unresolved expressions are omitted.
172172
* All literals and variable values are pct-encoded, if not already encoded or marked `encoded` via a `@Param` annotation.
173173
174+
We also have limited support for Level 3, Path Style Expressions, with the following restrictions:
175+
176+
* Maps and Lists are expanded by default.
177+
* Only Single variable templates are supported.
178+
179+
*Examples:*
180+
181+
```
182+
{;who} ;who=fred
183+
{;half} ;half=50%25
184+
{;empty} ;empty
185+
{;list} ;list=red;list=green;list=blue
186+
{;map} ;semi=%3B;dot=.;comma=%2C
187+
```
188+
189+
```java
190+
public interface MatrixService {
191+
192+
@RequestLine("GET /repos{;owners}")
193+
List<Contributor> contributors(@Param("owners") List<String> owners);
194+
195+
class Contributor {
196+
String login;
197+
int contributions;
198+
}
199+
}
200+
```
201+
202+
If `owners` in the above example is defined as `Matt, Jeff, Susan`, the uri will expand to `/repos;owners=Matt;owners=Jeff;owners=Susan`
203+
204+
For more information see [RFC 6570, Section 3.2.7](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.7)
205+
174206
#### Undefined vs. Empty Values ####
175207
176208
Undefined expressions are expressions where the value for the expression is an explicit `null` or no value is provided.

core/src/main/java/feign/template/Expressions.java

Lines changed: 93 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,18 @@
1313
*/
1414
package feign.template;
1515

16+
import feign.Param.Expander;
17+
import feign.Util;
1618
import java.util.LinkedHashMap;
1719
import java.util.Map;
1820
import java.util.Map.Entry;
19-
import java.util.Optional;
2021
import java.util.regex.Matcher;
2122
import java.util.regex.Pattern;
22-
import feign.Util;
2323

2424
public final class Expressions {
25-
private static Map<Pattern, Class<? extends Expression>> expressions;
26-
27-
static {
28-
expressions = new LinkedHashMap<>();
29-
30-
/*
31-
* basic pattern for variable names. this is compliant with RFC 6570 Simple Expressions ONLY
32-
* with the following additional values allowed without required pct-encoding:
33-
*
34-
* - brackets - dashes
35-
*
36-
* see https://tools.ietf.org/html/rfc6570#section-2.3 for more information.
37-
*/
38-
39-
expressions.put(Pattern.compile("^([+#./;?&]?)(.*)$"),
40-
SimpleExpression.class);
41-
}
25+
26+
private static final String PATH_STYLE_MODIFIER = ";";
27+
private static final Pattern EXPRESSION_PATTERN = Pattern.compile("^([+#./;?&]?)(.*)$");
4228

4329
public static Expression create(final String value) {
4430

@@ -48,25 +34,15 @@ public static Expression create(final String value) {
4834
throw new IllegalArgumentException("an expression is required.");
4935
}
5036

51-
Optional<Entry<Pattern, Class<? extends Expression>>> matchedExpressionEntry =
52-
expressions.entrySet()
53-
.stream()
54-
.filter(entry -> entry.getKey().matcher(expression).matches())
55-
.findFirst();
56-
57-
if (!matchedExpressionEntry.isPresent()) {
58-
/* not a valid expression */
59-
return null;
60-
}
61-
62-
Entry<Pattern, Class<? extends Expression>> matchedExpression = matchedExpressionEntry.get();
63-
Pattern expressionPattern = matchedExpression.getKey();
64-
6537
/* create a new regular expression matcher for the expression */
6638
String variableName = null;
6739
String variablePattern = null;
68-
Matcher matcher = expressionPattern.matcher(expression);
40+
String modifier = null;
41+
Matcher matcher = EXPRESSION_PATTERN.matcher(expression);
6942
if (matcher.matches()) {
43+
/* grab the modifier */
44+
modifier = matcher.group(1).trim();
45+
7046
/* we have a valid variable expression, extract the name from the first group */
7147
variableName = matcher.group(2).trim();
7248
if (variableName.contains(":")) {
@@ -83,6 +59,12 @@ public static Expression create(final String value) {
8359
}
8460
}
8561

62+
/* check for a modifier */
63+
if (PATH_STYLE_MODIFIER.equalsIgnoreCase(modifier)) {
64+
return new PathStyleExpression(variableName, variablePattern);
65+
}
66+
67+
/* default to simple */
8668
return new SimpleExpression(variableName, variablePattern);
8769
}
8870

@@ -102,20 +84,37 @@ private static String stripBraces(String expression) {
10284
*/
10385
static class SimpleExpression extends Expression {
10486

105-
SimpleExpression(String expression, String pattern) {
106-
super(expression, pattern);
87+
private static final String DEFAULT_SEPARATOR = ",";
88+
protected String separator = DEFAULT_SEPARATOR;
89+
private boolean nameRequired = false;
90+
91+
SimpleExpression(String name, String pattern) {
92+
super(name, pattern);
93+
}
94+
95+
SimpleExpression(String name, String pattern, String separator, boolean nameRequired) {
96+
this(name, pattern);
97+
this.separator = separator;
98+
this.nameRequired = nameRequired;
10799
}
108100

109-
String encode(Object value) {
101+
protected String encode(Object value) {
110102
return UriUtils.encode(value.toString(), Util.UTF_8);
111103
}
112104

105+
@SuppressWarnings("unchecked")
113106
@Override
114-
String expand(Object variable, boolean encode) {
107+
protected String expand(Object variable, boolean encode) {
115108
StringBuilder expanded = new StringBuilder();
116109
if (Iterable.class.isAssignableFrom(variable.getClass())) {
117110
expanded.append(this.expandIterable((Iterable<?>) variable));
111+
} else if (Map.class.isAssignableFrom(variable.getClass())) {
112+
expanded.append(this.expandMap((Map<String, ?>) variable));
118113
} else {
114+
if (this.nameRequired) {
115+
expanded.append(this.encode(this.getName()))
116+
.append("=");
117+
}
119118
expanded.append((encode) ? encode(variable) : variable);
120119
}
121120

@@ -128,8 +127,7 @@ String expand(Object variable, boolean encode) {
128127
return result;
129128
}
130129

131-
132-
private String expandIterable(Iterable<?> values) {
130+
protected String expandIterable(Iterable<?> values) {
133131
StringBuilder result = new StringBuilder();
134132
for (Object value : values) {
135133
if (value == null) {
@@ -141,19 +139,71 @@ private String expandIterable(Iterable<?> values) {
141139
String expanded = this.encode(value);
142140
if (expanded.isEmpty()) {
143141
/* always append the separator */
144-
result.append(",");
142+
result.append(this.separator);
145143
} else {
146144
if (result.length() != 0) {
147-
if (!result.toString().equalsIgnoreCase(",")) {
148-
result.append(",");
145+
if (!result.toString().equalsIgnoreCase(this.separator)) {
146+
result.append(this.separator);
149147
}
150148
}
149+
if (this.nameRequired) {
150+
result.append(this.encode(this.getName()))
151+
.append("=");
152+
}
151153
result.append(expanded);
152154
}
153155
}
154156

155157
/* return the expanded value */
156158
return result.toString();
157159
}
160+
161+
protected String expandMap(Map<String, ?> values) {
162+
StringBuilder result = new StringBuilder();
163+
164+
for (Entry<String, ?> entry : values.entrySet()) {
165+
StringBuilder expanded = new StringBuilder();
166+
String name = this.encode(entry.getKey());
167+
String value = this.encode(entry.getValue().toString());
168+
169+
expanded.append(name)
170+
.append("=");
171+
if (!value.isEmpty()) {
172+
expanded.append(value);
173+
}
174+
175+
if (result.length() != 0) {
176+
result.append(this.separator);
177+
}
178+
179+
result.append(expanded);
180+
}
181+
return result.toString();
182+
}
183+
}
184+
185+
public static class PathStyleExpression extends SimpleExpression implements Expander {
186+
187+
public PathStyleExpression(String name, String pattern) {
188+
super(name, pattern, ";", true);
189+
}
190+
191+
@Override
192+
protected String expand(Object variable, boolean encode) {
193+
return this.separator + super.expand(variable, encode);
194+
}
195+
196+
@Override
197+
public String expand(Object value) {
198+
return this.expand(value, true);
199+
}
200+
201+
@Override
202+
public String getValue() {
203+
if (this.getPattern() != null) {
204+
return "{" + this.separator + this.getName() + ":" + this.getName() + "}";
205+
}
206+
return "{" + this.separator + this.getName() + "}";
207+
}
158208
}
159209
}

core/src/test/java/feign/FeignTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,7 @@ public void whenReturnTypeIsResponseNoErrorHandling() {
689689
}
690690

691691
private static class MockRetryer implements Retryer {
692+
692693
boolean tripped;
693694

694695
@Override
@@ -952,6 +953,39 @@ public void beanQueryMapEncoderWithEmptyParams() throws Exception {
952953
.hasQueryParams("/");
953954
}
954955

956+
@Test
957+
public void matrixParametersAreSupported() throws Exception {
958+
TestInterface api = new TestInterfaceBuilder()
959+
.target("http://localhost:" + server.getPort());
960+
961+
server.enqueue(new MockResponse());
962+
963+
List<String> owners = new ArrayList<>();
964+
owners.add("Mark");
965+
owners.add("Jeff");
966+
owners.add("Susan");
967+
api.matrixParameters(owners);
968+
assertThat(server.takeRequest())
969+
.hasPath("/owners;owners=Mark;owners=Jeff;owners=Susan");
970+
971+
}
972+
973+
@Test
974+
public void matrixParametersAlsoSupportMaps() throws Exception {
975+
TestInterface api = new TestInterfaceBuilder()
976+
.target("http://localhost:" + server.getPort());
977+
978+
server.enqueue(new MockResponse());
979+
Map<String, Object> properties = new LinkedHashMap<>();
980+
properties.put("account", "a");
981+
properties.put("name", "n");
982+
983+
api.matrixParametersWithMap(properties);
984+
assertThat(server.takeRequest())
985+
.hasPath("/settings;account=a;name=n");
986+
987+
}
988+
955989
interface TestInterface {
956990

957991
@RequestLine("POST /")
@@ -1037,6 +1071,12 @@ void queryMapWithQueryParams(@Param("name") String name,
10371071
@RequestLine("GET /")
10381072
void queryMapPropertyInheritence(@QueryMap ChildPojo object);
10391073

1074+
@RequestLine("GET /owners{;owners}")
1075+
void matrixParameters(@Param("owners") List<String> owners);
1076+
1077+
@RequestLine("GET /settings{;props}")
1078+
void matrixParametersWithMap(@Param("props") Map<String, Object> owners);
1079+
10401080
class DateToMillis implements Param.Expander {
10411081

10421082
@Override
@@ -1070,6 +1110,7 @@ public void setGrade(String grade) {
10701110
}
10711111

10721112
class TestInterfaceException extends Exception {
1113+
10731114
TestInterfaceException(String message) {
10741115
super(message);
10751116
}

core/src/test/java/feign/template/UriTemplateTest.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import static org.assertj.core.api.Assertions.fail;
1818
import feign.Util;
1919
import java.net.URI;
20+
import java.util.ArrayList;
2021
import java.util.Collections;
2122
import java.util.LinkedHashMap;
2223
import java.util.List;
@@ -299,4 +300,50 @@ public void encodeReserved() {
299300
String expanded = uriTemplate.expand(Collections.singletonMap("url", "https://www.google.com"));
300301
assertThat(expanded).isEqualToIgnoringCase("/get?url=https%3A%2F%2Fwww.google.com");
301302
}
303+
304+
@Test
305+
public void pathStyleExpansionSupported() {
306+
String template = "{;who}";
307+
UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8);
308+
String expanded = uriTemplate.expand(Collections.singletonMap("who", "fred"));
309+
assertThat(expanded).isEqualToIgnoringCase(";who=fred");
310+
}
311+
312+
@Test
313+
public void pathStyleExpansionEncodesReservedCharacters() {
314+
String template = "{;half}";
315+
UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8);
316+
String expanded = uriTemplate.expand(Collections.singletonMap("half", "50%"));
317+
assertThat(expanded).isEqualToIgnoringCase(";half=50%25");
318+
}
319+
320+
@Test
321+
public void pathStyleExpansionSupportedWithLists() {
322+
String template = "{;list}";
323+
UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8);
324+
325+
List<String> values = new ArrayList<>();
326+
values.add("red");
327+
values.add("green");
328+
values.add("blue");
329+
330+
String expanded = uriTemplate.expand(Collections.singletonMap("list", values));
331+
assertThat(expanded).isEqualToIgnoringCase(";list=red;list=green;list=blue");
332+
333+
}
334+
335+
@Test
336+
public void pathStyleExpansionSupportedWithMap() {
337+
String template = "/server/matrixParams{;parameters}";
338+
Map<String, Object> parameters = new LinkedHashMap<>();
339+
parameters.put("account", "a");
340+
parameters.put("name", "n");
341+
342+
Map<String, Object> values = new LinkedHashMap<>();
343+
values.put("parameters", parameters);
344+
345+
UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8);
346+
String expanded = uriTemplate.expand(values);
347+
assertThat(expanded).isEqualToIgnoringCase("/server/matrixParams;account=a;name=n");
348+
}
302349
}

0 commit comments

Comments
 (0)