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
18 changes: 18 additions & 0 deletions docs/backend/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,24 @@ Rules:
browser on its own.
- Unauthenticated requests get a JSON 401 (not a redirect to `/login`), so `fetch` callers can handle them.

### Caching the metadata endpoints

View descriptors, entity metadata and navigation (`GET /apps/api/metadata/views/{id}`, `/entities`, `/entities/{id}`,
`/entities/{id}/views[/{view}]`, `/entities/by-path`, `/navigation`) are practically static, but not always: texts depend
on the request locale (`Messages.getDefaultLocale()`, which an application resolves per request through its
`LocaleProvider`), and an application may build descriptors dynamically or vary them per user. The framework therefore
adds nothing by default and leaves the decision to the application through `MetadataHttpCachePolicy`:

- **Default:** no cache headers, exactly as before.
- **`dynamia.app.metadata.http-cache=etag`:** the included `ContentEtagMetadataHttpCachePolicy`. A strong ETag from the
content and the locale, `Cache-Control: private, no-cache` and `Vary: Accept-Language`. The client revalidates on every
use and gets a `304` with no body when nothing changed, and never a stale descriptor after a deployment or a hot-reload.
- **Your own policy:** register a `MetadataHttpCachePolicy` bean (for example an ETag from the application version, or a
`Vary` that includes the user). It replaces the included one.

Only the read-only endpoints above take part; actions, entity data (`/entities/ref/**`) and executions are never cached,
and neither are `/apps/api/dashboard` or `/apps/api/reports`.

---

## Dependency Injection & Spring Integration
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package tools.dynamia.app.controllers.cache;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Locale;

/**
* Included policy, enabled with {@code dynamia.app.metadata.http-cache=etag}: a strong ETag derived from the content
* and the locale, {@code Cache-Control: private, no-cache} and {@code Vary: Accept-Language}.
* <p>
* {@code no-cache} makes the client revalidate on every use, which costs a {@code 304} with no body instead of the
* whole descriptor, and never serves a stale one after a deployment or a descriptor hot-reload: the ETag is the hash of
* what would be sent, so it changes with it.
*/
public class ContentEtagMetadataHttpCachePolicy implements MetadataHttpCachePolicy {

@Override
public MetadataCacheDirectives directives(String path, Locale locale, byte[] content) {
MessageDigest digest = sha256();
digest.update(locale.toLanguageTag().getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(content);
String eTag = "\"" + HexFormat.of().formatHex(digest.digest(), 0, 16) + "\"";
return new MetadataCacheDirectives(eTag, "private, no-cache", "Accept-Language");
}

private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package tools.dynamia.app.controllers.cache;

/**
* HTTP cache headers for one metadata response.
*
* @param eTag the validator, quoted ({@code "abc"}); when present a matching {@code If-None-Match} is answered with
* {@code 304 Not Modified} and no body. May be {@code null}.
* @param cacheControl the {@code Cache-Control} value, or {@code null} to not set it
* @param vary the {@code Vary} header names, comma separated, or {@code null} to not set it
*/
public record MetadataCacheDirectives(String eTag, String cacheControl, String vary) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package tools.dynamia.app.controllers.cache;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.MethodParameter;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import tools.dynamia.app.controllers.ApplicationMetadataController;
import tools.dynamia.commons.Messages;
import tools.dynamia.commons.StringPojoParser;
import tools.jackson.databind.json.JsonMapper;

import java.util.List;

/**
* Applies the {@link MetadataHttpCachePolicy} to the read-only metadata endpoints of
* {@link ApplicationMetadataController}: view descriptors, entity metadata and navigation. Nothing else is touched
* (actions, entity data and executions are never cached).
* <p>
* The policy is the application's bean if there is one; otherwise {@link ContentEtagMetadataHttpCachePolicy} when
* {@code dynamia.app.metadata.http-cache=etag}; otherwise none, and responses are exactly as before.
*/
@RestControllerAdvice(assignableTypes = ApplicationMetadataController.class)
public class MetadataHttpCacheAdvice implements ResponseBodyAdvice<Object> {

/** Property that enables the included ETag policy. */
public static final String PROPERTY = "dynamia.app.metadata.http-cache";

private static final List<String> CACHEABLE = List.of(
"/views/*", "/navigation", "/entities", "/entities/by-path", "/entities/*", "/entities/*/views", "/entities/*/views/*");

private final AntPathMatcher matcher = new AntPathMatcher();
private final MetadataHttpCachePolicy policy;
private final JsonMapper mapper;

public MetadataHttpCacheAdvice(ObjectProvider<MetadataHttpCachePolicy> custom, ObjectProvider<JsonMapper> mapper, Environment environment) {
MetadataHttpCachePolicy configured = custom.getIfAvailable();
if (configured == null && "etag".equalsIgnoreCase(environment.getProperty(PROPERTY))) {
configured = new ContentEtagMetadataHttpCachePolicy();
}
this.policy = configured;
this.mapper = mapper.getIfAvailable(StringPojoParser::createJsonMapper);
}

@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return policy != null;
}

@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
if (policy == null || body == null || request.getMethod() != HttpMethod.GET
|| !(request instanceof ServletServerHttpRequest servletRequest)
|| !(response instanceof ServletServerHttpResponse servletResponse)) {
return body;
}
String path = relativePath(servletRequest);
if (!isCacheable(path)) {
return body;
}

MetadataCacheDirectives directives = policy.directives(path, Messages.getDefaultLocale(), mapper.writeValueAsBytes(body));
if (directives == null) {
return body;
}
if (directives.cacheControl() != null) {
response.getHeaders().set("Cache-Control", directives.cacheControl());
}
if (directives.vary() != null) {
response.getHeaders().set("Vary", directives.vary());
}
if (directives.eTag() != null) {
var webRequest = new ServletWebRequest(servletRequest.getServletRequest(), servletResponse.getServletResponse());
if (webRequest.checkNotModified(directives.eTag())) {
return null; // 304: the validator matched, no body
}
}
return body;
}

private static String relativePath(ServletServerHttpRequest request) {
String uri = request.getServletRequest().getRequestURI();
String context = request.getServletRequest().getContextPath();
String path = context != null && !context.isEmpty() && uri.startsWith(context) ? uri.substring(context.length()) : uri;
return path.startsWith(ApplicationMetadataController.PATH) ? path.substring(ApplicationMetadataController.PATH.length()) : path;
}

private boolean isCacheable(String path) {
return CACHEABLE.stream().anyMatch(pattern -> matcher.match(pattern, path));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package tools.dynamia.app.controllers.cache;

import java.util.Locale;

/**
* Decides how the clients may cache what {@code /apps/api/metadata/**} serves (view descriptors, entity metadata and
* navigation). Those responses are practically static, but not always: texts depend on the request locale and an
* application may build descriptors dynamically or vary them per user. The framework therefore does not impose a
* policy: the application registers a bean of this type, or enables the included one with
* {@code dynamia.app.metadata.http-cache=etag}. Without either, nothing is added to the responses.
*
* <pre>{@code
* @Component
* class VersionedMetadataCache implements MetadataHttpCachePolicy {
* public MetadataCacheDirectives directives(String path, Locale locale, byte[] content) {
* return new MetadataCacheDirectives("\"" + version + "-" + locale + "\"", "private, no-cache", "Accept-Language");
* }
* }
* }</pre>
*/
public interface MetadataHttpCachePolicy {

/**
* Cache directives for one response.
*
* @param path the request path relative to {@code /apps/api/metadata}, e.g. {@code /views/clientesWidget}
* @param locale the locale the response was produced with ({@code Messages.getDefaultLocale()})
* @param content the serialized JSON body
* @return the directives, or {@code null} to leave the response untouched
*/
MetadataCacheDirectives directives(String path, Locale locale, byte[] content);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package tools.dynamia.app.controllers.cache;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import tools.jackson.databind.json.JsonMapper;

import java.util.List;
import java.util.Locale;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Verifies the HTTP cache of the metadata endpoints: nothing changes by default, the included ETag policy validates by
* content, and only the read-only metadata endpoints take part.
*/
class MetadataHttpCacheAdviceTest {

private static final String PATH = "/apps/api/metadata";
private final Map<String, Object> descriptor = Map.of("id", "clientesWidget", "view", "table");

private MetadataHttpCacheAdvice advice(MetadataHttpCachePolicy custom, String property) {
var factory = new DefaultListableBeanFactory();
if (custom != null) {
factory.registerSingleton("policy", custom);
}
var environment = new MockEnvironment();
if (property != null) {
environment.setProperty(MetadataHttpCacheAdvice.PROPERTY, property);
}
return new MetadataHttpCacheAdvice(factory.getBeanProvider(MetadataHttpCachePolicy.class), factory.getBeanProvider(JsonMapper.class), environment);
}

private record Exchange(MockHttpServletRequest request, MockHttpServletResponse response, Object body) {
}

private Exchange get(MetadataHttpCacheAdvice advice, String method, String path, String ifNoneMatch, Object body) {
var request = new MockHttpServletRequest(method, PATH + path);
if (ifNoneMatch != null) {
request.addHeader("If-None-Match", ifNoneMatch);
}
var response = new MockHttpServletResponse();
Object result = advice.beforeBodyWrite(body, null, MediaType.APPLICATION_JSON, null,
new ServletServerHttpRequest(request), new ServletServerHttpResponse(response));
return new Exchange(request, response, result);
}

@Test
void doesNothingByDefault() {
var advice = advice(null, null);

assertFalse(advice.supports(null, null));
var exchange = get(advice, "GET", "/views/clientesWidget", null, descriptor);
assertSame(descriptor, exchange.body());
assertNull(exchange.response().getHeader("ETag"));
assertNull(exchange.response().getHeader("Cache-Control"));
}

@Test
void etagPropertyEnablesContentValidation() {
var advice = advice(null, "etag");

assertTrue(advice.supports(null, null));
var exchange = get(advice, "GET", "/views/clientesWidget", null, descriptor);

assertSame(descriptor, exchange.body());
assertNotNull(exchange.response().getHeader("ETag"));
assertEquals("private, no-cache", exchange.response().getHeader("Cache-Control"));
assertEquals("Accept-Language", exchange.response().getHeader("Vary"));
assertEquals(200, exchange.response().getStatus());
}

@Test
void aMatchingIfNoneMatchAnswers304WithNoBody() {
var advice = advice(null, "etag");
String eTag = get(advice, "GET", "/views/clientesWidget", null, descriptor).response().getHeader("ETag");

var exchange = get(advice, "GET", "/views/clientesWidget", eTag, descriptor);

assertNull(exchange.body());
assertEquals(HttpStatus.NOT_MODIFIED.value(), exchange.response().getStatus());
}

@Test
void aChangedDescriptorGetsAnotherEtagAndAStaleValidatorGetsTheBody() {
var advice = advice(null, "etag");
String old = get(advice, "GET", "/views/clientesWidget", null, descriptor).response().getHeader("ETag");
var changed = Map.of("id", "clientesWidget", "view", "table", "fields", List.of("alias"));

var exchange = get(advice, "GET", "/views/clientesWidget", old, changed);

assertSame(changed, exchange.body());
assertEquals(200, exchange.response().getStatus());
assertNotEquals(old, exchange.response().getHeader("ETag"));
}

@Test
void theLocaleIsPartOfTheValidator() {
var policy = new ContentEtagMetadataHttpCachePolicy();
byte[] content = "{}".getBytes();

var es = policy.directives("/views/x", Locale.forLanguageTag("es-CO"), content).eTag();
var en = policy.directives("/views/x", Locale.ENGLISH, content).eTag();

assertNotEquals(es, en);
assertEquals(es, policy.directives("/views/x", Locale.forLanguageTag("es-CO"), content).eTag());
}

@Test
void onlyTheReadOnlyMetadataEndpointsTakePart() {
var advice = advice(null, "etag");

for (String path : List.of("/views/a", "/navigation", "/entities", "/entities/Book", "/entities/by-path", "/entities/Book/views", "/entities/Book/views/table")) {
assertNotNull(get(advice, "GET", path, null, descriptor).response().getHeader("ETag"), path);
}
for (String path : List.of("", "/actions", "/entities/ref/Book/1", "/entities/ref/Book/search", "/entities/Book/action/save")) {
assertNull(get(advice, "GET", path, null, descriptor).response().getHeader("ETag"), path);
}
assertNull(get(advice, "POST", "/views/a", null, descriptor).response().getHeader("ETag"));
}

@Test
void anApplicationPolicyReplacesTheIncludedOne() {
MetadataHttpCachePolicy custom = (path, locale, content) -> new MetadataCacheDirectives("\"v1\"", "max-age=60", null);
var advice = advice(custom, "etag");

var exchange = get(advice, "GET", "/views/a", null, descriptor);

assertEquals("\"v1\"", exchange.response().getHeader("ETag"));
assertEquals("max-age=60", exchange.response().getHeader("Cache-Control"));
assertNull(exchange.response().getHeader("Vary"));
}

@Test
void aPolicyThatReturnsNothingLeavesTheResponseUntouched() {
var advice = advice((path, locale, content) -> null, null);

var exchange = get(advice, "GET", "/views/a", null, descriptor);

assertSame(descriptor, exchange.body());
assertNull(exchange.response().getHeader("ETag"));
}
}
Loading