Skip to content

Commit ec12922

Browse files
authored
fix: fall back to literal path on path-extension 406 (#24479)
1 parent e2bd9f7 commit ec12922

2 files changed

Lines changed: 106 additions & 10 deletions

File tree

dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/mvc/CustomRequestMappingHandlerMapping.java

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.hisp.dhis.webapi.security.config.WebMvcConfig;
3737
import org.hisp.dhis.webapi.view.SuffixMediaTypeContentNegotiationStrategy;
3838
import org.springframework.http.MediaType;
39+
import org.springframework.web.HttpMediaTypeNotAcceptableException;
3940
import org.springframework.web.bind.annotation.RequestMethod;
4041
import org.springframework.web.method.HandlerMethod;
4142
import org.springframework.web.servlet.HandlerMapping;
@@ -53,12 +54,14 @@
5354
*
5455
* <p>It also reinstates path-extension and trailing-slash matching without relying on Spring's
5556
* removed/deprecated handler-mapping flags ({@code setUseSuffixPatternMatch} / {@code
56-
* setUseTrailingSlashMatch} / {@code favorPathExtension}). For each request the literal path is
57-
* matched first so controllers that map an extension literally (e.g. OpenAPI's {@code
58-
* /openapi/openapi.json}) keep working. Only if that has no handler do we fall back to matching the
59-
* path with a trailing slash and/or a registered media-type extension removed (e.g. {@code
60-
* /api/dataElements.json} to {@code /api/dataElements}), recording the resolved media type for
61-
* {@link SuffixMediaTypeContentNegotiationStrategy}.
57+
* setUseTrailingSlashMatch} / {@code favorPathExtension}). For each request that ends with a
58+
* registered media-type extension (or trailing slash), the suffix-stripped path is matched first so
59+
* content negotiation works and generic {@code /{property}} handlers do not swallow names like
60+
* {@code metadata.json}. If the stripped lookup finds no handler, or finds one whose {@code
61+
* produces} cannot satisfy the extension-derived media type ({@code 406}), we fall back to the
62+
* original path so controllers that map an extension literally (analytics {@code .xml}/{@code .csv}
63+
* downloads, OpenAPI's {@code /openapi/openapi.json}) keep working. The resolved media type is
64+
* recorded for {@link SuffixMediaTypeContentNegotiationStrategy}.
6265
*
6366
* <p>Forward-compatible on Spring 6.2 (Spring 7 readiness PR-F).
6467
*/
@@ -92,15 +95,30 @@ protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Ex
9295
// When the path ends with a registered media-type extension (e.g. .json / .json.zip), prefer
9396
// matching the suffix-stripped path so content negotiation works and generic /{property}
9497
// handlers do not swallow "metadata.json" as a property name. If that fails (literal-extension
95-
// controller mappings such as /openapi/openapi.json), fall back to the original path.
98+
// controller mappings such as /openapi/openapi.json), or the stripped path matches a handler
99+
// whose produces cannot satisfy the extension-derived media type (406 - common for analytics
100+
// download endpoints that map .xml/.csv/... literally next to a JSON produces handler), fall
101+
// back to the original path.
96102
//
97103
// Paths without a registered extension: match as-is, then fall back to trailing-slash strip.
98104
HttpServletRequest normalized = normalize(request);
99105
if (normalized != request) {
100106
clearAndReparsePathCaches(normalized);
101-
HandlerMethod stripped = super.getHandlerInternal(normalized);
102-
if (stripped != null) {
103-
return stripped;
107+
try {
108+
HandlerMethod stripped = super.getHandlerInternal(normalized);
109+
if (stripped != null) {
110+
return stripped;
111+
}
112+
} catch (HttpMediaTypeNotAcceptableException notAcceptable) {
113+
// Stripped path matched a handler, but its produces does not include the media type forced
114+
// by the path extension. Prefer a literal-suffix mapping when one exists; otherwise rethrow
115+
// so the client still gets 406 rather than a misleading 404.
116+
clearAndReparsePathCaches(request);
117+
HandlerMethod literal = super.getHandlerInternal(request);
118+
if (literal != null) {
119+
return literal;
120+
}
121+
throw notAcceptable;
104122
}
105123
// No stripped handler - try the original path (literal extension mappings).
106124
clearAndReparsePathCaches(request);

dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/mvc/CustomRequestMappingHandlerMappingNormalizeTest.java

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
import static org.junit.jupiter.api.Assertions.assertNotNull;
3434
import static org.junit.jupiter.api.Assertions.assertNull;
3535
import static org.junit.jupiter.api.Assertions.assertSame;
36+
import static org.junit.jupiter.api.Assertions.assertThrows;
37+
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
3638

3739
import jakarta.servlet.http.HttpServletRequest;
3840
import java.lang.reflect.Method;
@@ -45,6 +47,8 @@
4547
import org.springframework.http.MediaType;
4648
import org.springframework.mock.web.MockHttpServletRequest;
4749
import org.springframework.stereotype.Controller;
50+
import org.springframework.web.HttpMediaTypeNotAcceptableException;
51+
import org.springframework.web.accept.ContentNegotiationManager;
4852
import org.springframework.web.bind.annotation.GetMapping;
4953
import org.springframework.web.bind.annotation.RequestMapping;
5054
import org.springframework.web.method.HandlerMethod;
@@ -65,6 +69,10 @@ class CustomRequestMappingHandlerMappingNormalizeTest {
6569
void setUp() throws Exception {
6670
mapping = new CustomRequestMappingHandlerMapping();
6771
mapping.setApplicationContext(new StaticApplicationContext());
72+
// Needed so produces conditions evaluate the suffix-forced media type the same way production
73+
// does via SuffixMediaTypeContentNegotiationStrategy (PR-F / #24463).
74+
mapping.setContentNegotiationManager(
75+
new ContentNegotiationManager(new SuffixMediaTypeContentNegotiationStrategy()));
6876
mapping.afterPropertiesSet();
6977

7078
Method method = SampleController.class.getMethod("list");
@@ -78,6 +86,24 @@ void setUp() throws Exception {
7886
mapping.getMappingForMethod(openapi, SampleController.class),
7987
new SampleController(),
8088
openapi);
89+
90+
Method queryJson = AnalyticsStyleController.class.getMethod("queryJson", String.class);
91+
mapping.registerMapping(
92+
mapping.getMappingForMethod(queryJson, AnalyticsStyleController.class),
93+
new AnalyticsStyleController(),
94+
queryJson);
95+
96+
Method queryXml = AnalyticsStyleController.class.getMethod("queryXml", String.class);
97+
mapping.registerMapping(
98+
mapping.getMappingForMethod(queryXml, AnalyticsStyleController.class),
99+
new AnalyticsStyleController(),
100+
queryXml);
101+
102+
Method onlyJson = AnalyticsStyleController.class.getMethod("onlyJson", String.class);
103+
mapping.registerMapping(
104+
mapping.getMappingForMethod(onlyJson, AnalyticsStyleController.class),
105+
new AnalyticsStyleController(),
106+
onlyJson);
81107
}
82108

83109
@Test
@@ -93,6 +119,31 @@ void literalExtensionMappingWinsWhenStrippedPathHasNoHandler() throws Exception
93119
SuffixMediaTypeContentNegotiationStrategy.SUFFIX_MEDIA_TYPE_ATTRIBUTE));
94120
}
95121

122+
/**
123+
* Regression for analytics-style dual mappings: JSON produces on {@code /query/{id}} plus a
124+
* literal {@code /query/{id}.xml} download handler. Stripping the suffix must not surface the
125+
* JSON handler's 406 and block the literal download mapping (CI failure after #24463).
126+
*/
127+
@Test
128+
void literalDownloadMappingWinsWhenStrippedProducesIsIncompatible() throws Exception {
129+
MockHttpServletRequest request =
130+
request("/api/analytics/trackedEntities/query/nEenWmSyUEp.xml");
131+
HandlerMethod handler = mapping.getHandlerInternal(request);
132+
assertNotNull(handler);
133+
assertEquals("queryXml", handler.getMethod().getName());
134+
assertEquals(
135+
MediaType.APPLICATION_XML,
136+
request.getAttribute(
137+
SuffixMediaTypeContentNegotiationStrategy.SUFFIX_MEDIA_TYPE_ATTRIBUTE));
138+
}
139+
140+
@Test
141+
void rethrowsNotAcceptableWhenNoLiteralFallbackExists() {
142+
MockHttpServletRequest request = request("/api/analytics/only-json/nEenWmSyUEp.xml");
143+
assertThrows(
144+
HttpMediaTypeNotAcceptableException.class, () -> mapping.getHandlerInternal(request));
145+
}
146+
96147
@ParameterizedTest
97148
@CsvSource({
98149
"/api/dataElements.json, application/json",
@@ -190,4 +241,31 @@ public String openapi() {
190241
return "openapi";
191242
}
192243
}
244+
245+
/**
246+
* Mirrors the analytics download pattern: a generic path with restricted {@code produces} next to
247+
* a literal-suffix download mapping with no produces restriction.
248+
*/
249+
@Controller
250+
@RequestMapping
251+
static class AnalyticsStyleController {
252+
@GetMapping(
253+
value = "/api/analytics/trackedEntities/query/{trackedEntityType}",
254+
produces = {APPLICATION_JSON_VALUE, "application/javascript"})
255+
public String queryJson(String trackedEntityType) {
256+
return "json";
257+
}
258+
259+
@GetMapping(value = "/api/analytics/trackedEntities/query/{trackedEntityType}.xml")
260+
public String queryXml(String trackedEntityType) {
261+
return "xml";
262+
}
263+
264+
@GetMapping(
265+
value = "/api/analytics/only-json/{id}",
266+
produces = {APPLICATION_JSON_VALUE})
267+
public String onlyJson(String id) {
268+
return "json-only";
269+
}
270+
}
193271
}

0 commit comments

Comments
 (0)