diff --git a/core/pom.xml b/core/pom.xml
index 0b0009b465dd858bba7f36396d789ca427175061..b4fa21c47610907823a0aa21e1772055111bd8e8 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -46,6 +46,17 @@
org.apache.commonscommons-lang3
+
+
+ commons-collections
+ commons-collections
+ 3.2.2
+
+
+
+ io.quarkus
+ quarkus-resteasy-qute
+ io.quarkus
diff --git a/core/src/main/java/org/eclipsefoundation/core/service/StartupProxy.java b/core/src/main/java/org/eclipsefoundation/core/model/StartupProxy.java
similarity index 95%
rename from core/src/main/java/org/eclipsefoundation/core/service/StartupProxy.java
rename to core/src/main/java/org/eclipsefoundation/core/model/StartupProxy.java
index 2583a570afd42f7cc365216a7bcd5fc94083498f..ba72c0f7e865bd5c5c7b9eec4a9bec1843a46bae 100644
--- a/core/src/main/java/org/eclipsefoundation/core/service/StartupProxy.java
+++ b/core/src/main/java/org/eclipsefoundation/core/model/StartupProxy.java
@@ -9,7 +9,7 @@
*
* SPDX-License-Identifier: EPL-2.0
**********************************************************************/
-package org.eclipsefoundation.core.service;
+package org.eclipsefoundation.core.model;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
diff --git a/core/src/main/java/org/eclipsefoundation/core/namespace/OptionalPath.java b/core/src/main/java/org/eclipsefoundation/core/namespace/OptionalPath.java
new file mode 100644
index 0000000000000000000000000000000000000000..8ac81605df935fed3113af5cea2e0e735032d3e9
--- /dev/null
+++ b/core/src/main/java/org/eclipsefoundation/core/namespace/OptionalPath.java
@@ -0,0 +1,21 @@
+package org.eclipsefoundation.core.namespace;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+@Retention(RUNTIME)
+@Target(METHOD)
+public @interface OptionalPath {
+ /**
+ * Name of the configuration value to check for whether the targeted resource path should be allowed to continue
+ * processing.
+ *
+ * @return the full path of the configuration value for enabling/disabling the given endpoint.
+ */
+ String value();
+
+ boolean enabledByDefault() default false;
+}
diff --git a/core/src/main/java/org/eclipsefoundation/core/request/OptionalPathFilter.java b/core/src/main/java/org/eclipsefoundation/core/request/OptionalPathFilter.java
new file mode 100644
index 0000000000000000000000000000000000000000..e62e0019048cc510beadd2c01810a3807da87616
--- /dev/null
+++ b/core/src/main/java/org/eclipsefoundation/core/request/OptionalPathFilter.java
@@ -0,0 +1,62 @@
+package org.eclipsefoundation.core.request;
+
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.util.Optional;
+
+import javax.enterprise.inject.Instance;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerRequestFilter;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.ext.Provider;
+
+import org.eclipse.microprofile.config.ConfigProvider;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.eclipsefoundation.core.namespace.OptionalPath;
+import org.jboss.resteasy.core.interception.jaxrs.PostMatchContainerRequestContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Does lookups on the current path to see if the resource is disabled.
+ *
+ * @author Martin Lowe
+ *
+ */
+@Provider
+public class OptionalPathFilter implements ContainerRequestFilter {
+ public static final Logger LOGGER = LoggerFactory.getLogger(OptionalPathFilter.class);
+
+ @ConfigProperty(name = "eclipse.optional-resources.enabled", defaultValue = "false")
+ Instance optionalResourcesEnabled;
+
+ @Override
+ public void filter(ContainerRequestContext requestContext) throws IOException {
+ // check annotation on target endpoint to be sure that endpoint is enabled
+ Method m = ((PostMatchContainerRequestContext) requestContext).getResourceMethod().getMethod();
+ OptionalPath opt = m.getAnnotation(OptionalPath.class);
+ if (opt != null) {
+ if (!optionalResourcesEnabled.get()) {
+ LOGGER.trace("Request to '{}' rejected as optional resources are not enabled",
+ requestContext.getUriInfo().getAbsolutePath());
+ // abort with 404 as we should hide that this exists
+ requestContext.abortWith(Response.status(404).build());
+ }
+ // get the specified config value fresh and check that it is enabled, or enabled by default if missing
+ Optional configValue = ConfigProvider.getConfig().getOptionalValue(opt.value(), Boolean.class);
+ if (configValue.isPresent() && configValue.get()) {
+ LOGGER.trace("Request to '{}' enabled by config, allowing call",
+ requestContext.getUriInfo().getAbsolutePath());
+ } else if (configValue.isEmpty() && opt.enabledByDefault()) {
+ LOGGER.trace("Request to '{}' enabled by default, allowing call",
+ requestContext.getUriInfo().getAbsolutePath());
+ } else {
+ LOGGER.trace("Request to '{}' rejected as endpoint is not enabled",
+ requestContext.getUriInfo().getAbsolutePath());
+ // abort with 404 as we should hide that this exists
+ requestContext.abortWith(Response.status(404).build());
+ }
+ }
+
+ }
+}
diff --git a/core/src/main/java/org/eclipsefoundation/core/resource/CacheResource.java b/core/src/main/java/org/eclipsefoundation/core/resource/CacheResource.java
new file mode 100644
index 0000000000000000000000000000000000000000..3bc7b4c3d9a1aac0a460ffd787a5075540c33d4c
--- /dev/null
+++ b/core/src/main/java/org/eclipsefoundation/core/resource/CacheResource.java
@@ -0,0 +1,177 @@
+package org.eclipsefoundation.core.resource;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import javax.ws.rs.GET;
+import javax.ws.rs.HeaderParam;
+import javax.ws.rs.POST;
+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.MediaType;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+
+import org.apache.commons.collections.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.eclipsefoundation.core.namespace.MicroprofilePropertyNames;
+import org.eclipsefoundation.core.namespace.OptionalPath;
+import org.eclipsefoundation.core.service.CachingService;
+import org.eclipsefoundation.core.service.CachingService.ParameterizedCacheKey;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
+import com.google.auto.value.AutoValue;
+
+import io.quarkus.qute.Location;
+import io.quarkus.qute.Template;
+import io.quarkus.runtime.Startup;
+
+@Path("/caches")
+@Produces(MediaType.APPLICATION_JSON)
+public class CacheResource {
+ static final Logger LOGGER = LoggerFactory.getLogger(CacheResource.class);
+
+ @Inject
+ InstanceCacheResourceKey key;
+
+ // Qute templates, generates cache key interface
+ @Location("cache_keys")
+ Template cacheKeysView;
+
+ @Inject
+ CachingService service;
+
+ @GET
+ @Path("keys")
+ @Produces(MediaType.TEXT_HTML)
+ @OptionalPath(MicroprofilePropertyNames.CACHE_RESOURCE_ENABLED)
+ public Response getCaches(@QueryParam("key") String passedKey) {
+ if (shouldBlockCacheRequest(passedKey)) {
+ return Response.status(403).build();
+ }
+ return Response
+ .ok()
+ .entity(cacheKeysView
+ .data("cacheKeys", service.getCacheKeys().stream().map(this::buildWrappedKeys).collect(Collectors.toList()), "key",
+ passedKey)
+ .render())
+ .build();
+ }
+
+ @POST
+ @Path("{cacheKey}/clear")
+ @OptionalPath(MicroprofilePropertyNames.CACHE_RESOURCE_ENABLED)
+ public Response clearForKey(@PathParam("cacheKey") String cacheKey, @HeaderParam("x-cache-key") String passedKey,
+ Map> params) {
+ if (shouldBlockCacheRequest(passedKey)) {
+ return Response.status(403).build();
+ }
+ // remove the given keys from the cache
+ List keys = service
+ .getCacheKeys()
+ .stream()
+ .filter(k -> k.getId().equals(cacheKey) && checkParams(params, k.getParams()))
+ .collect(Collectors.toList());
+ keys.forEach(k -> service.remove(k));
+ return Response.ok().entity(keys.size()).build();
+ }
+
+ /**
+ * Converts cache keys to wrapped cache keys with their expiration time.
+ *
+ * @param k the key to wrap
+ * @return the wrapped key, containing the passed key and its time to live.
+ */
+ private WrappedCacheKey buildWrappedKeys(ParameterizedCacheKey k) {
+ Date d = null;
+ try {
+ d = new Date(service.getExpiration(k.getId(), k.getParams(), k.getClazz()).orElse(service.getMaxAge()));
+ } catch (RuntimeException e) {
+ // intentionally empty
+ }
+ return WrappedCacheKey.builder().setTtl(d).setKey(k).build();
+ }
+
+ /**
+ * Compare 2 parameter collections to check if they match.
+ *
+ * @param passedParams the passed params we need to match
+ * @param entryParams the current entry to checks params
+ * @return true if they match (insensitive to order), false otherwise
+ */
+ private boolean checkParams(Map> passedParams, MultivaluedMap entryParams) {
+ if (passedParams.size() != entryParams.size()) {
+ return false;
+ }
+ return passedParams.entrySet().stream().allMatch(e -> CollectionUtils.isEqualCollection(e.getValue(), entryParams.get(e.getKey())));
+ }
+
+ /**
+ * Checks the access key to see if the current request should be blocked.
+ *
+ * @param passedKey the key passed by the requester
+ * @return true if request should be blocked, false otherwise.
+ */
+ private boolean shouldBlockCacheRequest(String passedKey) {
+ return StringUtils.isBlank(passedKey) || !passedKey.equals(key.key);
+ }
+
+ /**
+ * Cache key plus its expiration to be displayed on the view more page.
+ *
+ * @author Martin Lowe
+ *
+ */
+ @AutoValue
+ @JsonDeserialize(builder = AutoValue_CacheResource_WrappedCacheKey.Builder.class)
+ public abstract static class WrappedCacheKey {
+ public abstract ParameterizedCacheKey getKey();
+
+ @Nullable
+ public abstract Date getTtl();
+
+ public static Builder builder() {
+ return new AutoValue_CacheResource_WrappedCacheKey.Builder();
+ }
+
+ @AutoValue.Builder
+ @JsonPOJOBuilder(withPrefix = "set")
+ public abstract static class Builder {
+ public abstract Builder setKey(ParameterizedCacheKey key);
+
+ public abstract Builder setTtl(@Nullable Date ttl);
+
+ public abstract WrappedCacheKey build();
+ }
+ }
+
+ /**
+ * Contains the random key generated on system start. Creates a random secure key that will be used to gate access to
+ * the cache resources endpoint.
+ *
+ * @author Martin Lowe
+ *
+ */
+ @Startup
+ @Singleton
+ static class InstanceCacheResourceKey {
+ static final Logger LOGGER = LoggerFactory.getLogger(InstanceCacheResourceKey.class);
+ final String key;
+
+ InstanceCacheResourceKey() {
+ this.key = UUID.randomUUID().toString();
+ LOGGER.info("Generated cache resource key: {}", key);
+ }
+ }
+}
diff --git a/core/src/main/java/org/eclipsefoundation/core/response/PaginatedResultsFilter.java b/core/src/main/java/org/eclipsefoundation/core/response/PaginatedResultsFilter.java
index 23fd873e9e4956b6e46bee3329b51992dd6a7063..94f43209481b845b98e4e057fc83e3036461c9c5 100644
--- a/core/src/main/java/org/eclipsefoundation/core/response/PaginatedResultsFilter.java
+++ b/core/src/main/java/org/eclipsefoundation/core/response/PaginatedResultsFilter.java
@@ -76,19 +76,14 @@ public class PaginatedResultsFilter implements ContainerResponseFilter {
ResourceInfo resourceInfo;
@Override
- public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext)
- throws IOException {
-
- Method method = resourceInfo.getResourceMethod();
- Pagination annotation = method.getAnnotation(Pagination.class);
+ public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException {
- if ((annotation == null || annotation.value()) && Boolean.TRUE.equals(enablePagination.get())) {
+ if (checkPaginationAnnotation() && Boolean.TRUE.equals(enablePagination.get())) {
Object entity = responseContext.getEntity();
// only try and paginate if there are multiple entities
if (entity instanceof Set) {
- paginateResults(responseContext,
- (new TreeSet<>((Set>) entity)).stream().collect(Collectors.toList()));
+ paginateResults(responseContext, (new TreeSet<>((Set>) entity)).stream().collect(Collectors.toList()));
} else if (entity instanceof List) {
paginateResults(responseContext, (List>) entity);
}
@@ -110,13 +105,31 @@ public class PaginatedResultsFilter implements ContainerResponseFilter {
if (listEntity.size() > pageSize) {
// set the sliced array as the entity
responseContext
- .setEntity(listEntity.subList(getArrayLimitedNumber(listEntity, Math.max(0, page - 1) * pageSize),
- getArrayLimitedNumber(listEntity, pageSize * page)));
+ .setEntity(listEntity
+ .subList(getArrayLimitedNumber(listEntity, Math.max(0, page - 1) * pageSize),
+ getArrayLimitedNumber(listEntity, pageSize * page)));
}
// set the link header to the response
responseContext.getHeaders().add("Link", createLinkHeader(page, lastPage));
}
+ /**
+ * Checks the current method for pagination annotations and if present, checks to make sure pagination is enabled.
+ * Defaults to using pagination if the pagination annotation is missing or cannot be read.
+ *
+ * @return true if pagination should be enabled, false otherwise
+ */
+ private boolean checkPaginationAnnotation() {
+
+ // method can be null sometimes for quarkus native endpoints
+ Method method = resourceInfo.getResourceMethod();
+ if (method != null) {
+ Pagination annotation = method.getAnnotation(Pagination.class);
+ return annotation == null || annotation.value();
+ }
+ return true;
+ }
+
/**
* Gets a header value if available, checking first the servlet response then the mutable response wrapper.
*
@@ -230,8 +243,8 @@ public class PaginatedResultsFilter implements ContainerResponseFilter {
*
* @param list the list to bind the number by
* @param num the number to check for exceeding bounds.
- * @return the passed number if its within the size of the given array, 0 if the number is negative, and the array
- * size if greater than the maximum bounds.
+ * @return the passed number if its within the size of the given array, 0 if the number is negative, and the array size
+ * if greater than the maximum bounds.
*/
private int getArrayLimitedNumber(List> list, int num) {
return Math.min(list.size(), Math.max(0, num));
diff --git a/core/src/main/java/org/eclipsefoundation/core/service/StartupProxyService.java b/core/src/main/java/org/eclipsefoundation/core/service/StartupProxyService.java
index acde361bfca8db82efe59593fe4d028754271e8a..e1cf9a836aed5a3ac0e2b385ee43d2cf71cb548b 100644
--- a/core/src/main/java/org/eclipsefoundation/core/service/StartupProxyService.java
+++ b/core/src/main/java/org/eclipsefoundation/core/service/StartupProxyService.java
@@ -1,41 +1,32 @@
-/*********************************************************************
-* Copyright (c) 2022 Eclipse Foundation.
-*
-* This program and the accompanying materials are made
-* available under the terms of the Eclipse Public License 2.0
-* which is available at https://www.eclipse.org/legal/epl-2.0/
-*
-* Author: Zachary Sabourin
-*
-* SPDX-License-Identifier: EPL-2.0
-**********************************************************************/
+/**
+ * Copyright (c) 2022 Eclipse Foundation
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * Author: Martin Lowe
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
package org.eclipsefoundation.core.service;
-import javax.annotation.PostConstruct;
-import javax.enterprise.context.ApplicationScoped;
-import javax.enterprise.inject.Instance;
-import javax.inject.Inject;
+import java.util.List;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import io.quarkus.runtime.Startup;
+import org.eclipsefoundation.core.model.StartupProxy;
/**
- * Finds services extending StartupProxy and ensures they are loaded at startup.
- * This ensures that beans served using the provider pattern are found and
- * default values are loaded.
+ * Interface for binding classes that can't have startup annotations to initialize at startup.
+ *
+ * @author Martin Lowe
+ *
*/
-@Startup
-@ApplicationScoped
-public class StartupProxyService {
- static final Logger LOGGER = LoggerFactory.getLogger(StartupProxyService.class);
-
- @Inject
- Instance services;
+public interface StartupProxyService {
- @PostConstruct
- void init() {
- services.forEach(StartupProxy::startupProxy);
- }
+ /**
+ * Returns a list of proxied bean classes.
+ *
+ * @return list of beans detected as using the startup proxy.
+ */
+ List> getProxiedBeanTypes();
}
diff --git a/core/src/main/java/org/eclipsefoundation/core/service/impl/DefaultStartupProxyService.java b/core/src/main/java/org/eclipsefoundation/core/service/impl/DefaultStartupProxyService.java
new file mode 100644
index 0000000000000000000000000000000000000000..ad72e5d90a8b2a281cab4958a2fb1435732c51ad
--- /dev/null
+++ b/core/src/main/java/org/eclipsefoundation/core/service/impl/DefaultStartupProxyService.java
@@ -0,0 +1,55 @@
+/*********************************************************************
+* Copyright (c) 2022 Eclipse Foundation.
+*
+* This program and the accompanying materials are made
+* available under the terms of the Eclipse Public License 2.0
+* which is available at https://www.eclipse.org/legal/epl-2.0/
+*
+* Author: Zachary Sabourin
+*
+* SPDX-License-Identifier: EPL-2.0
+**********************************************************************/
+package org.eclipsefoundation.core.service.impl;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import javax.annotation.PostConstruct;
+import javax.enterprise.context.ApplicationScoped;
+import javax.enterprise.inject.Instance;
+import javax.inject.Inject;
+
+import org.eclipsefoundation.core.model.StartupProxy;
+import org.eclipsefoundation.core.service.StartupProxyService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.quarkus.runtime.Startup;
+
+/**
+ * Finds services extending StartupProxy and ensures they are loaded at startup. This ensures that beans served using
+ * the provider pattern are found and default values are loaded.
+ *
+ * To properly bind in cases where the services are managed by a bean provider class, the interface of the service
+ * should be extended with the StartupProxy, rather than marking the actual implementation with an additional implements
+ * call.
+ */
+@Startup
+@ApplicationScoped
+public class DefaultStartupProxyService implements StartupProxyService {
+ static final Logger LOGGER = LoggerFactory.getLogger(DefaultStartupProxyService.class);
+
+ @Inject
+ Instance services;
+
+ @PostConstruct
+ void init() {
+ services.forEach(StartupProxy::startupProxy);
+ }
+
+ @Override
+ public List> getProxiedBeanTypes() {
+ return services.stream().map(StartupProxy::getClass).collect(Collectors.toList());
+ }
+
+}
diff --git a/core/src/main/java/org/eclipsefoundation/core/service/impl/QuarkusCachingService.java b/core/src/main/java/org/eclipsefoundation/core/service/impl/QuarkusCachingService.java
index dbc03aefecd905f7220e567a1dea01bded12f8e4..31d7fbb290c251b4cd75588d8c174ba2d21e0f4e 100644
--- a/core/src/main/java/org/eclipsefoundation/core/service/impl/QuarkusCachingService.java
+++ b/core/src/main/java/org/eclipsefoundation/core/service/impl/QuarkusCachingService.java
@@ -39,7 +39,8 @@ import io.quarkus.cache.CacheResult;
import io.quarkus.cache.CaffeineCache;
/**
- * Utililzes Quarkus caching extensions in order to cache and retrieve data.
+ * Utililzes Quarkus caching extensions in order to cache and retrieve data. Reason we augment over the Quarkus core
+ * cache (caffeine) is so that we can record TTLs for cache objects which aren't exposed by base cache.
*
* @author Martin Lowe
*
@@ -58,12 +59,10 @@ public class QuarkusCachingService implements CachingService {
Cache cache;
@Override
- public Optional get(String id, MultivaluedMap params, Class> rawType,
- Callable extends T> callable) {
+ public Optional get(String id, MultivaluedMap params, Class> rawType, Callable extends T> callable) {
Objects.requireNonNull(callable);
- ParameterizedCacheKey cacheKey = new ParameterizedCacheKey(rawType, id,
- params != null ? params : new MultivaluedMapImpl<>());
+ ParameterizedCacheKey cacheKey = new ParameterizedCacheKey(rawType, id, params != null ? params : new MultivaluedMapImpl<>());
LOGGER.debug("Retrieving cache value for '{}'", cacheKey);
return Optional.ofNullable(get(cacheKey, callable));
}
@@ -101,8 +100,7 @@ public class QuarkusCachingService implements CachingService {
@Override
public void fuzzyRemove(String id, Class> type) {
- this.getCacheKeys().stream().filter(k -> k.getId().equals(id) && k.getClazz().equals(type))
- .forEach(this::remove);
+ this.getCacheKeys().stream().filter(k -> k.getId().equals(id) && k.getClazz().equals(type)).forEach(this::remove);
}
@Override
@@ -136,21 +134,22 @@ public class QuarkusCachingService implements CachingService {
@CacheResult(cacheName = "default")
public T get(@CacheKey ParameterizedCacheKey cacheKey, Callable extends T> callable) {
+ // attempt to get the result for the cache entry
T out = null;
try {
out = callable.call();
- // set internal expiration cache
- getExpiration(false, cacheKey);
} catch (Exception e) {
LOGGER.error("Error while creating cache entry for key '{}': \n", cacheKey, e);
}
+ // need to make sure that the expiration is always set for a cache value since we accept null
+ getExpiration(false, cacheKey);
return out;
}
@CacheResult(cacheName = "ttl")
- public long getExpiration(boolean throwIfMissing, @CacheKey ParameterizedCacheKey cacheKey) throws Exception {
+ public long getExpiration(boolean throwIfMissing, @CacheKey ParameterizedCacheKey cacheKey) {
if (throwIfMissing) {
- throw new Exception("No TTL present for cache key '{}', not generating");
+ throw new RuntimeException("No TTL present for cache key '{}', not generating");
}
LOGGER.debug("Timeout for {}: {}", cacheKey, System.currentTimeMillis() + getMaxAge());
return System.currentTimeMillis() + getMaxAge();
diff --git a/core/src/main/java/org/eclipsefoundation/core/template/MapToJSONExtension.java b/core/src/main/java/org/eclipsefoundation/core/template/MapToJSONExtension.java
new file mode 100644
index 0000000000000000000000000000000000000000..c45feca4c63165481638a5f81bd8c602f1fec51e
--- /dev/null
+++ b/core/src/main/java/org/eclipsefoundation/core/template/MapToJSONExtension.java
@@ -0,0 +1,17 @@
+package org.eclipsefoundation.core.template;
+
+import javax.json.Json;
+import javax.json.JsonObject;
+import javax.json.JsonObjectBuilder;
+import javax.ws.rs.core.MultivaluedMap;
+
+import io.quarkus.qute.TemplateExtension;
+
+@TemplateExtension
+public class MapToJSONExtension {
+ public static JsonObject toJson(MultivaluedMap params) {
+ JsonObjectBuilder out = Json.createObjectBuilder();
+ params.forEach((k, v) -> out.add(k, Json.createArrayBuilder(v)));
+ return out.build();
+ }
+}
diff --git a/core/src/main/resources/templates/cache_keys.html b/core/src/main/resources/templates/cache_keys.html
new file mode 100644
index 0000000000000000000000000000000000000000..440156c684eb4ed240aad07740f3a4371f827b28
--- /dev/null
+++ b/core/src/main/resources/templates/cache_keys.html
@@ -0,0 +1,64 @@
+{#include eclipse_header /}
+{||}
+
+
+