diff --git a/resources/js/apiClientCache.js b/resources/js/apiClientCache.js new file mode 100644 index 0000000000..8b3dc7762c --- /dev/null +++ b/resources/js/apiClientCache.js @@ -0,0 +1,477 @@ +/** + * Default number of milliseconds a successful GET response remains reusable. + */ +const DEFAULT_CACHE_TTL = 5000; + +/** + * HTTP method eligible for response caching and request deduplication. + */ +const CACHEABLE_METHOD = "get"; + +/** + * Determines whether a value is a plain object-like value for parameter encoding. + * + * @param {*} value + * @returns {boolean} + */ +const isObject = (value) => value && typeof value === "object" && !Array.isArray(value); + +/** + * Checks whether a URL already includes a protocol and host. + * + * @param {string} url + * @returns {boolean} + */ +const isAbsoluteURL = (url) => /^[a-z][a-z\d+\-.]*:\/\//i.test(url); + +/** + * Combines Axios baseURL and request URL while preserving absolute request URLs. + * + * @param {string} baseURL + * @param {string} requestedURL + * @returns {string} + */ +const combineURLs = (baseURL, requestedURL = "") => { + const normalizedBaseURL = baseURL || ""; + + if (!normalizedBaseURL || isAbsoluteURL(requestedURL)) { + return requestedURL; + } + + return `${normalizedBaseURL.replace(/\/+$/, "")}/${requestedURL.replace(/^\/+/, "")}`; +}; + +/** + * Reads a header value case-insensitively from an Axios headers object. + * + * @param {object} headers + * @param {string} headerName + * @returns {*} + */ +const normalizeHeaderName = (headers, headerName) => { + const normalizedHeaders = headers || {}; + const match = Object.keys(normalizedHeaders).find((key) => key.toLowerCase() === headerName.toLowerCase()); + return match ? normalizedHeaders[match] : undefined; +}; + +/** + * Converts a query parameter value into a stable string representation. + * + * @param {*} value + * @returns {*} + */ +const encodeValue = (value) => { + if (value instanceof Date) { + return value.toISOString(); + } + + if (isObject(value)) { + return JSON.stringify(value); + } + + return value; +}; + +/** + * Serializes query parameters in a stable order unless Axios provides a custom serializer. + * + * @param {object|URLSearchParams} params + * @param {Function} paramsSerializer + * @returns {string} + */ +const serializeParams = (params, paramsSerializer) => { + if (!params) { + return ""; + } + + if (typeof paramsSerializer === "function") { + return paramsSerializer(params); + } + + if (typeof URLSearchParams !== "undefined" && params instanceof URLSearchParams) { + return params.toString(); + } + + return Object.keys(params) + .sort() + .reduce((parts, key) => { + const value = params[key]; + + if (value === null || typeof value === "undefined") { + return parts; + } + + const values = Array.isArray(value) ? value : [value]; + + values.forEach((entry) => { + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(encodeValue(entry))}`); + }); + + return parts; + }, []) + .join("&"); +}; + +/** + * Appends serialized query parameters to a URL. + * + * @param {string} url + * @param {object|URLSearchParams} params + * @param {Function} paramsSerializer + * @returns {string} + */ +const appendParams = (url, params, paramsSerializer) => { + const serializedParams = serializeParams(params, paramsSerializer); + + if (!serializedParams) { + return url; + } + + return `${url}${url.includes("?") ? "&" : "?"}${serializedParams}`; +}; + +/** + * Clones response data so cached responses are not mutated by consumers. + * + * @param {*} data + * @returns {*} + */ +const cloneData = (data) => { + if (!data || typeof data !== "object") { + return data; + } + + if (typeof structuredClone === "function") { + try { + return structuredClone(data); + } catch (error) { + // Fall through to JSON cloning for plain response payloads. + } + } + + try { + return JSON.parse(JSON.stringify(data)); + } catch (error) { + return data; + } +}; + +/** + * Clones the Axios response fields that consumers commonly mutate. + * + * @param {object} response + * @returns {object} + */ +const cloneResponse = (response) => ({ + ...response, + data: cloneData(response.data), + headers: response.headers ? { ...response.headers } : response.headers, +}); + +/** + * Builds the cache lookup key and human-readable URL for an Axios request. + * + * The key includes URL, query parameters, response type, and Accept header so + * requests that can produce different payload shapes do not share cache entries. + * + * @param {object} config Axios request configuration + * @returns {{key: string, url: string}} + */ +export const buildApiClientCacheKey = (config = {}) => { + const url = appendParams( + combineURLs(config.baseURL, config.url || ""), + config.params, + config.paramsSerializer, + ); + const headers = config.headers || {}; + const relevantConfig = { + responseType: config.responseType || "", + accept: normalizeHeaderName(headers, "Accept") || "", + }; + + return { + key: `${url}|${JSON.stringify(relevantConfig)}`, + url, + }; +}; + +/** + * Installs response caching and in-flight request deduplication on an Axios client. + * + * The wrapper is implemented at the Axios adapter layer so normal Axios call + * forms, interceptors, defaults, and helpers continue to work unchanged. + * + * @param {Function|object} apiClient Axios client instance + * @returns {Function|object} The same Axios client instance + */ +export const installApiClientCache = (apiClient) => { + if (!apiClient || apiClient.cache) { + return apiClient; + } + + const client = apiClient; + const responseCache = new Map(); + const pendingRequests = new Map(); + const originalAdapter = client.defaults.adapter; + + let globallyEnabled = false; + let disabled = false; + let debugEnabled = false; + + /** + * Writes cache diagnostics only when global or per-request debugging is enabled. + * + * @param {object} config Axios request configuration + * @param {string} message Debug message + * @param {object} details Structured details for browser console inspection + * @returns {void} + */ + const debug = (config, message, details = {}) => { + if (!debugEnabled && !(config.cache && config.cache.debug)) { + return; + } + + // eslint-disable-next-line no-console + console.log(`[ProcessMaker.apiClient.cache] ${message}`, details); + }; + + /** + * Removes expired response entries from the in-memory cache. + * + * @returns {void} + */ + const cleanup = () => { + const now = Date.now(); + + responseCache.forEach((entry, key) => { + if (entry.expiresAt <= now) { + responseCache.delete(key); + } + }); + }; + + /** + * Invalidates response entries that satisfy a caller-provided predicate. + * + * @param {Function} matcher Predicate receiving the cache key and entry + * @returns {void} + */ + const invalidateByMatcher = (matcher) => { + responseCache.forEach((entry, key) => { + if (matcher(key, entry)) { + responseCache.delete(key); + } + }); + }; + + const cache = { + DEFAULT_CACHE_TTL, + /** + * Indicates whether cache is enabled globally for requests that do not opt in. + * + * @returns {boolean} + */ + get enabled() { + return globallyEnabled && !disabled; + }, + /** + * Indicates whether cache has been disabled for the current window. + * + * @returns {boolean} + */ + get disabled() { + return disabled; + }, + /** + * Indicates whether global cache debug logging is active. + * + * @returns {boolean} + */ + get debug() { + return debugEnabled; + }, + /** + * Enables cache for all eligible GET requests in the current window. + * + * @returns {void} + */ + enable() { + globallyEnabled = true; + disabled = false; + debug({}, "cache enabled globally"); + }, + /** + * Disables cache and deduplication for the current window. + * + * @returns {void} + */ + disable() { + disabled = true; + debug({}, "cache disabled for current window"); + }, + /** + * Enables cache diagnostic logging for the current window. + * + * @returns {void} + */ + enableDebug() { + debugEnabled = true; + debug({}, "debug logging enabled"); + }, + /** + * Disables cache diagnostic logging for the current window. + * + * @returns {void} + */ + disableDebug() { + debug({}, "debug logging disabled"); + debugEnabled = false; + }, + /** + * Clears cached responses and tracked in-flight requests. + * + * @returns {void} + */ + clear() { + responseCache.clear(); + pendingRequests.clear(); + debug({}, "cache cleared"); + }, + cleanup, + /** + * Invalidates an exact cache key or URL. + * + * @param {string} urlOrKey + * @returns {void} + */ + invalidate(urlOrKey) { + invalidateByMatcher((key, entry) => key === urlOrKey || entry.url === urlOrKey); + debug({}, "cache invalidated", { urlOrKey }); + }, + /** + * Invalidates cache entries whose key or URL matches a string or RegExp pattern. + * + * @param {string|RegExp} pattern + * @returns {void} + */ + invalidateByPattern(pattern) { + if (pattern instanceof RegExp) { + invalidateByMatcher((key, entry) => pattern.test(key) || pattern.test(entry.url)); + debug({}, "cache invalidated by RegExp pattern", { pattern }); + return; + } + + invalidateByMatcher((key, entry) => key.includes(pattern) || entry.url.includes(pattern)); + debug({}, "cache invalidated by pattern", { pattern }); + }, + }; + + /** + * Determines whether a request should use cache behavior. + * + * @param {object} config Axios request configuration + * @returns {boolean} + */ + const isCacheEnabledForRequest = (config) => { + if (disabled || (config.cache && config.cache.enabled === false)) { + return false; + } + + return Boolean(config.cache && config.cache.enabled === true) || globallyEnabled; + }; + + /** + * Resolves the per-request TTL, falling back to the default duration. + * + * @param {object} config Axios request configuration + * @returns {number} + */ + const getTTL = (config) => { + const ttl = Number(config.cache && config.cache.ttl); + return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_CACHE_TTL; + }; + + client.DEFAULT_CACHE_TTL = DEFAULT_CACHE_TTL; + client.cache = cache; + /** + * Axios adapter wrapper that serves cached GETs, joins duplicate in-flight GETs, + * or delegates to the original adapter for all other requests. + * + * @param {object} config Axios request configuration + * @returns {Promise} + */ + client.defaults.adapter = (config) => { + const method = (config.method || CACHEABLE_METHOD).toLowerCase(); + + if (method !== CACHEABLE_METHOD || !isCacheEnabledForRequest(config)) { + debug(config, "bypassing cache", { + method, + url: config.url, + reason: method !== CACHEABLE_METHOD ? "non-get request" : "cache disabled", + }); + return originalAdapter(config); + } + + cleanup(); + + const { key, url } = buildApiClientCacheKey(config); + const cachedResponse = responseCache.get(key); + + if (cachedResponse && cachedResponse.expiresAt > Date.now()) { + debug(config, "cache hit", { + key, + url, + expiresAt: cachedResponse.expiresAt, + }); + return Promise.resolve(cloneResponse(cachedResponse.response)); + } + + if (pendingRequests.has(key)) { + debug(config, "deduplicated in-flight request", { key, url }); + return pendingRequests.get(key); + } + + debug(config, "cache miss; sending network request", { key, url }); + + const request = originalAdapter(config) + .then((response) => { + responseCache.set(key, { + key, + url, + response: cloneResponse(response), + expiresAt: Date.now() + getTTL(config), + }); + + debug(config, "response cached", { + key, + url, + ttl: getTTL(config), + status: response.status, + }); + + return cloneResponse(response); + }) + .catch((error) => { + debug(config, "request failed; response not cached", { + key, + url, + message: error.message, + status: error.response && error.response.status, + }); + + return Promise.reject(error); + }) + .finally(() => { + pendingRequests.delete(key); + debug(config, "in-flight request removed", { key, url }); + }); + + pendingRequests.set(key, request); + + return request; + }; + + return client; +}; + +export default installApiClientCache; diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 8df87873b1..acce95c058 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -34,6 +34,7 @@ import TreeView from "./components/TreeView.vue"; import FilterTable from "./components/shared/FilterTable.vue"; import PaginationTable from "./components/shared/PaginationTable.vue"; import PMDropdownSuggest from "./components/PMDropdownSuggest"; +import { installApiClientCache } from "./apiClientCache"; import "@processmaker/screen-builder/dist/vue-form-builder.css"; window.__ = translator; @@ -242,6 +243,8 @@ window.ProcessMaker.i18nPromise.then(() => { translationsLoaded = true; }); */ window.ProcessMaker.apiClient = require("axios"); +installApiClientCache(window.ProcessMaker.apiClient); + window.ProcessMaker.apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; /** diff --git a/resources/js/next/config/processmaker.js b/resources/js/next/config/processmaker.js index 348227533a..e30588a07b 100644 --- a/resources/js/next/config/processmaker.js +++ b/resources/js/next/config/processmaker.js @@ -1,5 +1,6 @@ import axios from "axios"; import { setGlobalPMVariables, getGlobalPMVariable } from "../globalVariables"; +import { installApiClientCache } from "../../apiClientCache"; export default () => { const token = document.head.querySelector("meta[name=\"csrf-token\"]"); @@ -20,6 +21,7 @@ export default () => { */ const apiClient = axios; + installApiClientCache(apiClient); apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; diff --git a/tests/js/apiClientCache.test.js b/tests/js/apiClientCache.test.js new file mode 100644 index 0000000000..563c6e234d --- /dev/null +++ b/tests/js/apiClientCache.test.js @@ -0,0 +1,286 @@ +import { buildApiClientCacheKey, installApiClientCache } from "../../resources/js/apiClientCache"; + +const createApiClient = (adapter) => ({ + defaults: { + adapter, + }, +}); + +const createResponse = (data, config = {}) => ({ + data, + status: 200, + statusText: "OK", + headers: {}, + config, +}); + +describe("apiClient cache", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("does not cache GET requests unless caching is enabled", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { method: "get", baseURL: "/api/1.0/", url: "tasks" }; + + const first = await apiClient.defaults.adapter(config); + const second = await apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(2); + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 2 }); + }); + + test("deduplicates concurrent cache-enabled GET requests", async () => { + let resolveRequest; + const adapter = jest.fn(() => new Promise((resolve) => { + resolveRequest = resolve; + })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "task_schema/584421", + cache: { enabled: true }, + }; + + const first = apiClient.defaults.adapter(config); + const second = apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(1); + expect(first).toBe(second); + + resolveRequest(createResponse({ id: 584421 }, config)); + + await expect(first).resolves.toMatchObject({ data: { id: 584421 } }); + await expect(second).resolves.toMatchObject({ data: { id: 584421 } }); + }); + + test("uses the default TTL and refetches after it expires", async () => { + let now = 1000; + jest.spyOn(Date, "now").mockImplementation(() => now); + + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + const first = await apiClient.defaults.adapter(config); + now = 5999; + const second = await apiClient.defaults.adapter(config); + now = 6000; + const third = await apiClient.defaults.adapter(config); + + expect(apiClient.DEFAULT_CACHE_TTL).toBe(5000); + expect(adapter).toHaveBeenCalledTimes(2); + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 1 }); + expect(third.data).toEqual({ count: 2 }); + }); + + test("uses a per-request TTL override", async () => { + let now = 1000; + jest.spyOn(Date, "now").mockImplementation(() => now); + + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true, ttl: 30_000 }, + }; + + await apiClient.defaults.adapter(config); + now = 30_000; + const cached = await apiClient.defaults.adapter(config); + now = 31_000; + const refetched = await apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(2); + expect(cached.data).toEqual({ count: 1 }); + expect(refetched.data).toEqual({ count: 2 }); + }); + + test("does not cache failed GET requests", async () => { + const error = new Error("Network failed"); + const adapter = jest.fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(createResponse({ count: 1 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + await expect(apiClient.defaults.adapter(config)).rejects.toThrow("Network failed"); + await expect(apiClient.defaults.adapter(config)).resolves.toMatchObject({ data: { count: 1 } }); + + expect(adapter).toHaveBeenCalledTimes(2); + }); + + test("bypasses cache and deduplication for non-GET requests", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "post", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + await apiClient.defaults.adapter(config); + await apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(2); + }); + + test("globally enables caching while allowing a request to opt out", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })) + .mockResolvedValueOnce(createResponse({ count: 3 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { method: "get", baseURL: "/api/1.0/", url: "tasks" }; + + apiClient.cache.enable(); + await apiClient.defaults.adapter(config); + await apiClient.defaults.adapter(config); + await apiClient.defaults.adapter({ ...config, cache: { enabled: false } }); + await apiClient.defaults.adapter({ ...config, cache: { enabled: false } }); + + expect(adapter).toHaveBeenCalledTimes(3); + }); + + test("invalidates cached responses by exact URL and pattern", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })) + .mockResolvedValueOnce(createResponse({ count: 3 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + params: { include: "data" }, + cache: { enabled: true }, + }; + + await apiClient.defaults.adapter(config); + apiClient.cache.invalidate("/api/1.0/tasks?include=data"); + const second = await apiClient.defaults.adapter(config); + apiClient.cache.invalidateByPattern("/tasks"); + const third = await apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(3); + expect(second.data).toEqual({ count: 2 }); + expect(third.data).toEqual({ count: 3 }); + }); + + test("disable bypasses even explicitly cache-enabled requests in the current window", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ count: 1 })) + .mockResolvedValueOnce(createResponse({ count: 2 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + apiClient.cache.disable(); + + const first = await apiClient.defaults.adapter(config); + const second = await apiClient.defaults.adapter(config); + + expect(adapter).toHaveBeenCalledTimes(2); + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 2 }); + }); + + test("builds stable keys from URL, params, and relevant config", () => { + const first = buildApiClientCacheKey({ + baseURL: "/api/1.0/", + url: "tasks", + params: { b: 2, a: 1 }, + headers: { Accept: "application/json" }, + }); + const second = buildApiClientCacheKey({ + baseURL: "/api/1.0/", + url: "tasks", + params: { a: 1, b: 2 }, + headers: { accept: "application/json" }, + }); + + expect(first).toEqual(second); + expect(first.url).toBe("/api/1.0/tasks?a=1&b=2"); + }); + + test("keeps responses with distinct relevant configuration separate", async () => { + const adapter = jest.fn() + .mockResolvedValueOnce(createResponse({ format: "json" })) + .mockResolvedValueOnce(createResponse({ format: "blob" })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const baseConfig = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + const json = await apiClient.defaults.adapter({ + ...baseConfig, + headers: { Accept: "application/json" }, + }); + const blob = await apiClient.defaults.adapter({ + ...baseConfig, + headers: { Accept: "application/octet-stream" }, + responseType: "blob", + }); + + expect(adapter).toHaveBeenCalledTimes(2); + expect(json.data).toEqual({ format: "json" }); + expect(blob.data).toEqual({ format: "blob" }); + }); + + test("logs cache decisions when debug is enabled", async () => { + const log = jest.spyOn(console, "log").mockImplementation(() => {}); + const adapter = jest.fn().mockResolvedValue(createResponse({ count: 1 })); + const apiClient = installApiClientCache(createApiClient(adapter)); + const config = { + method: "get", + baseURL: "/api/1.0/", + url: "tasks", + cache: { enabled: true }, + }; + + apiClient.cache.enableDebug(); + + await apiClient.defaults.adapter(config); + + expect(log).toHaveBeenCalledWith( + "[ProcessMaker.apiClient.cache] cache miss; sending network request", + expect.objectContaining({ url: "/api/1.0/tasks" }), + ); + expect(log).toHaveBeenCalledWith( + "[ProcessMaker.apiClient.cache] response cached", + expect.objectContaining({ status: 200 }), + ); + }); +});