Skip to content
Open
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
105 changes: 103 additions & 2 deletions src/main/java/com/atomgraph/core/model/impl/Response.java
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ public class Response
* When true, the language is preserved in the ETag calculation.
*/
private final Predicate<MediaType> 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<Locale> acceptableLanguages;

public Response(Request request, Object entity, Date lastModified, EntityTag entityTag, List<MediaType> mediaTypes, List<Locale> languages, List<String> encodings)
{
Expand All @@ -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<MediaType> mediaTypes, List<Locale> languages, List<String> encodings, Predicate<MediaType> 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<MediaType> mediaTypes, List<Locale> languages, List<String> encodings, Predicate<MediaType> isMediaTypeLangSignificant, List<Locale> acceptableLanguages)
{
this(request, entity, lastModified, entityTag,
selectVariant(request, getVariants(mediaTypes, languages, encodings, isMediaTypeLangSignificant)),
isMediaTypeLangSignificant, acceptableLanguages);
}

/**
Expand All @@ -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<Variant> variants, Predicate<MediaType> 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. <code>ContainerRequest.selectVariant</code>
* overwrites its <code>varyValue</code> field on every call, and Jersey builds the <code>Vary</code> 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 <code>Vary</code> with no <code>Accept-Language</code> dimension or no
* <code>Vary</code> 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 <code>Vary</code> 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<Variant> variants)
{
List<Variant> 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<MediaType> 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<MediaType> isMediaTypeLangSignificant, List<Locale> acceptableLanguages) throws NotAcceptableException
{
if (request == null) throw new IllegalArgumentException("Request cannot be null");
if (entity == null) throw new IllegalArgumentException("Object cannot be null");
Expand All @@ -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<Variant> getVariants(List<MediaType> mediaTypes, List<Locale> languages, List<String> encodings)
Expand Down Expand Up @@ -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));
}

Expand Down Expand Up @@ -336,6 +427,16 @@ public Predicate<MediaType> 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<Locale> getAcceptableLanguages()
{
return acceptableLanguages;
}

public Request getRequest()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<jakarta.ws.rs.core.MediaType> 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<Variant> 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<jakarta.ws.rs.core.MediaType> significant, List<java.util.Locale> 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<MediaType>
{
Expand Down