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..a331829 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) { @@ -76,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); } /** @@ -91,10 +124,58 @@ 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 + { + 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"); @@ -110,6 +191,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) @@ -308,6 +390,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)); } @@ -336,6 +427,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 c97ce43..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,10 @@ 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; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -164,7 +168,96 @@ 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 + @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 {