From 0b9cebc68501773566de49de6f2a6ecc5413c207 Mon Sep 17 00:00:00 2001 From: Prabhat Khera <91852476+prabhat-org@users.noreply.github.com> Date: Thu, 11 Jan 2024 11:12:53 +1300 Subject: [PATCH] fix(caching): integrate with axios cache interceptor [EE-6505] (#10922) * integrate with axios-cache-interceptor * remove extra headers as not needed --- api/http/handler/kubernetes/handler.go | 1 + .../factory/kubernetes/agent_transport.go | 8 +- api/portainer.go | 2 + app/portainer/services/axios.ts | 119 ++++++++++++------ app/portainer/services/csrf.ts | 7 +- .../applications/application.service.ts | 8 ++ package.json | 2 +- yarn.lock | 31 +++-- 8 files changed, 123 insertions(+), 55 deletions(-) diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index a0eeaebb5..3ccfc0ac5 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -107,6 +107,7 @@ func kubeOnlyMiddleware(next http.Handler) http.Handler { return } + rw.Header().Set(portainer.PortainerCacheHeader, "true") next.ServeHTTP(rw, request) }) } diff --git a/api/http/proxy/factory/kubernetes/agent_transport.go b/api/http/proxy/factory/kubernetes/agent_transport.go index 7b94efd96..b6ab548ae 100644 --- a/api/http/proxy/factory/kubernetes/agent_transport.go +++ b/api/http/proxy/factory/kubernetes/agent_transport.go @@ -57,5 +57,11 @@ func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Respons request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey()) request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) - return transport.baseTransport.RoundTrip(request) + response, err := transport.baseTransport.RoundTrip(request) + if err != nil { + return response, err + } + response.Header.Set(portainer.PortainerCacheHeader, "true") + + return response, err } diff --git a/api/portainer.go b/api/portainer.go index f74c95dd7..4d2ad19ca 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1638,6 +1638,8 @@ const ( WebSocketKeepAlive = 1 * time.Hour // AuthCookieName is the name of the cookie used to store the JWT token AuthCookieKey = "portainer_api_key" + // PortainerCacheHeader is used to enabled FE caching for Kubernetes resources + PortainerCacheHeader = "X-Portainer-Cache" ) // List of supported features diff --git a/app/portainer/services/axios.ts b/app/portainer/services/axios.ts index 32b4a981b..797e3f346 100644 --- a/app/portainer/services/axios.ts +++ b/app/portainer/services/axios.ts @@ -1,8 +1,18 @@ -import axiosOrigin, { AxiosError, InternalAxiosRequestConfig } from 'axios'; -import { setupCache } from 'axios-cache-adapter'; +import Axios, { + AxiosError, + AxiosInstance, + InternalAxiosRequestConfig, +} from 'axios'; +import { + setupCache, + buildMemoryStorage, + CacheAxiosResponse, + InterpreterResult, + AxiosCacheInstance, +} from 'axios-cache-interceptor'; import { loadProgressBar } from 'axios-progress-bar'; - import 'axios-progress-bar/dist/nprogress.css'; + import PortainerError from '@/portainer/error'; import { @@ -12,55 +22,82 @@ import { portainerAgentTargetHeader, } from './http-request.helper'; -export const cache = setupCache({ - maxAge: CACHE_DURATION, - debug: false, // set to true to print cache hits/misses - exclude: { - query: false, // include urls with query params - methods: ['put', 'patch', 'delete'], - filter: (req: InternalAxiosRequestConfig) => { - // exclude caching get requests unless the path contains 'kubernetes' - if (!req.url?.includes('kubernetes') && req.method === 'get') { - return true; - } +const portainerCacheHeader = 'X-Portainer-Cache'; - const acceptHeader = req.headers?.Accept; - if ( - acceptHeader && - typeof acceptHeader === 'string' && - acceptHeader.includes('application/yaml') - ) { - return true; - } - - // exclude caching post requests unless the path contains 'selfsubjectaccessreview' - if ( - !req.url?.includes('selfsubjectaccessreview') && - req.method === 'post' - ) { - return true; - } - return false; +const storage = buildMemoryStorage(); +// mock the cache adapter +export const cache = { + store: { + clear: () => { + storage.data = {}; }, }, - // ask to clear cache on mutation - invalidate: async (_, req) => { - dispatchCacheRefreshEventIfNeeded(req); - }, +}; + +function headerInterpreter( + headers?: CacheAxiosResponse['headers'] +): InterpreterResult { + if (!headers) { + return 'not enough headers'; + } + + if (headers[portainerCacheHeader]) { + return CACHE_DURATION; + } + + return 'not enough headers'; +} + +const axios = Axios.create({ baseURL: 'api' }); +axios.interceptors.request.use((req) => { + dispatchCacheRefreshEventIfNeeded(req); + return req; }); -// by default don't use the cache adapter -const axios = axiosOrigin.create({ baseURL: 'api' }); +// type guard the axios instance +function isAxiosCacheInstance( + a: AxiosInstance | AxiosCacheInstance +): a is AxiosCacheInstance { + return (a as AxiosCacheInstance).defaults.cache !== undefined; +} // when entering a kubernetes environment, or updating user settings, update the cache adapter export function updateAxiosAdapter(useCache: boolean) { - axios.defaults.adapter = useCache ? cache.adapter : undefined; + if (useCache) { + if (isAxiosCacheInstance(axios)) { + return; + } + + setupCache(axios, { + storage, + ttl: CACHE_DURATION, + methods: ['get', 'head', 'options', 'post'], + // cachePredicate determines if the response should be cached based on response + cachePredicate: { + containsHeaders: { + [portainerCacheHeader]: () => true, + }, + ignoreUrls: [/^(?!.*\bkubernetes\b).*$/gm], + responseMatch: (res) => { + if (res.config.method === 'post') { + if (res.config.url?.includes('selfsubjectaccessreviews')) { + return true; + } + return false; + } + return true; + }, + }, + // headerInterpreter interprets the response headers to determine if the response should be cached + headerInterpreter, + }); + } } -loadProgressBar(undefined, axios); - export default axios; +loadProgressBar(undefined, axios); + export const agentTargetHeader = 'X-PortainerAgent-Target'; export function agentInterceptor(config: InternalAxiosRequestConfig) { @@ -173,7 +210,7 @@ export function isDefaultResponse( export function isAxiosError( error: unknown ): error is AxiosError { - return axiosOrigin.isAxiosError(error); + return Axios.isAxiosError(error); } export function arrayToJson(arr?: Array) { diff --git a/app/portainer/services/csrf.ts b/app/portainer/services/csrf.ts index fcbe06e8c..61e275f37 100644 --- a/app/portainer/services/csrf.ts +++ b/app/portainer/services/csrf.ts @@ -1,4 +1,5 @@ -import { InternalAxiosRequestConfig, AxiosResponse } from 'axios'; +import { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import { CacheAxiosResponse } from 'axios-cache-interceptor'; import { IHttpResponse } from 'angular'; import axios from './axios'; @@ -8,7 +9,9 @@ axios.interceptors.request.use(csrfInterceptor); let csrfToken: string | null = null; -export function csrfTokenReaderInterceptor(config: AxiosResponse) { +export function csrfTokenReaderInterceptor( + config: CacheAxiosResponse | AxiosResponse +) { const csrfTokenHeader = config.headers['x-csrf-token']; if (csrfTokenHeader) { csrfToken = csrfTokenHeader; diff --git a/app/react/kubernetes/applications/application.service.ts b/app/react/kubernetes/applications/application.service.ts index 555c815bb..2cc6c934f 100644 --- a/app/react/kubernetes/applications/application.service.ts +++ b/app/react/kubernetes/applications/application.service.ts @@ -216,6 +216,14 @@ async function getApplicationByKind< buildUrl(environmentId, namespace, `${appKind}s`, name), { headers: { Accept: yaml ? 'application/yaml' : 'application/json' }, + // this logic is to get the latest YAML response + // axios-cache-adapter looks for the response headers to determine if the response should be cached + // to avoid writing requestInterceptor, adding a query param to the request url + params: yaml + ? { + _: Date.now(), + } + : null, } ); return data; diff --git a/package.json b/package.json index 9678c0e2a..d850747b8 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "angularjs-slider": "^6.4.0", "angulartics": "^1.6.0", "axios": "^1.6.2", - "axios-cache-adapter": "^2.7.3", + "axios-cache-interceptor": "^1.4.1", "axios-progress-bar": "^1.2.0", "babel-plugin-angularjs-annotate": "^0.10.0", "bootstrap": "^3.4.0", diff --git a/yarn.lock b/yarn.lock index 176226abb..27fa7e7dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7231,13 +7231,14 @@ axe-core@^4.6.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.0.tgz#34ba5a48a8b564f67e103f0aa5768d76e15bbbbf" integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ== -axios-cache-adapter@^2.7.3: - version "2.7.3" - resolved "https://registry.yarnpkg.com/axios-cache-adapter/-/axios-cache-adapter-2.7.3.tgz#0d1eefa0f25b88f42a95c7528d7345bde688181d" - integrity sha512-A+ZKJ9lhpjthOEp4Z3QR/a9xC4du1ALaAsejgRGrH9ef6kSDxdFrhRpulqsh9khsEnwXxGfgpUuDp1YXMNMEiQ== +axios-cache-interceptor@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/axios-cache-interceptor/-/axios-cache-interceptor-1.4.1.tgz#1046a9e77410d7405b03d470c74bd074629258f5" + integrity sha512-Ax4+PiGfNxpQvyF00t55nFzWoVnqW7slKCg9va6dbqiuAGIxRE8r1uMzunw8TKJ5iwLivFqAb0EeiLeUCxuZIw== dependencies: - cache-control-esm "1.0.0" - md5 "^2.2.1" + cache-parser "1.2.4" + fast-defer "1.1.8" + object-code "1.3.2" axios-progress-bar@^1.2.0: version "1.2.0" @@ -7781,10 +7782,10 @@ bytes@3.1.2: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -cache-control-esm@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/cache-control-esm/-/cache-control-esm-1.0.0.tgz#417647ecf1837a5e74155f55d5a4ae32a84e2581" - integrity sha512-Fa3UV4+eIk4EOih8FTV6EEsVKO0W5XWtNs6FC3InTfVz+EjurjPfDXY5wZDo/lxjDxg5RjNcurLyxEJBcEUx9g== +cache-parser@1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/cache-parser/-/cache-parser-1.2.4.tgz#60975135ef2330e6a1d60895279d7237a2a9b398" + integrity sha512-O0KwuHuJnbHUrghHi2kGp0SxnWSIBXTYt7M8WVhW0kbPRUNUKoE/Of6e1rRD6AAxmfxFunKnt90yEK09D+sc5g== call-bind@^1.0.0, call-bind@^1.0.2: version "1.0.2" @@ -10068,6 +10069,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-defer@1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/fast-defer/-/fast-defer-1.1.8.tgz#940ef9597b2ea51c4cd08e99d0f2a8978fa49ba2" + integrity sha512-lEJeOH5VL5R09j6AA0D4Uvq7AgsHw0dAImQQ+F3iSyHZuAxyQfWobsagGpTcOPvJr3urmKRHrs+Gs9hV+/Qm/Q== + fast-glob@^3.1.1: version "3.2.7" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1" @@ -13598,6 +13604,11 @@ object-assign@^4.0.1, object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== +object-code@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/object-code/-/object-code-1.3.2.tgz#f0c0fd71b16aed45a58c306b831f2249806dffd5" + integrity sha512-3CVDmQiru7YYVr+4OpCGrqkVE7GogmWinDcgfno1fXlKgIhtW9FhgHTiV98gMPUjQwWuWvijQDCY8sGnqKrhTw== + object-hash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9"