From 93216b3a8abbc4ed57ef1619c924cabb7c64da9c Mon Sep 17 00:00:00 2001 From: Francisco J Lopez-Pellicer Date: Tue, 20 Oct 2026 15:02:40 +0200 Subject: [PATCH 1/2] fix(wfs): keep GeoServer Content-Type off Spring's parser Spring 7 re-reads the servlet Content-Type while writing the body. A slash in subtype=gml/2.1.2 became a 500. The response header stays the upstream string. --- CHANGELOG.md | 2 +- .../service/UpstreamContentTypeFilter.java | 64 +++++++++++++++++-- .../UpstreamContentTypeFilterTest.java | 22 +++++++ 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90be385..e5c7759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **WFS**: Upstream `Content-Type` values pass through unchanged, including GeoServer `text/xml; subtype=gml/2.1.2`. The proxy sets a different type only when it replaces the body, such as `application/problem+json`. +- **WFS**: Upstream `Content-Type` values pass through unchanged, including GeoServer `text/xml; subtype=gml/2.1.2`. Spring reads a separate parseable value, so the slash in `subtype` is not a 500. The proxy sets a different type only when it replaces the body, such as `application/problem+json`. ### Added diff --git a/src/main/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilter.java b/src/main/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilter.java index f6ceca3..0df3d99 100644 --- a/src/main/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilter.java +++ b/src/main/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilter.java @@ -6,6 +6,8 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponseWrapper; import java.io.IOException; +import java.util.Collection; +import java.util.List; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -27,6 +29,8 @@ protected void doFilterInternal( private static final class ContentTypeResponse extends HttpServletResponseWrapper { private final HttpServletRequest request; + private String springVisibleContentType; + private boolean upstreamApplied; private ContentTypeResponse(HttpServletRequest request, HttpServletResponse response) { super(response); @@ -35,28 +39,74 @@ private ContentTypeResponse(HttpServletRequest request, HttpServletResponse resp @Override public void setHeader(String name, String value) { - super.setHeader(name, contentType(name, value)); + if (applyUpstream(name, value)) { + return; + } + super.setHeader(name, value); } @Override public void addHeader(String name, String value) { - super.addHeader(name, contentType(name, value)); + if (applyUpstream(name, value)) { + return; + } + super.addHeader(name, value); } @Override public void setContentType(String type) { - super.setContentType(contentType(HttpHeaders.CONTENT_TYPE, type)); + if (applyUpstream(HttpHeaders.CONTENT_TYPE, type)) { + return; + } + super.setContentType(type); + } + + @Override + public String getHeader(String name) { + if (springVisibleContentType != null && isContentType(name)) { + return springVisibleContentType; + } + return super.getHeader(name); } - private String contentType(String name, String value) { - if (!HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(name)) { - return value; + @Override + public Collection getHeaders(String name) { + if (springVisibleContentType != null && isContentType(name)) { + return List.of(springVisibleContentType); } + return super.getHeaders(name); + } + + @Override + public String getContentType() { + String upstream = upstream(); + if (upstreamApplied && upstream != null) { + return upstream; + } + return super.getContentType(); + } + + private boolean applyUpstream(String name, String value) { + String upstream = upstream(); + if (!isContentType(name) || upstream == null) { + return false; + } + springVisibleContentType = value; + super.setHeader(name, upstream); + upstreamApplied = true; + return true; + } + + private String upstream() { Object raw = request.getAttribute(UpstreamContentType.ATTRIBUTE); if (raw instanceof String contentType && !contentType.isBlank()) { return contentType; } - return value; + return null; + } + + private static boolean isContentType(String name) { + return HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(name); } } } diff --git a/src/test/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilterTest.java b/src/test/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilterTest.java index 85cdc93..20bc036 100644 --- a/src/test/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilterTest.java +++ b/src/test/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilterTest.java @@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; @@ -53,6 +54,27 @@ void leavesAConverterContentTypeWhenNothingWasRemembered() throws Exception { assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo("image/png"); } + @Test + void springReadsALegalContentTypeWhileTheResponseKeepsTheUpstreamString() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(UpstreamContentType.ATTRIBUTE, "text/xml; subtype=gml/2.1.2"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (req, res) -> { + HttpServletResponse http = (HttpServletResponse) res; + http.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE); + assertThat(MediaType.parseMediaType(http.getHeader(HttpHeaders.CONTENT_TYPE))) + .isEqualTo(MediaType.APPLICATION_OCTET_STREAM); + assertThat(http.getContentType()).isEqualTo("text/xml; subtype=gml/2.1.2"); + }); + + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)) + .isEqualTo("text/xml; subtype=gml/2.1.2"); + } + @Test void addHeaderFromTheMessageConverterUsesTheUpstreamValue() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); From 63456719c7d60bfd178ff0e6ce3cc0eae9593bb4 Mon Sep 17 00:00:00 2001 From: Francisco J Lopez-Pellicer Date: Tue, 20 Oct 2026 15:17:32 +0200 Subject: [PATCH 2/2] fix(wfs): show Spring the standards-compliant content type Mime4j parses the upstream header. Spring sees text/xml and any parameter it can parse. The response keeps the original string. --- CHANGELOG.md | 2 +- build.gradle | 1 + gradle/libs.versions.toml | 2 + .../service/UpstreamContentTypeFilter.java | 67 ++++++++++++++++++- .../UpstreamContentTypeFilterTest.java | 23 ++++++- 5 files changed, 92 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c7759..b28d09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **WFS**: Upstream `Content-Type` values pass through unchanged, including GeoServer `text/xml; subtype=gml/2.1.2`. Spring reads a separate parseable value, so the slash in `subtype` is not a 500. The proxy sets a different type only when it replaces the body, such as `application/problem+json`. +- **WFS**: Upstream `Content-Type` values pass through unchanged, including GeoServer `text/xml; subtype=gml/2.1.2`. Spring reads the standards-compliant part (`text/xml`, plus any parameter it can parse), so the slash in `subtype` is not a 500. The proxy sets a different type only when it replaces the body, such as `application/problem+json`. ### Added diff --git a/build.gradle b/build.gradle index 6eadba4..0acc5c8 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,7 @@ dependencies { // HTTP Client implementation libs.okhttp3 + implementation libs.mime4j.core implementation 'org.sitmun:sitmun-proxy-contract:1.2.9' // URI Templates diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2caaaf4..3e012c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ h2 = "2.2.224" json = "20240303" jjwt = "0.12.6" okhttp3 = "5.5.0" +mime4j = "0.8.12" handy-uri-templates = "2.1.8" [libraries] @@ -35,6 +36,7 @@ jjwt-jackson = { group = "io.jsonwebtoken", name = "jjwt-jackson", version.ref = # HTTP Client okhttp3 = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp3" } +mime4j-core = { group = "org.apache.james", name = "apache-mime4j-core", version.ref = "mime4j" } # URI Templates handyUriTemplates = { group = "com.damnhandy", name = "handy-uri-templates", version.ref = "handy-uri-templates" } diff --git a/src/main/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilter.java b/src/main/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilter.java index 0df3d99..8ce22d8 100644 --- a/src/main/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilter.java +++ b/src/main/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilter.java @@ -6,9 +6,16 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponseWrapper; import java.io.IOException; +import java.nio.charset.Charset; import java.util.Collection; import java.util.List; +import org.apache.james.mime4j.stream.NameValuePair; +import org.apache.james.mime4j.stream.RawBody; +import org.apache.james.mime4j.stream.RawField; +import org.apache.james.mime4j.stream.RawFieldParser; import org.springframework.http.HttpHeaders; +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -86,12 +93,29 @@ public String getContentType() { return super.getContentType(); } + @Override + public void setCharacterEncoding(String charset) { + if (upstreamApplied) { + return; + } + super.setCharacterEncoding(charset); + } + + @Override + public void setCharacterEncoding(Charset charset) { + if (upstreamApplied) { + return; + } + super.setCharacterEncoding(charset); + } + private boolean applyUpstream(String name, String value) { String upstream = upstream(); if (!isContentType(name) || upstream == null) { return false; } - springVisibleContentType = value; + String visible = visibleToSpring(upstream); + springVisibleContentType = visible != null ? visible : value; super.setHeader(name, upstream); upstreamApplied = true; return true; @@ -109,4 +133,45 @@ private static boolean isContentType(String name) { return HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(name); } } + + static String visibleToSpring(String upstream) { + RawBody body = RawFieldParser.DEFAULT.parseRawBody(new RawField("Content-Type", upstream)); + String type = body.getValue(); + if (type == null || !parses(type)) { + return null; + } + String visible = type; + for (NameValuePair parameter : body.getParams()) { + String rendered = render(parameter); + if (rendered == null) { + continue; + } + String candidate = visible + "; " + rendered; + if (parses(candidate)) { + visible = candidate; + } + } + return visible; + } + + private static String render(NameValuePair parameter) { + String name = parameter.getName(); + if (name == null || name.isBlank()) { + return null; + } + String value = parameter.getValue(); + if (value == null) { + return name; + } + return name + "=" + value; + } + + private static boolean parses(String value) { + try { + MediaType.parseMediaType(value); + return true; + } catch (InvalidMediaTypeException ex) { + return false; + } + } } diff --git a/src/test/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilterTest.java b/src/test/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilterTest.java index 20bc036..0aca687 100644 --- a/src/test/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilterTest.java +++ b/src/test/java/org/sitmun/proxy/middleware/service/UpstreamContentTypeFilterTest.java @@ -67,7 +67,7 @@ void springReadsALegalContentTypeWhileTheResponseKeepsTheUpstreamString() throws HttpServletResponse http = (HttpServletResponse) res; http.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE); assertThat(MediaType.parseMediaType(http.getHeader(HttpHeaders.CONTENT_TYPE))) - .isEqualTo(MediaType.APPLICATION_OCTET_STREAM); + .isEqualTo(MediaType.TEXT_XML); assertThat(http.getContentType()).isEqualTo("text/xml; subtype=gml/2.1.2"); }); @@ -75,6 +75,27 @@ void springReadsALegalContentTypeWhileTheResponseKeepsTheUpstreamString() throws .isEqualTo("text/xml; subtype=gml/2.1.2"); } + @Test + void springKeepsAStandardParameterAndDropsTheGmlSubtype() throws Exception { + String upstream = "text/xml; charset=utf-8; subtype=gml/2.1.2"; + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(UpstreamContentType.ATTRIBUTE, upstream); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter( + request, + response, + (req, res) -> { + HttpServletResponse http = (HttpServletResponse) res; + http.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_OCTET_STREAM_VALUE); + assertThat(MediaType.parseMediaType(http.getHeader(HttpHeaders.CONTENT_TYPE))) + .isEqualTo(MediaType.parseMediaType("text/xml; charset=utf-8")); + assertThat(http.getContentType()).isEqualTo(upstream); + }); + + assertThat(response.getHeader(HttpHeaders.CONTENT_TYPE)).isEqualTo(upstream); + } + @Test void addHeaderFromTheMessageConverterUsesTheUpstreamValue() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest();