From 84f1b8c76984ff6787d68732126616e4ad19b51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Tue, 1 Sep 2026 00:16:13 +0200 Subject: [PATCH 1/3] Language-negotiated responses keep Accept-Language in Vary when no offered language is acceptable. Variant selection ran twice: request.selectVariant(variants), and on null a retry with request.selectVariant(removeLanguages(variants)). ContainerRequest.selectVariant assigns its varyValue field on every call, and ContainerResponse builds the Vary header from whatever the last call left there - skipping the header entirely when varyValue is null. So the retry published a Vary derived from a variant list with no language dimension, and only a request whose Accept-Language matched an offered language got the dimension at all. The entity had still been negotiated over Accept-Language and its content still depended on it, so a shared cache was free to store one language's representation and serve it to a client that asked for another. Reordering the two calls does not fix it: selecting over the full list last leaves varyValue null whenever nothing matches, and the response then carries no Vary at all - worse than an incomplete one. Verified, not assumed: with the calls reordered the lt request came back with no Vary header. So the language-neutral representations join the offer instead of replacing it, and one selection pass serves both purposes. The dimension stays in Vary because the list still declares languages, and a request accepting none of the offered languages still gets a representation because the list also offers language-neutral ones. A request that does match an offered language still selects the language-specific variant, so Content-Language and the language-specific ETag are unchanged - LocaleEntityTagTest.testLocales covers that and still passes. testVaryIncludesAcceptLanguage covers the three cases: a request accepting the offered language, one accepting only a language that is not offered, and a multi-entry header of the kind browsers actually send. It fails on the previous implementation - Vary comes back without the dimension - and passes on this one. Full suite: 61 tests, no failures. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L --- .../atomgraph/core/model/impl/Response.java | 30 ++++++++++++- .../core/model/impl/LocaleEntityTagTest.java | 44 ++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/atomgraph/core/model/impl/Response.java b/src/main/java/com/atomgraph/core/model/impl/Response.java index 59a32d1..0a7558e 100644 --- a/src/main/java/com/atomgraph/core/model/impl/Response.java +++ b/src/main/java/com/atomgraph/core/model/impl/Response.java @@ -91,7 +91,35 @@ public Response(Request request, Object entity, Date lastModified, EntityTag ent */ public Response(Request request, Object entity, Date lastModified, EntityTag entityTag, List variants, Predicate isMediaTypeLangSignificant) { - this(request, entity, lastModified, entityTag, request.selectVariant(variants) != null ? request.selectVariant(variants) : request.selectVariant(removeLanguages(variants)), isMediaTypeLangSignificant); + this(request, entity, lastModified, entityTag, selectVariant(request, variants), isMediaTypeLangSignificant); + } + + /** + * Selects the response variant, falling back to a language-neutral representation when the request accepts none of + * the offered languages. + * + * The language-neutral representations are offered alongside the language-specific ones in a single selection pass, + * rather than retried in a second pass over a language-stripped list. ContainerRequest.selectVariant + * overwrites its varyValue field on every call, and Jersey builds the Vary response header + * from whatever the most recent call left behind - dropping the header entirely when that call matched nothing. A + * second pass therefore published either a Vary with no Accept-Language dimension or no + * Vary at all, advertising a cache key that ignores a language the entity was in fact negotiated over, + * and leaving a shared cache free to serve one language's representation to a client that asked for another. Offering + * both in one list keeps the dimension in Vary and still serves a representation when no offered + * language is acceptable. + * + * @param request current request + * @param variants variant list + * @return selected variant, or null if not even a language-neutral representation is acceptable + */ + protected static Variant selectVariant(Request request, List variants) + { + List offer = new ArrayList<>(variants); + + for (Variant languageNeutral : removeLanguages(variants)) + if (!offer.contains(languageNeutral)) offer.add(languageNeutral); + + return request.selectVariant(offer); } public Response(Request request, Object entity, Date lastModified, EntityTag entityTag, Variant variant, Predicate isMediaTypeLangSignificant) throws NotAcceptableException diff --git a/src/test/java/com/atomgraph/core/model/impl/LocaleEntityTagTest.java b/src/test/java/com/atomgraph/core/model/impl/LocaleEntityTagTest.java index c97ce43..d3f1fd1 100644 --- a/src/test/java/com/atomgraph/core/model/impl/LocaleEntityTagTest.java +++ b/src/test/java/com/atomgraph/core/model/impl/LocaleEntityTagTest.java @@ -42,6 +42,8 @@ import org.glassfish.jersey.test.JerseyTest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -164,7 +166,47 @@ public void testLocales() assertNotEquals(langSpecificResp.getEntityTag(), resp.getEntityTag()); } - + + // a language-negotiated entity has to advertise Accept-Language as a cache key dimension whether or not one of the + // offered languages was acceptable - otherwise a shared cache may serve one language's representation to a client + // that asked for another + @Test + public void testVaryIncludesAcceptLanguage() + { + jakarta.ws.rs.core.Response acceptable = gsc.getClient(). + target(uriLang). + request(com.atomgraph.core.MediaType.APPLICATION_RDF_XML_TYPE). + header(HttpHeaders.ACCEPT_LANGUAGE, Locale.ENGLISH.getLanguage()). // the only language this resource offers + get(); + + assertEquals(200, acceptable.getStatus()); + assertNotNull(acceptable.getHeaderString(HttpHeaders.VARY)); + assertTrue(acceptable.getHeaderString(HttpHeaders.VARY).toLowerCase(Locale.ROOT).contains(HttpHeaders.ACCEPT_LANGUAGE.toLowerCase(Locale.ROOT))); + + // no offered language matches, so the variant falls back to a language-neutral one. The entity was still + // negotiated over Accept-Language and its content still depends on it, so the dimension has to survive + jakarta.ws.rs.core.Response unacceptable = gsc.getClient(). + target(uriLang). + request(com.atomgraph.core.MediaType.APPLICATION_RDF_XML_TYPE). + header(HttpHeaders.ACCEPT_LANGUAGE, Locale.forLanguageTag("lt").getLanguage()). + get(); + + assertEquals(200, unacceptable.getStatus()); + assertNotNull(unacceptable.getHeaderString(HttpHeaders.VARY)); + assertTrue(unacceptable.getHeaderString(HttpHeaders.VARY).toLowerCase(Locale.ROOT).contains(HttpHeaders.ACCEPT_LANGUAGE.toLowerCase(Locale.ROOT))); + + // a multi-entry header, as sent by every real browser, negotiates the same way + jakarta.ws.rs.core.Response multiple = gsc.getClient(). + target(uriLang). + request(com.atomgraph.core.MediaType.APPLICATION_RDF_XML_TYPE). + header(HttpHeaders.ACCEPT_LANGUAGE, "en-US,en;q=0.9,da;q=0.8,lt;q=0.7"). + get(); + + assertEquals(200, multiple.getStatus()); + assertNotNull(multiple.getHeaderString(HttpHeaders.VARY)); + assertTrue(multiple.getHeaderString(HttpHeaders.VARY).toLowerCase(Locale.ROOT).contains(HttpHeaders.ACCEPT_LANGUAGE.toLowerCase(Locale.ROOT))); + } + // make Accept-Language/Content-Language significant for RDF/XML (just as a test) public static class RDFXMLMediaTypePredicate implements Predicate { From 4971f4f6ebf0f9f6085ca48cdc22c7a067918ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Tue, 1 Sep 2026 23:54:29 +0200 Subject: [PATCH 2/3] The entity tag distinguishes representations that differ by accepted language. getVariantEntityTag hashed the content plus the selected variant, and the variant carries at most one language. For a language-significant media type that is the wrong granularity: the entity is rendered against the whole acceptable-language list, falling back per value, so two requests that select the same language-neutral variant are still different representations. With "lt" and "de" against an offer of English, one renders the Lithuanian values the data holds and the other falls back to English - byte-different pages under one strong ETag. A conditional request could be answered 304 with the wrong language. The acceptable languages now go into the hash when the media type is language-significant, which is what the isMediaTypeLangSignificant predicate already claimed to govern - its javadoc says the language is preserved in the ETag calculation, and until now only the variant's was. They arrive through a new constructor overload rather than a changed signature: jakarta.ws.rs.core.Request does not expose them, and Response is constructed from a dozen call sites across Web-Client and LinkedDataHub. Callers that supply nothing get exactly the previous entity tag, so this is additive - the test asserts that too, alongside a media type whose rendering does not depend on language being unaffected. Verified by removing the addition and watching the test fail with the two tags equal. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L --- .../atomgraph/core/model/impl/Response.java | 49 ++++++++++++++++++ .../core/model/impl/LocaleEntityTagTest.java | 51 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/main/java/com/atomgraph/core/model/impl/Response.java b/src/main/java/com/atomgraph/core/model/impl/Response.java index 0a7558e..ff5346d 100644 --- a/src/main/java/com/atomgraph/core/model/impl/Response.java +++ b/src/main/java/com/atomgraph/core/model/impl/Response.java @@ -56,6 +56,15 @@ public class Response * When true, the language is preserved in the ETag calculation. */ private final Predicate isMediaTypeLangSignificant; + + /** + * The languages the request accepts, in priority order. + * + * Distinct from the languages offered: a language-significant entity is rendered against the whole acceptable list, + * falling back per value, so two requests selecting the same variant can still differ in content. Empty when the + * caller does not supply it, in which case the entity tag ignores language as it did before. + */ + private final List acceptableLanguages; public Response(Request request, Object entity, Date lastModified, EntityTag entityTag, List mediaTypes, List languages, List encodings) { @@ -123,6 +132,26 @@ protected static Variant selectVariant(Request request, List variants) } public Response(Request request, Object entity, Date lastModified, EntityTag entityTag, Variant variant, Predicate isMediaTypeLangSignificant) throws NotAcceptableException + { + this(request, entity, lastModified, entityTag, variant, isMediaTypeLangSignificant, List.of()); + } + + /** + * Builds model response from a selected variant and the languages the request accepts. + * + * The acceptable languages are what a language-significant entity is actually rendered against - the renderer falls + * back per value over the whole list - so they, not the selected variant's single language, are what makes one + * representation different from another. Supplying them makes the entity tag distinguish those representations. + * + * @param request response entity + * @param entity response dataset + * @param lastModified last modified date + * @param entityTag entity tag + * @param variant selected variant + * @param isMediaTypeLangSignificant predicate indicating if language is significant + * @param acceptableLanguages languages the request accepts, in priority order + */ + public Response(Request request, Object entity, Date lastModified, EntityTag entityTag, Variant variant, Predicate isMediaTypeLangSignificant, List acceptableLanguages) throws NotAcceptableException { if (request == null) throw new IllegalArgumentException("Request cannot be null"); if (entity == null) throw new IllegalArgumentException("Object cannot be null"); @@ -138,6 +167,7 @@ public Response(Request request, Object entity, Date lastModified, EntityTag ent this.entityTag = entityTag; this.variant = variant; this.isMediaTypeLangSignificant = isMediaTypeLangSignificant; + this.acceptableLanguages = acceptableLanguages; } public static List getVariants(List mediaTypes, List languages, List encodings) @@ -336,6 +366,15 @@ public EntityTag getVariantEntityTag() BigInteger entityTagHash = new BigInteger(getEntityTag().getValue(), 16); BigInteger variantHash = BigInteger.valueOf(getVariant().hashCode()); entityTagHash = entityTagHash.add(variantHash); + + // a language-significant entity is rendered against the whole acceptable-language list, not the one language the + // selected variant carries. Two requests selecting the same language-neutral variant still differ: with "lt" and + // "de" against an offer of English, one renders the Lithuanian values the data holds and the other falls back to + // English. Hashing the variant alone gave those two representations one strong ETag, so a conditional request + // could be answered 304 with the wrong language + if (!getAcceptableLanguages().isEmpty() && getIsMediaTypeLangSignificant().test(getVariant().getMediaType())) + entityTagHash = entityTagHash.add(BigInteger.valueOf(getAcceptableLanguages().hashCode())); + return new EntityTag(entityTagHash.toString(16)); } @@ -364,6 +403,16 @@ public Predicate getIsMediaTypeLangSignificant() { return isMediaTypeLangSignificant; } + + /** + * Returns the languages the request accepts, in priority order, or an empty list when the caller did not supply them. + * + * @return acceptable languages + */ + public List getAcceptableLanguages() + { + return acceptableLanguages; + } public Request getRequest() { diff --git a/src/test/java/com/atomgraph/core/model/impl/LocaleEntityTagTest.java b/src/test/java/com/atomgraph/core/model/impl/LocaleEntityTagTest.java index d3f1fd1..047caf1 100644 --- a/src/test/java/com/atomgraph/core/model/impl/LocaleEntityTagTest.java +++ b/src/test/java/com/atomgraph/core/model/impl/LocaleEntityTagTest.java @@ -42,6 +42,8 @@ import org.glassfish.jersey.test.JerseyTest; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import jakarta.ws.rs.core.EntityTag; +import jakarta.ws.rs.core.Variant; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; @@ -167,6 +169,55 @@ public void testLocales() assertNotEquals(langSpecificResp.getEntityTag(), resp.getEntityTag()); } + /** + * Two requests that select the same language-neutral variant but accept different languages are different + * representations - the renderer falls back per value over the whole acceptable list - so they must not share a strong + * entity tag. Before the acceptable languages were folded in, "lt" and "de" produced byte-different pages under one ETag, + * and a conditional request could be answered 304 with the wrong language. + */ + @Test + public void testEntityTagVariesByAcceptableLanguages() + { + Variant variant = new Variant(com.atomgraph.core.MediaType.APPLICATION_RDF_XML_TYPE, (java.util.Locale) null, null); + EntityTag base = new EntityTag("cafe"); + java.util.function.Predicate significant = new RDFXMLMediaTypePredicate(); + + EntityTag lt = tagFor(variant, base, significant, List.of(java.util.Locale.forLanguageTag("lt"))); + EntityTag de = tagFor(variant, base, significant, List.of(java.util.Locale.forLanguageTag("de"))); + EntityTag alsoLt = tagFor(variant, base, significant, List.of(java.util.Locale.forLanguageTag("lt"))); + + assertNotEquals(lt, de); // different representations, different validators + assertEquals(alsoLt, lt); // same request, stable validator + + // a media type whose rendering does not depend on language is unaffected + EntityTag plainLt = tagFor(variant, base, mediaType -> false, List.of(java.util.Locale.forLanguageTag("lt"))); + EntityTag plainDe = tagFor(variant, base, mediaType -> false, List.of(java.util.Locale.forLanguageTag("de"))); + assertEquals(plainLt, plainDe); + + // callers that supply no acceptable languages keep the previous entity tag exactly + assertEquals(tagFor(variant, base, significant, List.of()), tagFor(variant, base, mediaType -> false, List.of())); + } + + /** The entity tag calculation touches no request state, so a stub keeps the test to the thing under test. */ + private Request getRequestStub() + { + return new Request() + { + @Override public String getMethod() { return "GET"; } + @Override public Variant selectVariant(List variants) { return null; } + @Override public jakarta.ws.rs.core.Response.ResponseBuilder evaluatePreconditions(EntityTag eTag) { return null; } + @Override public jakarta.ws.rs.core.Response.ResponseBuilder evaluatePreconditions(java.util.Date lastModified) { return null; } + @Override public jakarta.ws.rs.core.Response.ResponseBuilder evaluatePreconditions(java.util.Date lastModified, EntityTag eTag) { return null; } + @Override public jakarta.ws.rs.core.Response.ResponseBuilder evaluatePreconditions() { return null; } + }; + } + + private EntityTag tagFor(Variant variant, EntityTag base, java.util.function.Predicate significant, List acceptable) + { + return new com.atomgraph.core.model.impl.Response(getRequestStub(), "entity", null, base, variant, significant, acceptable). + getVariantEntityTag(); + } + // a language-negotiated entity has to advertise Accept-Language as a cache key dimension whether or not one of the // offered languages was acceptable - otherwise a shared cache may serve one language's representation to a client // that asked for another From 955f93d69b122c7759f5086de84c8a89c5693d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 2 Sep 2026 00:46:29 +0200 Subject: [PATCH 3/3] Carry the acceptable languages through the constructor callers actually use. The previous commit added the accept list to the entity tag through a new terminal-constructor overload, but every caller in Web-Client and LinkedDataHub builds a Response from media types, languages and encodings rather than from a selected Variant - so nothing could supply the list, and the entity tag went on ignoring language. Verified live before this: with the fix deployed, Accept-Language lt and de still shared ETag "bf450a1e36535bd0" while returning 25763 and 25710 bytes. The overload threads the list through the form those callers use. Still additive - the existing constructor delegates with an empty list and produces exactly the entity tag it did before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0184W2B82P2wUBntUPie223L --- .../atomgraph/core/model/impl/Response.java | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/atomgraph/core/model/impl/Response.java b/src/main/java/com/atomgraph/core/model/impl/Response.java index ff5346d..a331829 100644 --- a/src/main/java/com/atomgraph/core/model/impl/Response.java +++ b/src/main/java/com/atomgraph/core/model/impl/Response.java @@ -85,7 +85,31 @@ public Response(Request request, Object entity, Date lastModified, EntityTag ent */ public Response(Request request, Object entity, Date lastModified, EntityTag entityTag, List mediaTypes, List languages, List encodings, Predicate isMediaTypeLangSignificant) { - this(request, entity, lastModified, entityTag, getVariants(mediaTypes, languages, encodings, isMediaTypeLangSignificant), isMediaTypeLangSignificant); + this(request, entity, lastModified, entityTag, mediaTypes, languages, encodings, isMediaTypeLangSignificant, List.of()); + } + + /** + * Builds model response from request, carrying the languages the request accepts. + * + * Supplying them makes the entity tag distinguish representations that differ only by accepted language - a + * language-significant entity is rendered against the whole list, not against the single language of the selected + * variant. See the seven-argument variant constructor for why the variant alone is the wrong granularity. + * + * @param request response entity + * @param entity response dataset + * @param lastModified last modified date + * @param entityTag entity tag + * @param mediaTypes supported media types + * @param languages content languages offered + * @param encodings content type encodings + * @param isMediaTypeLangSignificant predicate indicating if language is significant + * @param acceptableLanguages languages the request accepts, in priority order + */ + public Response(Request request, Object entity, Date lastModified, EntityTag entityTag, List mediaTypes, List languages, List encodings, Predicate isMediaTypeLangSignificant, List acceptableLanguages) + { + this(request, entity, lastModified, entityTag, + selectVariant(request, getVariants(mediaTypes, languages, encodings, isMediaTypeLangSignificant)), + isMediaTypeLangSignificant, acceptableLanguages); } /**