All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.gravitee.policy.v3.cache.CachePolicyV3 Maven / Gradle / Ivy

The newest version!
/*
 * Copyright © 2015 The Gravitee team (http://gravitee.io)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package io.gravitee.policy.v3.cache;

import static io.gravitee.policy.cache.util.ContentTypeUtil.hasBinaryContentType;

import com.fasterxml.jackson.core.JsonProcessingException;
import io.gravitee.common.http.HttpMethod;
import io.gravitee.common.http.HttpStatusCode;
import io.gravitee.gateway.api.ExecutionContext;
import io.gravitee.gateway.api.Invoker;
import io.gravitee.gateway.api.Request;
import io.gravitee.gateway.api.Response;
import io.gravitee.gateway.api.buffer.Buffer;
import io.gravitee.gateway.api.handler.Handler;
import io.gravitee.gateway.api.http.HttpHeaderNames;
import io.gravitee.gateway.api.http.HttpHeaders;
import io.gravitee.gateway.api.proxy.ProxyConnection;
import io.gravitee.gateway.api.proxy.ProxyResponse;
import io.gravitee.gateway.api.stream.ReadStream;
import io.gravitee.policy.api.PolicyChain;
import io.gravitee.policy.api.PolicyResult;
import io.gravitee.policy.api.annotations.OnRequest;
import io.gravitee.policy.api.annotations.RequireResource;
import io.gravitee.policy.cache.CacheAction;
import io.gravitee.policy.cache.CacheControl;
import io.gravitee.policy.cache.CacheResponse;
import io.gravitee.policy.cache.configuration.CachePolicyConfiguration;
import io.gravitee.policy.cache.configuration.SerializationMode;
import io.gravitee.policy.cache.mapper.CacheResponseMapper;
import io.gravitee.policy.cache.resource.CacheElement;
import io.gravitee.policy.cache.util.CacheControlUtil;
import io.gravitee.policy.cache.util.ExpiresUtil;
import io.gravitee.policy.v3.cache.proxy.CacheProxyConnection;
import io.gravitee.policy.v3.cache.proxy.EvaluableProxyResponse;
import io.gravitee.resource.api.ResourceManager;
import io.gravitee.resource.cache.api.Cache;
import io.gravitee.resource.cache.api.CacheResource;
import io.gravitee.resource.cache.api.Element;
import io.vertx.core.AsyncResult;
import io.vertx.core.Vertx;
import java.time.Instant;
import java.util.Base64;
import java.util.Collections;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;

/**
 * @author David BRASSELY (david.brassely at graviteesource.com)
 * @author GraviteeSource Team
 */
@RequireResource
@Slf4j
public class CachePolicyV3 {

    protected static final String CACHE_SERIALIZATION_MODE_KEY = "policy.cache.serialization";

    /**
     * Cache policy configuration
     */
    protected final CachePolicyConfiguration cachePolicyConfiguration;

    public static final String UPSTREAM_RESPONSE = "upstreamResponse";
    // Policy cache action
    public static final String CACHE_ACTION_QUERY_PARAMETER = "cache";
    public static final String X_GRAVITEE_CACHE_ACTION = "X-Gravitee-Cache";

    protected Cache cache;
    protected CacheAction action;

    protected CacheResponseMapper mapper = new CacheResponseMapper();

    public CachePolicyV3(final CachePolicyConfiguration cachePolicyConfiguration) {
        this.cachePolicyConfiguration = cachePolicyConfiguration;
    }

    @OnRequest
    public void onRequest(Request request, Response response, ExecutionContext executionContext, PolicyChain policyChain) {
        setMapperSerializationMode(executionContext);

        action = lookForAction(request);

        if (action != CacheAction.BY_PASS) {
            if (isCachedMethod(request.method())) {
                // It's safe to do so because a new instance of policy is created for each request.
                String cacheName = cachePolicyConfiguration.getCacheName();
                CacheResource cacheResource = executionContext
                    .getComponent(ResourceManager.class)
                    .getResource(cacheName, CacheResource.class);
                if (cacheResource == null) {
                    policyChain.failWith(PolicyResult.failure("No cache has been defined with name " + cacheName));
                    return;
                }

                cache = cacheResource.getCache(executionContext);
                if (cache == null) {
                    policyChain.failWith(PolicyResult.failure("No cache named [ " + cacheName + " ] has been found."));
                    return;
                }

                // Override the invoker
                Invoker defaultInvoker = (Invoker) executionContext.getAttribute(ExecutionContext.ATTR_INVOKER);
                executionContext.setAttribute(ExecutionContext.ATTR_INVOKER, new CacheInvoker(defaultInvoker));
            } else {
                log.debug("Request {} is not a cached request, disable caching for it.", request.id());
            }
        }

        policyChain.doNext(request, response);
    }

    class CacheInvoker implements Invoker {

        private final Invoker invoker;

        CacheInvoker(final Invoker invoker) {
            this.invoker = invoker;
        }

        @Override
        public void invoke(ExecutionContext executionContext, ReadStream stream, Handler connectionHandler) {
            // Here we have to check if there is a value in cache
            String cacheId = hash(executionContext);
            log.debug("Looking for element in cache with the key {}", cacheId);

            Vertx vertx = executionContext.getComponent(Vertx.class);
            vertx.executeBlocking(
                promise -> {
                    Element elt = cache.get(cacheId);
                    promise.complete(elt);
                },
                new io.vertx.core.Handler>() {
                    @Override
                    public void handle(AsyncResult elementAsyncResult) {
                        Element elt = elementAsyncResult.result();
                        if (elt != null && action != CacheAction.REFRESH) {
                            log.debug("An element has been found for key {}, returning the cached response to the initial client", cacheId);

                            try {
                                CacheResponse cacheResponse = mapper.readValue(elt.value().toString(), CacheResponse.class);
                                Buffer content = hasBinaryContentType(cacheResponse.getHeaders())
                                    ? Buffer.buffer(Base64.getDecoder().decode(cacheResponse.getContent().getBytes()))
                                    : cacheResponse.getContent();
                                cacheResponse.setContent(content);
                                final ProxyConnection proxyConnection = new CacheProxyConnection(cacheResponse);

                                // Ok, there is a value for this request in cache so send it through proxy connection
                                connectionHandler.handle(proxyConnection);

                                // Plug underlying stream to connection stream
                                stream.bodyHandler(proxyConnection::write).endHandler(aVoid -> proxyConnection.end());
                            } catch (JsonProcessingException e) {
                                log.error(
                                    "Cannot deserialize element with key {}, invoke backend with invoker {}",
                                    cacheId,
                                    invoker.getClass().getName()
                                );
                            }

                            // Resume the incoming request to handle content and end
                            executionContext.request().resume();
                        } else {
                            if (action == CacheAction.REFRESH) {
                                log.info(
                                    "A refresh action has been received for key {}, invoke backend with invoker {}",
                                    cacheId,
                                    invoker.getClass().getName()
                                );
                            } else {
                                log.debug("No element for key {}, invoke backend with invoker {}", cacheId, invoker.getClass().getName());
                            }

                            // No value, let's do the default invocation and cache result in response
                            invoker.invoke(
                                executionContext,
                                stream,
                                proxyConnection -> {
                                    log.debug("Put response in cache for key {} and request {}", cacheId, executionContext.request().id());

                                    ProxyConnection cacheProxyConnection = new ProxyConnection() {
                                        @Override
                                        public ProxyConnection write(Buffer buffer) {
                                            proxyConnection.write(buffer);
                                            return this;
                                        }

                                        @Override
                                        public void end() {
                                            proxyConnection.end();
                                        }

                                        @Override
                                        public ProxyConnection responseHandler(Handler responseHandler) {
                                            return proxyConnection.responseHandler(
                                                new CacheResponseHandler(cacheId, responseHandler, executionContext)
                                            );
                                        }
                                    };

                                    connectionHandler.handle(cacheProxyConnection);
                                }
                            );
                        }
                    }
                }
            );
        }
    }

    class CacheResponseHandler implements Handler {

        private final String cacheId;
        private final Handler responseHandler;
        private final CacheResponse response = new CacheResponse();
        private final ExecutionContext executionContext;

        CacheResponseHandler(final String cacheId, final Handler responseHandler, ExecutionContext executionContext) {
            this.cacheId = cacheId;
            this.responseHandler = responseHandler;
            this.executionContext = executionContext;
        }

        @Override
        public void handle(ProxyResponse proxyResponse) {
            if (
                cachePolicyConfiguration.getResponseCondition() != null &&
                evaluate(executionContext, proxyResponse, cachePolicyConfiguration.getResponseCondition())
            ) {
                responseHandler.handle(new CacheProxyResponse(proxyResponse, cacheId));
            } else if (
                cachePolicyConfiguration.getResponseCondition() == null &&
                proxyResponse.status() >= HttpStatusCode.OK_200 &&
                proxyResponse.status() < HttpStatusCode.MULTIPLE_CHOICES_300
            ) {
                responseHandler.handle(new CacheProxyResponse(proxyResponse, cacheId));
            } else {
                log.debug(
                    "Response for key {} not put in cache because of the status code {} or the condition",
                    cacheId,
                    proxyResponse.status()
                );
                responseHandler.handle(proxyResponse);
            }
        }

        class CacheProxyResponse implements ProxyResponse {

            private final ProxyResponse proxyResponse;
            private final String cacheId;

            final Buffer content = Buffer.buffer();

            CacheProxyResponse(final ProxyResponse proxyResponse, final String cacheId) {
                this.proxyResponse = proxyResponse;
                this.cacheId = cacheId;
            }

            @Override
            public ReadStream bodyHandler(Handler bodyHandler) {
                this.proxyResponse.bodyHandler(chunk -> {
                        bodyHandler.handle(chunk);
                        content.appendBuffer(chunk);
                    });

                return this;
            }

            @Override
            public ReadStream endHandler(Handler endHandler) {
                this.proxyResponse.endHandler(result -> {
                        endHandler.handle(result);
                        response.setStatus(proxyResponse.status());

                        io.gravitee.common.http.HttpHeaders headers = new io.gravitee.common.http.HttpHeaders();
                        proxyResponse.headers().forEach(entry -> headers.add(entry.getKey(), entry.getValue()));
                        response.setHeaders(headers);
                        Buffer buffer = hasBinaryContentType(headers)
                            ? Buffer.buffer(Base64.getEncoder().encode(content.getBytes()))
                            : content;
                        response.setContent(buffer);
                        Vertx vertx = executionContext.getComponent(Vertx.class);
                        vertx.executeBlocking(
                            promise -> {
                                long timeToLive = -1;
                                if (cachePolicyConfiguration.isUseResponseCacheHeaders()) {
                                    timeToLive = resolveTimeToLive(proxyResponse);
                                }
                                if (timeToLive == -1 || cachePolicyConfiguration.getTimeToLiveSeconds() < timeToLive) {
                                    timeToLive = cachePolicyConfiguration.getTimeToLiveSeconds();
                                }

                                try {
                                    CacheElement element = new CacheElement(cacheId, mapper.writeValueAsString(response));
                                    element.setTimeToLive((int) timeToLive);
                                    cache.put(element);
                                } catch (JsonProcessingException e) {
                                    log.error("Cannot serialize element with key {}", cacheId);
                                }
                                promise.complete();
                            },
                            objectAsyncResult -> {}
                        );
                    });

                return this;
            }

            @Override
            public ReadStream pause() {
                return proxyResponse.pause();
            }

            @Override
            public ReadStream resume() {
                return proxyResponse.resume();
            }

            @Override
            public int status() {
                return proxyResponse.status();
            }

            @Override
            public HttpHeaders headers() {
                return proxyResponse.headers();
            }
        }
    }

    /**
     * Generate a unique identifier for the cache key.
     *
     * @param executionContext
     * @return
     */
    String hash(ExecutionContext executionContext) {
        StringBuilder sb = new StringBuilder();
        String cacheName = cachePolicyConfiguration.getCacheName();
        CacheResource cacheResource = executionContext.getComponent(ResourceManager.class).getResource(cacheName, CacheResource.class);
        String keySeparator = cacheResource.keySeparator();

        switch (cachePolicyConfiguration.getScope()) {
            case APPLICATION:
                sb.append(executionContext.getAttribute(ExecutionContext.ATTR_API)).append(keySeparator);
                sb.append(executionContext.getAttribute(ExecutionContext.ATTR_APPLICATION)).append(keySeparator);
                break;
            case API:
                sb.append(executionContext.getAttribute(ExecutionContext.ATTR_API)).append(keySeparator);
                break;
        }

        sb.append(executionContext.request().path().hashCode()).append(keySeparator);
        sb.append(buildParametersKeyComponent(executionContext.request())).append(keySeparator);

        String key = cachePolicyConfiguration.getKey();
        if (key != null && !key.isEmpty()) {
            key = executionContext.getTemplateEngine().convert(key);
            sb.append(key);
        } else {
            // Remove latest separator
            sb.deleteCharAt(sb.length() - 1);
        }

        return sb.toString();
    }

    private int buildParametersKeyComponent(Request request) {
        return request
            .parameters()
            .entrySet()
            .stream()
            .sorted(Map.Entry.comparingByKey())
            .peek(entry -> Collections.sort(entry.getValue()))
            .map(Map.Entry::toString)
            .collect(Collectors.joining())
            .hashCode();
    }

    public long resolveTimeToLive(ProxyResponse response) {
        long timeToLive = -1;
        if (cachePolicyConfiguration.isUseResponseCacheHeaders()) {
            timeToLive = timeToLiveFromResponse(response);
        }

        if (timeToLive != -1 && cachePolicyConfiguration.getTimeToLiveSeconds() < timeToLive) {
            timeToLive = cachePolicyConfiguration.getTimeToLiveSeconds();
        }

        return timeToLive;
    }

    public static long timeToLiveFromResponse(ProxyResponse response) {
        long timeToLive = -1;
        CacheControl cacheControl = CacheControlUtil.parseCacheControl(response.headers().getFirst(HttpHeaderNames.CACHE_CONTROL));

        if (cacheControl != null && cacheControl.getSMaxAge() != -1) {
            timeToLive = cacheControl.getSMaxAge();
        } else if (cacheControl != null && cacheControl.getMaxAge() != -1) {
            timeToLive = cacheControl.getMaxAge();
        } else {
            Instant expiresAt = ExpiresUtil.parseExpires(response.headers().getFirst(HttpHeaderNames.EXPIRES));
            if (expiresAt != null) {
                long expiresInSeconds = (expiresAt.toEpochMilli() - System.currentTimeMillis()) / 1000;
                timeToLive = (expiresInSeconds < 0) ? -1 : expiresInSeconds;
            }
        }

        return timeToLive;
    }

    private CacheAction lookForAction(Request request) {
        // 1_ First, search in HTTP headers
        String cacheAction = request.headers().getFirst(X_GRAVITEE_CACHE_ACTION);

        if (cacheAction == null || cacheAction.isEmpty()) {
            // 2_ If not found, search in query parameters
            cacheAction = request.parameters().getFirst(CACHE_ACTION_QUERY_PARAMETER);

            // Do not propagate specific query parameter
            request.parameters().remove(CACHE_ACTION_QUERY_PARAMETER);
        } else {
            // Do not propagate specific header
            request.headers().remove(X_GRAVITEE_CACHE_ACTION);
        }

        try {
            return (cacheAction != null) ? CacheAction.valueOf(cacheAction.toUpperCase()) : null;
        } catch (IllegalArgumentException iae) {
            return null;
        }
    }

    protected boolean isCachedMethod(HttpMethod method) {
        if (cachePolicyConfiguration.getMethods() == null || cachePolicyConfiguration.getMethods().isEmpty()) {
            //use Safe Methods
            return (method == HttpMethod.GET || method == HttpMethod.OPTIONS || method == HttpMethod.HEAD);
        }
        return cachePolicyConfiguration.getMethods().contains(method);
    }

    private boolean evaluate(final ExecutionContext context, final ProxyResponse proxyResponse, final String condition) {
        if (condition != null && !condition.isEmpty()) {
            try {
                context.getTemplateEngine().getTemplateContext().setVariable(UPSTREAM_RESPONSE, new EvaluableProxyResponse(proxyResponse));
                return context.getTemplateEngine().getValue(condition, Boolean.class);
            } catch (Exception e) {
                log.error("Unable to evaluate the condition {}", e.getMessage(), e);
                return false;
            }
        }
        return true;
    }

    private void setMapperSerializationMode(ExecutionContext context) {
        if (mapper.isSerializationModeDefined()) {
            return;
        }

        Environment environment = context.getComponent(Environment.class);
        String serializationModeAsString = environment.getProperty(CACHE_SERIALIZATION_MODE_KEY, SerializationMode.TEXT.name());
        mapper.setSerializationMode(SerializationMode.valueOf(serializationModeAsString.toUpperCase()));
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy