diff --git a/pom.xml b/pom.xml
index ecb3ebf..3b428a2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -197,7 +197,6 @@
central-portal-snapshots
true
- published
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
{