Skip to content
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

[rest] Add caching for static resources #3335

Merged
merged 9 commits into from Jun 15, 2023
Expand Up @@ -17,9 +17,11 @@
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
Expand All @@ -29,15 +31,18 @@
import javax.annotation.security.RolesAllowed;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.CacheControl;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Request;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.SecurityContext;
Expand Down Expand Up @@ -69,6 +74,7 @@
import org.openhab.core.automation.rest.internal.dto.EnrichedRuleDTOMapper;
import org.openhab.core.automation.util.ModuleBuilder;
import org.openhab.core.automation.util.RuleBuilder;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.config.core.ConfigUtil;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.events.Event;
Expand All @@ -80,6 +86,7 @@
import org.openhab.core.library.types.DateTimeType;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.jaxrs.whiteboard.JaxrsWhiteboardConstants;
import org.osgi.service.jaxrs.whiteboard.propertytypes.JSONRequired;
Expand Down Expand Up @@ -128,8 +135,10 @@
private final RuleManager ruleManager;
private final RuleRegistry ruleRegistry;
private final ManagedRuleProvider managedRuleProvider;
private final ResetLastModifiedChangeListener resetLastModifiedChangeListener = new ResetLastModifiedChangeListener();

private @Context @NonNullByDefault({}) UriInfo uriInfo;
private @Nullable Date cacheableListLastModified = null;

@Activate
public RuleResource( //
Expand All @@ -141,20 +150,52 @@
this.ruleManager = ruleManager;
this.ruleRegistry = ruleRegistry;
this.managedRuleProvider = managedRuleProvider;

this.ruleRegistry.addRegistryChangeListener(resetLastModifiedChangeListener);
}

@Deactivate
void deactivate() {
this.ruleRegistry.removeRegistryChangeListener(resetLastModifiedChangeListener);
}

@GET
@RolesAllowed({ Role.USER, Role.ADMIN })
@Produces(MediaType.APPLICATION_JSON)
@Operation(operationId = "getRules", summary = "Get available rules, optionally filtered by tags and/or prefix.", responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = EnrichedRuleDTO.class)))) })
public Response get(@Context SecurityContext securityContext, @QueryParam("prefix") final @Nullable String prefix,
@QueryParam("tags") final @Nullable List<String> tags,
@QueryParam("summary") @Parameter(description = "summary fields only") @Nullable Boolean summary) {
public Response get(@Context SecurityContext securityContext, @Context Request request,
@QueryParam("prefix") final @Nullable String prefix, @QueryParam("tags") final @Nullable List<String> tags,
@QueryParam("summary") @Parameter(description = "summary fields only") @Nullable Boolean summary,
@DefaultValue("false") @QueryParam("staticDataOnly") @Parameter(description = "provides a cacheable list of values not expected to change regularly and honors the If-Modified-Since header, all other parameters are ignored") boolean staticDataOnly) {

if ((summary == null || !summary) && !securityContext.isUserInRole(Role.ADMIN)) {
// users may only access the summary
return JSONResponse.createErrorResponse(Status.UNAUTHORIZED, "Authentication required");
}

if (staticDataOnly) {
if (cacheableListLastModified != null) {
Response.ResponseBuilder responseBuilder = request.evaluatePreconditions(cacheableListLastModified);
if (responseBuilder != null) {
// send 304 Not Modified
return responseBuilder.build();
}
} else {
cacheableListLastModified = Date.from(Instant.now().truncatedTo(ChronoUnit.SECONDS));
}

Stream<EnrichedRuleDTO> rules = ruleRegistry.stream()
.map(rule -> EnrichedRuleDTOMapper.map(rule, ruleManager, managedRuleProvider));

CacheControl cc = new CacheControl();
cc.setMustRevalidate(true);
cc.setPrivate(true);
rules = dtoMapper.limitToFields(rules, "uid,templateUID,name,visibility,description,tags,editable");
return Response.ok(new Stream2JSONInputStream(rules)).lastModified(cacheableListLastModified)
.cacheControl(cc).build();
}

// match all
Predicate<Rule> p = r -> true;

Expand Down Expand Up @@ -567,4 +608,26 @@
return null;
}
}

private void resetStaticListLastModified() {
staticListLastModified = null;

Check failure on line 613 in bundles/org.openhab.core.automation.rest/src/main/java/org/openhab/core/automation/rest/internal/RuleResource.java

View workflow job for this annotation

GitHub Actions / Build (Java 17, ubuntu-22.04)

staticListLastModified cannot be resolved to a variable
J-N-K marked this conversation as resolved.
Show resolved Hide resolved
}

private class ResetLastModifiedChangeListener implements RegistryChangeListener<Rule> {

@Override
public void added(Rule element) {
resetStaticListLastModified();
}

@Override
public void removed(Rule element) {
resetStaticListLastModified();
}

@Override
public void updated(Rule oldElement, Rule element) {
resetStaticListLastModified();
}
}
}
Expand Up @@ -12,9 +12,12 @@
*/
package org.openhab.core.io.rest.core.internal.item;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
Expand All @@ -40,9 +43,11 @@
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.CacheControl;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Request;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.ResponseBuilder;
import javax.ws.rs.core.Response.Status;
Expand All @@ -52,6 +57,7 @@
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.auth.Role;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.events.EventPublisher;
import org.openhab.core.io.rest.DTOMapper;
import org.openhab.core.io.rest.JSONResponse;
Expand All @@ -68,6 +74,7 @@
import org.openhab.core.items.ItemBuilderFactory;
import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.items.ItemRegistryChangeListener;
import org.openhab.core.items.ManagedItemProvider;
import org.openhab.core.items.Metadata;
import org.openhab.core.items.MetadataKey;
Expand All @@ -88,6 +95,7 @@
import org.openhab.core.types.TypeParser;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.jaxrs.whiteboard.JaxrsWhiteboardConstants;
import org.osgi.service.jaxrs.whiteboard.propertytypes.JSONRequired;
Expand Down Expand Up @@ -174,6 +182,10 @@ private static void respectForwarded(final UriBuilder uriBuilder, final @Context
private final ManagedItemProvider managedItemProvider;
private final MetadataRegistry metadataRegistry;
private final MetadataSelectorMatcher metadataSelectorMatcher;
private final ItemRegistryChangeListener resetLastModifiedItemChangeListener = new ResetLastModifiedItemChangeListener();
private final RegistryChangeListener<Metadata> resetLastModifiedMetadataChangeListener = new ResetLastModifiedMetadataChangeListener();

private Map<@Nullable String, Date> cacheableListsLastModified = new HashMap<>();

@Activate
public ItemResource(//
Expand All @@ -193,6 +205,15 @@ public ItemResource(//
this.managedItemProvider = managedItemProvider;
this.metadataRegistry = metadataRegistry;
this.metadataSelectorMatcher = metadataSelectorMatcher;

this.itemRegistry.addRegistryChangeListener(resetLastModifiedItemChangeListener);
this.metadataRegistry.addRegistryChangeListener(resetLastModifiedMetadataChangeListener);
}

@Deactivate
void deactivate() {
this.itemRegistry.removeRegistryChangeListener(resetLastModifiedItemChangeListener);
this.metadataRegistry.removeRegistryChangeListener(resetLastModifiedMetadataChangeListener);
}

private UriBuilder uriBuilder(final UriInfo uriInfo, final HttpHeaders httpHeaders) {
Expand All @@ -207,17 +228,47 @@ private UriBuilder uriBuilder(final UriInfo uriInfo, final HttpHeaders httpHeade
@Operation(operationId = "getItems", summary = "Get all available items.", responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = EnrichedItemDTO.class)))) })
public Response getItems(final @Context UriInfo uriInfo, final @Context HttpHeaders httpHeaders,
@Context Request request,
@HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) @Parameter(description = "language") @Nullable String language,
@QueryParam("type") @Parameter(description = "item type filter") @Nullable String type,
@QueryParam("tags") @Parameter(description = "item tag filter") @Nullable String tags,
@DefaultValue(".*") @QueryParam("metadata") @Parameter(description = "metadata selector - a comma separated list or a regular expression (returns all if no value given)") @Nullable String namespaceSelector,
@DefaultValue("false") @QueryParam("recursive") @Parameter(description = "get member items recursively") boolean recursive,
@QueryParam("fields") @Parameter(description = "limit output to the given fields (comma separated)") @Nullable String fields) {
@QueryParam("fields") @Parameter(description = "limit output to the given fields (comma separated)") @Nullable String fields,
@DefaultValue("false") @QueryParam("staticDataOnly") @Parameter(description = "provides a cacheable list of values not expected to change regularly and checks the If-Modified-Since header, all other parameters are ignored except \"metadata\"") boolean staticDataOnly) {
final Locale locale = localeService.getLocale(language);
final Set<String> namespaces = splitAndFilterNamespaces(namespaceSelector, locale);

final UriBuilder uriBuilder = uriBuilder(uriInfo, httpHeaders);

if (staticDataOnly) {
Date lastModifiedDate = Date.from(Instant.now());
if (cacheableListsLastModified.containsKey(namespaceSelector)) {
lastModifiedDate = cacheableListsLastModified.get(namespaceSelector);
Response.ResponseBuilder responseBuilder = request.evaluatePreconditions(lastModifiedDate);
if (responseBuilder != null) {
// send 304 Not Modified
return responseBuilder.build();
}
} else {
lastModifiedDate = Date.from(Instant.now().truncatedTo(ChronoUnit.SECONDS));
cacheableListsLastModified.put(namespaceSelector, lastModifiedDate);
}

Stream<EnrichedItemDTO> itemStream = getItems(null, null).stream() //
.map(item -> EnrichedItemDTOMapper.map(item, false, null, uriBuilder, locale)) //
.peek(dto -> addMetadata(dto, namespaces, null)) //
.peek(dto -> dto.editable = isEditable(dto.name));
itemStream = dtoMapper.limitToFields(itemStream,
"name,label,type,groupType,function,category,editable,groupNames,link,tags,metadata");

CacheControl cc = new CacheControl();
cc.setMustRevalidate(true);
cc.setPrivate(true);
return Response.ok(new Stream2JSONInputStream(itemStream)).lastModified(lastModifiedDate).cacheControl(cc)
.build();
}

Stream<EnrichedItemDTO> itemStream = getItems(type, tags).stream() //
.map(item -> EnrichedItemDTOMapper.map(item, recursive, null, uriBuilder, locale)) //
.peek(dto -> addMetadata(dto, namespaces, null)) //
Expand Down Expand Up @@ -935,4 +986,48 @@ private void addMetadata(EnrichedItemDTO dto, Set<String> namespaces, @Nullable
private boolean isEditable(String itemName) {
return managedItemProvider.get(itemName) != null;
}

private void resetCacheableListsLastModified() {
this.cacheableListsLastModified.clear();
}

private class ResetLastModifiedItemChangeListener implements ItemRegistryChangeListener {
@Override
public void added(Item element) {
resetCacheableListsLastModified();
}

@Override
public void allItemsChanged(Collection<String> oldItemNames) {
resetCacheableListsLastModified();
}

@Override
public void removed(Item element) {
resetCacheableListsLastModified();
}

@Override
public void updated(Item oldElement, Item element) {
resetCacheableListsLastModified();
}
}

private class ResetLastModifiedMetadataChangeListener implements RegistryChangeListener<Metadata> {

@Override
public void added(Metadata element) {
resetCacheableListsLastModified();
}

@Override
public void removed(Metadata element) {
resetCacheableListsLastModified();
}

@Override
public void updated(Metadata oldElement, Metadata element) {
resetCacheableListsLastModified();
}
}
}