From fb81b16a9679c7778a0072b8418a9f48ff96dd94 Mon Sep 17 00:00:00 2001 From: polina <> Date: Fri, 26 Jun 2026 12:50:41 -0700 Subject: [PATCH 1/4] Added deleteCachedResponse method --- src/api/index.spec.tsx | 39 +++++++++++++++++++++++++++++++++++++++ src/api/index.tsx | 9 +++++++++ 2 files changed, 48 insertions(+) diff --git a/src/api/index.spec.tsx b/src/api/index.spec.tsx index 001bda2..7ef776f 100644 --- a/src/api/index.spec.tsx +++ b/src/api/index.spec.tsx @@ -538,3 +538,42 @@ test('manual cache read/write and listener', async () => { test('buildUrl', () => { expect(api.buildUrl('/endpoint')).toEqual('http://test.com/endpoint') }) + +describe('deleteCachedResponse', () => { + it('removes a single cached response by params', async () => { + fetchMock.mockResponse(JSON.stringify({num: 1}), { + headers: {'content-type': 'application/json'} + }) + const params: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint'} + + await api.request(params, {fetchPolicy: 'fetch-first'}) + expect(api.readCachedResponse(params)).toEqual({num: 1}) + + api.deleteCachedResponse(params) + + expect(api.readCachedResponse(params)).toBeNull() + }) + + it('is a no-op when there is no cached entry', () => { + const params: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint'} + + expect(() => api.deleteCachedResponse(params)).not.toThrow() + expect(api.readCachedResponse(params)).toBeNull() + }) + + it('does not affect other cached entries', async () => { + fetchMock.mockResponse(JSON.stringify({num: 1}), { + headers: {'content-type': 'application/json'} + }) + const params1: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint1'} + const params2: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint2'} + + await api.request(params1, {fetchPolicy: 'fetch-first'}) + await api.request(params2, {fetchPolicy: 'fetch-first'}) + + api.deleteCachedResponse(params1) + + expect(api.readCachedResponse(params1)).toBeNull() + expect(api.readCachedResponse(params2)).toEqual({num: 1}) + }) +}) diff --git a/src/api/index.tsx b/src/api/index.tsx index edf51fb..23230c8 100644 --- a/src/api/index.tsx +++ b/src/api/index.tsx @@ -132,6 +132,15 @@ export class Api { ) as TResponseBody | null } + /** + * Removes a single cached response, identified by request params. + * The next `useApiQuery` or `api.request` call for the same params will + * fetch fresh data from the network. + */ + deleteCachedResponse = (params: ApiRequestParams): void => { + this.responseBodyCache.del(apiRequestId(params)) + } + /** * Clears the entire cache */ From 50966daa63ed4f02b2868093a56e56db009028e5 Mon Sep 17 00:00:00 2001 From: polina <> Date: Thu, 2 Jul 2026 16:07:09 -0700 Subject: [PATCH 2/4] Add maxAge option for cache TTL Adds a `maxAge` option to `ApiRequestOptions` that treats cached entries older than the given milliseconds as a cache miss, triggering a fresh fetch. Expired entries are evicted from the cache on read to prevent memory leaks. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 30 ++++++++++++++++++++- src/api/generic-cache.tsx | 19 ++++++++++--- src/api/index.spec.tsx | 57 +++++++++++++++++++++++++++++++++++++++ src/api/index.tsx | 4 +-- src/api/typings.tsx | 13 +++++++++ 5 files changed, 116 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4510c9d..49b3dab 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ const [{data, loading, error}] = useApiQuery({url: `/users/${id}`}) - [`requestInProgress(params: object)`](#requestinprogressparams-object) - [`writeCachedResponse(params: object, responseBody?: Blob | object | string)`](#writecachedresponseparams-object-responsebody-blob--object--string) - [`readCachedResponse(params: object)`](#readcachedresponseparams-object) + - [`deleteCachedResponse(params: object)`](#deletecachedresponseparams-object) - [`onCacheUpdate(params: object)`](#oncacheupdateparams-object) - [`setDefaultHeader(key: string, value: string)`](#setdefaultheaderkey-string-value-string) - [`onError`](#onerror) @@ -261,7 +262,7 @@ Note that calling `api.request()` directly always defaults `fetchPolicy` to `'no #### Using the cache directly -It can be useful to read and write directly to and from the cache. For this, you can use `Api#readCachedResponse` and `Api#writeCachedResponse`. View the [API examples](#writecachedresponseparams-object-responsebody-blob--object--string) for usage. +It can be useful to read, write, or remove entries directly from the cache. For this, you can use `Api#readCachedResponse`, `Api#writeCachedResponse`, and `Api#deleteCachedResponse`. View the API examples for [write](#writecachedresponseparams-object-responsebody-blob--object--string), [read](#readcachedresponseparams-object), and [delete](#deletecachedresponseparams-object) for usage. #### How request cache keys are determined @@ -1209,6 +1210,33 @@ The cached response body, or `undefined` on cache miss. +#### `deleteCachedResponse(params: object)` + +Removes a single cached response by request params. The next `useApiQuery` or `api.request` call for the same params will fetch fresh data from the network. + +This is useful for cache invalidation after a mutation — delete the list cache after creating, updating, or deleting an item so navigating back to the list fetches accurate data. + +
Example + +```jsx +await api.request(UserEndpoints.update(1, {name: 'Jane'})) + +// Invalidate the list cache so the next visit fetches fresh data +api.deleteCachedResponse(UserEndpoints.list()) +``` + +
+ +
Details + +`params` fields: + +Standard request params. See [Api#request()](#requestparams-object-opts-object) for options. + +If there is no cached entry for the given params, this is a no-op. + +
+ #### `onCacheUpdate(params: object)` Returns an observable which pushes each new response matching the given params. diff --git a/src/api/generic-cache.tsx b/src/api/generic-cache.tsx index f80a769..015a425 100644 --- a/src/api/generic-cache.tsx +++ b/src/api/generic-cache.tsx @@ -1,16 +1,27 @@ +interface CacheEntry { + value: TValue + timestamp: number +} + export class GenericCache { - private valuesByCacheKey = new Map() + private valuesByCacheKey = new Map>() has(key: string): boolean { return this.valuesByCacheKey.has(key) } - get(key: string): TValue | null { - return this.valuesByCacheKey.get(key) || null + get(key: string, maxAge?: number): TValue | null { + const entry = this.valuesByCacheKey.get(key) + if (!entry) return null + if (maxAge !== undefined && Date.now() - entry.timestamp > maxAge) { + this.valuesByCacheKey.delete(key) + return null + } + return entry.value } set(key: string, value: TValue): void { - this.valuesByCacheKey.set(key, value) + this.valuesByCacheKey.set(key, {value, timestamp: Date.now()}) } del(key: string): void { diff --git a/src/api/index.spec.tsx b/src/api/index.spec.tsx index 7ef776f..ffc5452 100644 --- a/src/api/index.spec.tsx +++ b/src/api/index.spec.tsx @@ -535,6 +535,63 @@ test('manual cache read/write and listener', async () => { expect(onCacheUpdate).not.toBeCalled() }) +describe('maxAge', () => { + beforeEach(() => { + jest.useFakeTimers() + }) + afterEach(() => { + jest.useRealTimers() + }) + + it('serves cached response within maxAge', async () => { + const responseBody = {test: 'data'} + fetchMock.mockResponse(JSON.stringify(responseBody), { + headers: {'content-type': 'application/json'} + }) + const params: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint'} + + await api.request(params, {fetchPolicy: 'fetch-first'}) + fetchMock.mockClear() + + const result = await api.request(params, {fetchPolicy: 'cache-first', maxAge: 60_000}) + expect(result).toEqual(responseBody) + expect(fetchMock).not.toBeCalled() + }) + + it('treats expired cache entry as a miss and refetches', async () => { + const staleBody = {test: 'stale'} + const freshBody = {test: 'fresh'} + fetchMock.mockResponseOnce(JSON.stringify(staleBody), { + headers: {'content-type': 'application/json'} + }) + const params: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint'} + + await api.request(params, {fetchPolicy: 'fetch-first'}) + + jest.advanceTimersByTime(61_000) + + fetchMock.mockResponseOnce(JSON.stringify(freshBody), { + headers: {'content-type': 'application/json'} + }) + + const result = await api.request(params, {fetchPolicy: 'cache-first', maxAge: 60_000}) + expect(result).toEqual(freshBody) + expect(fetchMock).toBeCalledTimes(2) + }) + + it('ignores maxAge when no cached entry exists', async () => { + const responseBody = {test: 'data'} + fetchMock.mockResponseOnce(JSON.stringify(responseBody), { + headers: {'content-type': 'application/json'} + }) + const params: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint'} + + const result = await api.request(params, {fetchPolicy: 'cache-first', maxAge: 60_000}) + expect(result).toEqual(responseBody) + expect(fetchMock).toBeCalledTimes(1) + }) +}) + test('buildUrl', () => { expect(api.buildUrl('/endpoint')).toEqual('http://test.com/endpoint') }) diff --git a/src/api/index.tsx b/src/api/index.tsx index 23230c8..9cfe5b5 100644 --- a/src/api/index.tsx +++ b/src/api/index.tsx @@ -64,7 +64,7 @@ export class Api { params: ApiRequestParams, options: ApiRequestOptions = {} ): Promise => { - const {fetchPolicy = DEFAULT_FETCH_POLICY} = options + const {fetchPolicy = DEFAULT_FETCH_POLICY, maxAge} = options if (!READ_CACHE_POLICIES.includes(fetchPolicy)) { return this.requestManager.getResponseBody( @@ -73,7 +73,7 @@ export class Api { ) as Promise } - const cachedResponse = this.responseBodyCache.get(apiRequestId(params)) + const cachedResponse = this.responseBodyCache.get(apiRequestId(params), maxAge) if (cachedResponse) { if (fetchPolicy === 'cache-and-fetch') { // kick off in the background diff --git a/src/api/typings.tsx b/src/api/typings.tsx index 33a5649..9516183 100644 --- a/src/api/typings.tsx +++ b/src/api/typings.tsx @@ -91,6 +91,19 @@ export interface ApiRequestOptions { * non-`GET` requests, but you can override here on a per-request basis. */ deduplicate?: boolean + + /** + * Maximum age of a cached response in milliseconds. If the cached entry + * is older than this value it is treated as a cache miss and fresh data + * is fetched from the server. + * + * Define this as a shared constant per endpoint so all callers agree on + * the same TTL: + * @example + * export const USERS_MAX_AGE = 60_000 + * api.request(params, { fetchPolicy: 'cache-first', maxAge: USERS_MAX_AGE }) + */ + maxAge?: number } export interface RequestFetcherParams { From 5f90be0b7ac44dffc5bcdab65df9f5dc49ffd4d2 Mon Sep 17 00:00:00 2001 From: polina <> Date: Thu, 2 Jul 2026 16:54:24 -0700 Subject: [PATCH 3/4] Add maxAge support to useApiQuery Threads the maxAge option through to api.request so the hook respects cache TTL the same way direct api.request calls do. Co-Authored-By: Claude Sonnet 4.6 --- src/react/use-api-query/index.tsx | 3 ++- src/react/use-api-query/typings.tsx | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/react/use-api-query/index.tsx b/src/react/use-api-query/index.tsx index 4336e27..a9957a6 100644 --- a/src/react/use-api-query/index.tsx +++ b/src/react/use-api-query/index.tsx @@ -118,7 +118,8 @@ export function useApiQuery( try { const data = await api.request(params, { fetchPolicy, - deduplicate: opts.deduplicate + deduplicate: opts.deduplicate, + maxAge: opts.maxAge }) dispatch( useApiQueryActions.success({ diff --git a/src/react/use-api-query/typings.tsx b/src/react/use-api-query/typings.tsx index d626552..f3d218e 100644 --- a/src/react/use-api-query/typings.tsx +++ b/src/react/use-api-query/typings.tsx @@ -91,6 +91,13 @@ export interface UseApiQueryOptions< * instead of `null` */ initialData?: TResponseBody + + /** + * Maximum age of a cached response in milliseconds. If the cached entry + * is older than this value it is treated as a cache miss and fresh data + * is fetched from the server. + */ + maxAge?: number } export type FalsyValue = '' | 0 | false | undefined | null From f9943f56c21df3f3df4b190494cae112ebe10ad2 Mon Sep 17 00:00:00 2001 From: polina <> Date: Tue, 7 Jul 2026 23:14:57 -0700 Subject: [PATCH 4/4] Replace deleteCachedResponse with url-based eviction deleteCachedResponse(params) required reconstructing the exact request params to compute the cache key, which is impossible for cache keys that embed a serialized request body via extraKey. deleteCachedResponsesByUrl matches entries by url prefix instead, evicting whole request families (sub-paths and query variants) while stopping at path boundaries. --- README.md | 26 +++++++----- src/api/generic-cache.tsx | 4 ++ src/api/index.spec.tsx | 88 ++++++++++++++++++++++++++++++--------- src/api/index.tsx | 35 ++++++++++++---- src/api/lib.spec.tsx | 21 +++++++++- src/api/lib.tsx | 10 +++++ 6 files changed, 146 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 49b3dab..87d11f3 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ const [{data, loading, error}] = useApiQuery({url: `/users/${id}`}) - [`requestInProgress(params: object)`](#requestinprogressparams-object) - [`writeCachedResponse(params: object, responseBody?: Blob | object | string)`](#writecachedresponseparams-object-responsebody-blob--object--string) - [`readCachedResponse(params: object)`](#readcachedresponseparams-object) - - [`deleteCachedResponse(params: object)`](#deletecachedresponseparams-object) + - [`deleteCachedResponsesByUrl(urlPrefix: string)`](#deletecachedresponsesbyurlurlprefix-string) - [`onCacheUpdate(params: object)`](#oncacheupdateparams-object) - [`setDefaultHeader(key: string, value: string)`](#setdefaultheaderkey-string-value-string) - [`onError`](#onerror) @@ -262,7 +262,7 @@ Note that calling `api.request()` directly always defaults `fetchPolicy` to `'no #### Using the cache directly -It can be useful to read, write, or remove entries directly from the cache. For this, you can use `Api#readCachedResponse`, `Api#writeCachedResponse`, and `Api#deleteCachedResponse`. View the API examples for [write](#writecachedresponseparams-object-responsebody-blob--object--string), [read](#readcachedresponseparams-object), and [delete](#deletecachedresponseparams-object) for usage. +It can be useful to read, write, or remove entries directly from the cache. For this, you can use `Api#readCachedResponse`, `Api#writeCachedResponse`, and `Api#deleteCachedResponsesByUrl`. View the API examples for [write](#writecachedresponseparams-object-responsebody-blob--object--string), [read](#readcachedresponseparams-object), and [delete](#deletecachedresponsesbyurlurlprefix-string) for usage. #### How request cache keys are determined @@ -1206,34 +1206,38 @@ Standard request params. See [Api#request()](#requestparams-object-opts-object) Returns `object | Blob | string`: -The cached response body, or `undefined` on cache miss. +The cached response body, or `null` on cache miss. -#### `deleteCachedResponse(params: object)` +#### `deleteCachedResponsesByUrl(urlPrefix: string)` -Removes a single cached response by request params. The next `useApiQuery` or `api.request` call for the same params will fetch fresh data from the network. +Removes **all** cached responses whose request `url` matches the given prefix. The next `useApiQuery` or `api.request` call for any matching request will fetch fresh data from the network. -This is useful for cache invalidation after a mutation — delete the list cache after creating, updating, or deleting an item so navigating back to the list fetches accurate data. +This is useful for cache invalidation after a mutation — evict the cached list responses after creating, updating, or deleting an item so navigating back to the list fetches accurate data. Matching by url (rather than by request params) also covers cache keys that cannot be reconstructed at the call site — most commonly keys that include a serialized request body via `extraKey`, which only the original caller can produce. + +Matching stops at path/query boundaries: a prefix of `/users` matches `/users`, `/users/1`, and `/users?page=2`, but **not** `/users-archive`.
Example ```jsx await api.request(UserEndpoints.update(1, {name: 'Jane'})) -// Invalidate the list cache so the next visit fetches fresh data -api.deleteCachedResponse(UserEndpoints.list()) +// Evict every cached response under this url — the plain list, +// filtered/paginated variants, and POST-based query endpoints +// whose cache keys embed the serialized request body +api.deleteCachedResponsesByUrl('/users') ```
Details -`params` fields: +`urlPrefix`: -Standard request params. See [Api#request()](#requestparams-object-opts-object) for options. +The request `url` prefix to match, as passed to `Api#request` (before `baseUrl` is applied). -If there is no cached entry for the given params, this is a no-op. +If no cached entries match, this is a no-op.
diff --git a/src/api/generic-cache.tsx b/src/api/generic-cache.tsx index 015a425..e07babd 100644 --- a/src/api/generic-cache.tsx +++ b/src/api/generic-cache.tsx @@ -27,4 +27,8 @@ export class GenericCache { del(key: string): void { this.valuesByCacheKey.delete(key) } + + keys(): IterableIterator { + return this.valuesByCacheKey.keys() + } } diff --git a/src/api/index.spec.tsx b/src/api/index.spec.tsx index ffc5452..db74140 100644 --- a/src/api/index.spec.tsx +++ b/src/api/index.spec.tsx @@ -596,41 +596,91 @@ test('buildUrl', () => { expect(api.buildUrl('/endpoint')).toEqual('http://test.com/endpoint') }) -describe('deleteCachedResponse', () => { - it('removes a single cached response by params', async () => { +describe('deleteCachedResponsesByUrl', () => { + it('removes all cached responses matching a url prefix', async () => { fetchMock.mockResponse(JSON.stringify({num: 1}), { headers: {'content-type': 'application/json'} }) - const params: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint'} + const params1: ApiRequestParams<'POST', {}> = { + method: 'POST', + url: '/data-services/portfolio-groups', + extraKey: 'body-a' + } + const params2: ApiRequestParams<'POST', {}> = { + method: 'POST', + url: '/data-services/portfolio-groups', + extraKey: 'body-b' + } - await api.request(params, {fetchPolicy: 'fetch-first'}) - expect(api.readCachedResponse(params)).toEqual({num: 1}) + await api.request(params1, {fetchPolicy: 'fetch-first'}) + await api.request(params2, {fetchPolicy: 'fetch-first'}) - api.deleteCachedResponse(params) + api.deleteCachedResponsesByUrl('/data-services/portfolio-groups') - expect(api.readCachedResponse(params)).toBeNull() + expect(api.readCachedResponse(params1)).toBeNull() + expect(api.readCachedResponse(params2)).toBeNull() }) - it('is a no-op when there is no cached entry', () => { - const params: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint'} + it('matches sub-paths and query strings of the prefix', async () => { + fetchMock.mockResponse(JSON.stringify({num: 1}), { + headers: {'content-type': 'application/json'} + }) + const subPath: ApiRequestParams<'GET', {}> = { + method: 'GET', + url: '/users/1' + } + const withQuery: ApiRequestParams<'GET', {}> = { + method: 'GET', + url: '/users?page=2' + } + + await api.request(subPath, {fetchPolicy: 'fetch-first'}) + await api.request(withQuery, {fetchPolicy: 'fetch-first'}) - expect(() => api.deleteCachedResponse(params)).not.toThrow() - expect(api.readCachedResponse(params)).toBeNull() + api.deleteCachedResponsesByUrl('/users') + + expect(api.readCachedResponse(subPath)).toBeNull() + expect(api.readCachedResponse(withQuery)).toBeNull() }) - it('does not affect other cached entries', async () => { + it('does not affect cached entries with non-matching urls', async () => { fetchMock.mockResponse(JSON.stringify({num: 1}), { headers: {'content-type': 'application/json'} }) - const params1: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint1'} - const params2: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/endpoint2'} + const matching: ApiRequestParams<'GET', {}> = { + method: 'GET', + url: '/portfolio-groups' + } + const other: ApiRequestParams<'GET', {}> = {method: 'GET', url: '/users'} - await api.request(params1, {fetchPolicy: 'fetch-first'}) - await api.request(params2, {fetchPolicy: 'fetch-first'}) + await api.request(matching, {fetchPolicy: 'fetch-first'}) + await api.request(other, {fetchPolicy: 'fetch-first'}) - api.deleteCachedResponse(params1) + api.deleteCachedResponsesByUrl('/portfolio-groups') - expect(api.readCachedResponse(params1)).toBeNull() - expect(api.readCachedResponse(params2)).toEqual({num: 1}) + expect(api.readCachedResponse(matching)).toBeNull() + expect(api.readCachedResponse(other)).toEqual({num: 1}) + }) + + it('does not evict urls that extend the prefix without a path boundary', async () => { + fetchMock.mockResponse(JSON.stringify({num: 1}), { + headers: {'content-type': 'application/json'} + }) + const archive: ApiRequestParams<'GET', {}> = { + method: 'GET', + url: '/users-archive' + } + + await api.request(archive, {fetchPolicy: 'fetch-first'}) + + api.deleteCachedResponsesByUrl('/users') + + expect(api.readCachedResponse(archive)).toEqual({num: 1}) + }) + + it('is a no-op when no cached entries match', () => { + expect(() => + api.deleteCachedResponsesByUrl('/no-such-endpoint') + ).not.toThrow() }) }) diff --git a/src/api/index.tsx b/src/api/index.tsx index 9cfe5b5..e0f41e4 100644 --- a/src/api/index.tsx +++ b/src/api/index.tsx @@ -4,7 +4,7 @@ import PushStream from 'zen-push' import {DEFAULT_FETCH_POLICY, READ_CACHE_POLICIES} from './constants' import {ApiCacheMissError} from './errors' import {GenericCache} from './generic-cache' -import {apiRequestId} from './lib' +import {apiRequestId, parseApiRequestIdUrl} from './lib' import {ApiRequestManager} from './request-manager' import { ApiParseResponseJson, @@ -122,7 +122,7 @@ export class Api { * The main reason you would use this over `Api#request` with a cached * fetch policy is that this runs synchronously. * - * Note that if there is a cache miss, it will return `undefined` + * Note that if there is a cache miss, it will return `null` */ readCachedResponse = ( params: ApiRequestParams @@ -133,12 +133,33 @@ export class Api { } /** - * Removes a single cached response, identified by request params. - * The next `useApiQuery` or `api.request` call for the same params will - * fetch fresh data from the network. + * Removes all cached responses whose request `url` matches the given + * prefix. Use this when the exact request params cannot be reconstructed + * at the call site — for example, cache keys that include a serialized + * request body via `extraKey`. The next `useApiQuery` or `Api#request` + * call for any matching request will fetch fresh data from the network. + * + * Matching stops at path/query boundaries: a prefix of `/users` matches + * `/users`, `/users/1`, and `/users?page=2`, but not `/users-archive`. */ - deleteCachedResponse = (params: ApiRequestParams): void => { - this.responseBodyCache.del(apiRequestId(params)) + deleteCachedResponsesByUrl = (urlPrefix: string): void => { + for (const key of this.responseBodyCache.keys()) { + const url = parseApiRequestIdUrl(key) + + if (!url.startsWith(urlPrefix)) { + continue + } + + const charAfterPrefix = url.charAt(urlPrefix.length) + + if ( + charAfterPrefix === '' || + charAfterPrefix === '/' || + charAfterPrefix === '?' + ) { + this.responseBodyCache.del(key) + } + } } /** diff --git a/src/api/lib.spec.tsx b/src/api/lib.spec.tsx index 1621a8d..5be540e 100644 --- a/src/api/lib.spec.tsx +++ b/src/api/lib.spec.tsx @@ -1,6 +1,25 @@ -import {apiRequestId, applyHeaders, cloneHeaders} from './lib' +import { + apiRequestId, + applyHeaders, + cloneHeaders, + parseApiRequestIdUrl +} from './lib' import {ApiRequestParams} from './typings' +describe('parseApiRequestIdUrl', () => { + it('extracts the url from an id created by apiRequestId', () => { + const requestParams: ApiRequestParams = { + method: 'POST', + url: '/data-services/portfolio-groups', + extraKey: JSON.stringify({page: 1}) + } + + expect(parseApiRequestIdUrl(apiRequestId(requestParams))).toEqual( + '/data-services/portfolio-groups' + ) + }) +}) + describe('apiRequestId', () => { it('serializes basic requests', () => { const requestParams: ApiRequestParams = { diff --git a/src/api/lib.tsx b/src/api/lib.tsx index 3b5df93..9096701 100644 --- a/src/api/lib.tsx +++ b/src/api/lib.tsx @@ -19,6 +19,16 @@ export function apiRequestId( ]) } +/** + * Extracts the request `url` from an id created by `apiRequestId`. + * + * Keep this in sync with the array shape serialized in `apiRequestId`. + */ +export function parseApiRequestIdUrl(id: string): string { + const [, url] = JSON.parse(id) as [string, string] + return url +} + function serializeHeaders(paramHeaders?: ApiHeaders): string { if (!paramHeaders) { return ''