Skip to content

Add htmx content cache feature #484

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 18, 2024
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
16 changes: 16 additions & 0 deletions htmx-api/src/main/java/io/avaje/htmx/api/ContentCache.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.avaje.htmx.api;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
* Mark a controller method as using a content cache.
*/
@Target(METHOD)
@Retention(RUNTIME)
public @interface ContentCache {

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.avaje.htmx.nima.jstache;

import io.avaje.htmx.nima.TemplateContentCache;
import io.avaje.htmx.nima.TemplateRender;
import io.avaje.inject.BeanScopeBuilder;
import io.avaje.inject.spi.Plugin;
Expand All @@ -11,11 +12,12 @@ public final class DefaultTemplateProvider implements Plugin {

@Override
public Class<?>[] provides() {
return new Class<?>[]{TemplateRender.class};
return new Class<?>[]{TemplateRender.class, TemplateContentCache.class};
}

@Override
public void apply(BeanScopeBuilder builder) {
builder.provideDefault(null, TemplateRender.class, JStacheTemplateRender::new);
builder.provideDefault(null, TemplateContentCache.class, SimpleContentCache::new);
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
package io.avaje.htmx.nima.jstache;

import io.avaje.htmx.nima.TemplateRender;
import io.helidon.webserver.http.ServerRequest;
import io.helidon.webserver.http.ServerResponse;
import io.jstach.jstachio.JStachio;

public final class JStacheTemplateRender implements TemplateRender {

@Override
public void render(Object viewModel, ServerRequest req, ServerResponse res) {
var content = JStachio.render(viewModel);
res.send(content);
public String render(Object viewModel) {
return JStachio.render(viewModel);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.avaje.htmx.nima.jstache;

import io.avaje.htmx.nima.TemplateContentCache;
import io.helidon.webserver.http.ServerRequest;

import java.util.concurrent.ConcurrentHashMap;

public class SimpleContentCache implements TemplateContentCache {

private final ConcurrentHashMap<String,String> localCache = new ConcurrentHashMap<>();

@Override
public String key(ServerRequest req) {
return req.requestedUri().path().rawPath();
}

@Override
public String key(ServerRequest req, Object formParams) {
return req.requestedUri().path().rawPath() + formParams;
}

@Override
public String content(String key) {
return localCache.get(key);
}

@Override
public void contentPut(String key, String content) {
localCache.put(key, content);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package io.avaje.htmx.nima;

import io.helidon.webserver.http.ServerRequest;

/**
* Defines caching of template content.
*/
public interface TemplateContentCache {

/**
* Return the key given the request.
*/
String key(ServerRequest req);

/**
* Return the key given the request with form parameters.
*/
String key(ServerRequest req, Object formParams);

/**
* Return the content given the key.
*/
String content(String key);

/**
* Put the content into the cache.
*/
void contentPut(String key, String content);

}
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
package io.avaje.htmx.nima;


import io.helidon.webserver.http.ServerRequest;
import io.helidon.webserver.http.ServerResponse;

/**
* Template render API for Helidon.
*/
Expand All @@ -12,5 +8,5 @@ public interface TemplateRender {
/**
* Render the given template view model to the server response.
*/
void render(Object viewModel, ServerRequest req, ServerResponse res);
String render(Object viewModel);
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public final class ControllerReader {
private final boolean hasValid;
/** Set true via {@code @Html} to indicate use of Templating */
private final boolean html;
/** Set true via {@code @ContentCache} to indicate use of Templating content cache */
private boolean hasContentCache;
private boolean methodHasValid;

/**
Expand Down Expand Up @@ -200,6 +202,10 @@ public boolean html() {
return html;
}

public boolean hasContentCache() {
return hasContentCache;
}

public TypeElement beanType() {
return beanType;
}
Expand Down Expand Up @@ -247,10 +253,11 @@ public void read(boolean withSingleton) {
}

private void deriveIncludeValidation() {
methodHasValid = methodHasValid();
methodHasValid = anyMethodHasValid();
hasContentCache = anyMethodHasContentCache();
}

private boolean methodHasValid() {
private boolean anyMethodHasValid() {
for (final MethodReader method : methods) {
if (method.hasValid()) {
return true;
Expand All @@ -259,6 +266,15 @@ private boolean methodHasValid() {
return false;
}

private boolean anyMethodHasContentCache() {
for (final MethodReader method : methods) {
if (method.hasContentCache()) {
return true;
}
}
return false;
}

private void readField(Element element) {
if (!requestScope) {
final String rawType = element.asType().toString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public class MethodReader {
private final List<? extends TypeMirror> actualParams;
private final PathSegments pathSegments;
private final boolean hasValid;
private final Optional<ContentCachePrism> contentCache;
private final List<ExecutableElement> superMethods;
private final Optional<RequestTimeoutPrism> timeout;
private final HxRequestPrism hxRequest;
Expand Down Expand Up @@ -87,10 +88,12 @@ public class MethodReader {
});
if (isWebMethod()) {
this.hasValid = initValid();
this.contentCache = initContentCache();
this.instrumentContext = initResolver();
this.pathSegments = PathSegments.parse(Util.combinePath(bean.path(), webMethodPath));
} else {
this.hasValid = false;
this.contentCache = Optional.empty();
this.pathSegments = null;
this.instrumentContext = false;
}
Expand Down Expand Up @@ -124,7 +127,11 @@ private boolean initValid() {

private boolean superMethodHasValid() {
return superMethods.stream()
.anyMatch(e -> findAnnotation(ValidPrism::getOptionalOn).isPresent());
.anyMatch(e -> findAnnotation(ValidPrism::getOptionalOn).isPresent());
}

private Optional<ContentCachePrism> initContentCache() {
return findAnnotation(ContentCachePrism::getOptionalOn);
}

@Override
Expand Down Expand Up @@ -413,6 +420,10 @@ boolean hasValid() {
return hasValid;
}

public boolean hasContentCache() {
return contentCache.isPresent();
}

public String simpleName() {
return element.getSimpleName().toString();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public enum MediaType {
APPLICATION_JSON("application/json"),
TEXT_PLAIN("text/plain"),
TEXT_HTML("text/html"),
HTML_UTF8("text/html;charset=UTF8"),
UNKNOWN("");

private final String value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
@GeneratePrism(value = io.avaje.http.api.RequestTimeout.class, publicAccess = true)
@GeneratePrism(value = io.avaje.htmx.api.HxRequest.class, publicAccess = true)
@GeneratePrism(value = io.avaje.htmx.api.Html.class, publicAccess = true)
@GeneratePrism(value = io.avaje.htmx.api.ContentCache.class, publicAccess = true)
package io.avaje.http.generator.core;

import io.avaje.prism.GeneratePrism;
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ void writeHandler(boolean requestScoped) {
writer.append(" res.status(%s);", lookupStatusCode(statusCode)).eol();
}
}

boolean withFormParams = false;
final var bodyType = method.bodyType();
if (bodyType != null && !method.isErrorMethod() && !isFilter) {
if ("InputStream".equals(bodyType)) {
Expand All @@ -144,26 +144,41 @@ void writeHandler(boolean requestScoped) {
} else {
defaultHelidonBodyContent();
}
} else if (usesFormParams()) {
writer.append(" var formParams = req.content().as(Parameters.class);").eol();
} else {
withFormParams = usesFormParams();
if (withFormParams) {
writer.append(" var formParams = req.content().as(Parameters.class);").eol();
}
}
final ResponseMode responseMode = responseMode();
final boolean withContentCache = responseMode == ResponseMode.Templating && useContentCache();
if (withContentCache) {
writer.append(" var key = contentCache.key(req");
if (withFormParams) {
writer.append(", formParams");
}
writer.append(");").eol();
writer.append(" var cacheContent = contentCache.content(key);").eol();
writer.append(" if (cacheContent != null) {").eol();
writeContextReturn(" ");
writer.append(" res.send(cacheContent);").eol();
writer.append(" return;").eol();
writer.append(" }").eol();
}

final var segments = method.pathSegments();
if (segments.fullPath().contains("{")) {
writer.append(" var pathParams = req.path().pathParameters();").eol();
}

for (final PathSegments.Segment matrixSegment : segments.matrixSegments()) {
matrixSegment.writeCreateSegment(writer, platform());
}

final var params = method.params();
for (final MethodParam param : params) {
if (!isExceptionOrFilterChain(param)) {
param.writeCtxGet(writer, segments);
}
}

if (method.includeValidate()) {
for (final MethodParam param : params) {
param.writeValidate(writer);
Expand Down Expand Up @@ -205,7 +220,7 @@ void writeHandler(boolean requestScoped) {
}
writer.append(");").eol();

if (!method.isVoid() && !isFilter) {
if (responseMode != ResponseMode.Void) {
TypeMirror typeMirror = method.returnType();
boolean includeNoContent = !typeMirror.getKind().isPrimitive();
if (includeNoContent) {
Expand All @@ -214,21 +229,29 @@ void writeHandler(boolean requestScoped) {
writer.append(" } else {").eol();
}
String indent = includeNoContent ? " " : " ";
writeContextReturn(indent);
if (isInputStream(method.returnType())) {
final var uType = UType.parse(method.returnType());
writer.append(indent).append("result.transferTo(res.outputStream());", uType.shortName()).eol();
} else if (producesJson()) {
if (returnTypeString()) {
writer.append(indent).append("res.send(result); // send raw JSON").eol();
} else {
final var uType = UType.parse(method.returnType());
writer.append(indent).append("%sJsonType.toJson(result, JsonOutput.of(res));", uType.shortName()).eol();
if (responseMode == ResponseMode.Templating) {
writer.append(indent).append("var content = renderer.render(result);").eol();
if (withContentCache) {
writer.append(indent).append("contentCache.contentPut(key, content);").eol();
}
} else if (useTemplating()) {
writer.append(indent).append("renderer.render(result, req, res);").eol();
writeContextReturn(indent);
writer.append(indent).append("res.send(content);").eol();

} else {
writer.append(indent).append("res.send(result);").eol();
writeContextReturn(indent);
if (responseMode == ResponseMode.InputStream) {
final var uType = UType.parse(method.returnType());
writer.append(indent).append("result.transferTo(res.outputStream());", uType.shortName()).eol();
} else if (responseMode == ResponseMode.Json) {
if (returnTypeString()) {
writer.append(indent).append("res.send(result); // send raw JSON").eol();
} else {
final var uType = UType.parse(method.returnType());
writer.append(indent).append("%sJsonType.toJson(result, JsonOutput.of(res));", uType.shortName()).eol();
}
} else {
writer.append(indent).append("res.send(result);").eol();
}
}
if (includeNoContent) {
writer.append(" }").eol();
Expand All @@ -237,6 +260,34 @@ void writeHandler(boolean requestScoped) {
writer.append(" }").eol().eol();
}

enum ResponseMode {
Void,
Json,
Templating,
InputStream,
Other
}

ResponseMode responseMode() {
if (method.isVoid() || isFilter) {
return ResponseMode.Void;
}
if (isInputStream(method.returnType())) {
return ResponseMode.InputStream;
}
if (producesJson()) {
return ResponseMode.Json;
}
if (useTemplating()) {
return ResponseMode.Templating;
}
return ResponseMode.Other;
}

private boolean useContentCache() {
return method.hasContentCache();
}

private boolean useTemplating() {
return reader.html()
&& !"byte[]".equals(method.returnType().toString())
Expand Down Expand Up @@ -308,6 +359,7 @@ private void writeContextReturn(String indent) {
final var contentTypeString = "res.headers().contentType(MediaTypes.";
writer.append(indent);
switch (produces) {
case HTML_UTF8 -> writer.append("res.headers().contentType(HTML_UTF8);").eol();
case APPLICATION_JSON -> writer.append(contentTypeString).append("APPLICATION_JSON);").eol();
case TEXT_HTML -> writer.append(contentTypeString).append("TEXT_HTML);").eol();
case TEXT_PLAIN -> writer.append(contentTypeString).append("TEXT_PLAIN);").eol();
Expand Down
Loading