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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +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;

Expand All @@ -27,6 +36,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);
Expand All @@ -35,28 +46,132 @@ 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);
}

@Override
public Collection<String> 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();
}

@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 String contentType(String name, String value) {
if (!HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(name)) {
return value;
private boolean applyUpstream(String name, String value) {
String upstream = upstream();
if (!isContentType(name) || upstream == null) {
return false;
}
String visible = visibleToSpring(upstream);
springVisibleContentType = visible != null ? visible : 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);
}
}

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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -53,6 +54,48 @@ 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.TEXT_XML);
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 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();
Expand Down
Loading