diff --git a/docs/backend/ARCHITECTURE.md b/docs/backend/ARCHITECTURE.md index baffb0e9..a863094f 100644 --- a/docs/backend/ARCHITECTURE.md +++ b/docs/backend/ARCHITECTURE.md @@ -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 diff --git a/platform/app/src/main/java/tools/dynamia/app/controllers/cache/ContentEtagMetadataHttpCachePolicy.java b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/ContentEtagMetadataHttpCachePolicy.java new file mode 100644 index 00000000..309802ec --- /dev/null +++ b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/ContentEtagMetadataHttpCachePolicy.java @@ -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}. + *

+ * {@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); + } + } +} diff --git a/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataCacheDirectives.java b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataCacheDirectives.java new file mode 100644 index 00000000..072aea6b --- /dev/null +++ b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataCacheDirectives.java @@ -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) { +} diff --git a/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataHttpCacheAdvice.java b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataHttpCacheAdvice.java new file mode 100644 index 00000000..39e6a673 --- /dev/null +++ b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataHttpCacheAdvice.java @@ -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). + *

+ * 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 { + + /** Property that enables the included ETag policy. */ + public static final String PROPERTY = "dynamia.app.metadata.http-cache"; + + private static final List 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 custom, ObjectProvider 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> converterType) { + return policy != null; + } + + @Override + public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, + Class> 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)); + } +} diff --git a/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataHttpCachePolicy.java b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataHttpCachePolicy.java new file mode 100644 index 00000000..65dfe130 --- /dev/null +++ b/platform/app/src/main/java/tools/dynamia/app/controllers/cache/MetadataHttpCachePolicy.java @@ -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. + * + *
{@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");
+ *     }
+ * }
+ * }
+ */ +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); +} diff --git a/platform/app/src/test/java/tools/dynamia/app/controllers/cache/MetadataHttpCacheAdviceTest.java b/platform/app/src/test/java/tools/dynamia/app/controllers/cache/MetadataHttpCacheAdviceTest.java new file mode 100644 index 00000000..f97c5e37 --- /dev/null +++ b/platform/app/src/test/java/tools/dynamia/app/controllers/cache/MetadataHttpCacheAdviceTest.java @@ -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 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")); + } +}