From 8f84890a931c97f5e3e399b6636731acdf0c7c40 Mon Sep 17 00:00:00 2001 From: Chris Serino Date: Sun, 8 Nov 2020 13:53:53 -0600 Subject: [PATCH] Enhance/fetch base query (#7) - Enhances `fetchBaseQuery` to have a more familiar API --- examples/posts-and-counter/package.json | 2 +- examples/posts-and-counter/yarn.lock | 4 +- examples/svelte-counter/package.json | 2 +- examples/svelte-counter/sandbox.config.json | 2 +- examples/svelte-counter/src/App.svelte | 4 + examples/svelte-counter/src/mocks/handlers.ts | 22 +++++ .../svelte-counter/src/services/counter.ts | 81 +++++++++++-------- examples/svelte-counter/yarn.lock | 5 +- package.json | 2 +- src/buildActionMaps.ts | 2 +- src/fetchBaseQuery.ts | 63 ++++++++++++--- src/utils/index.ts | 3 + src/utils/isAbsoluteUrl.ts | 9 +++ src/utils/isValidUrl.ts | 9 +++ src/utils/joinUrls.ts | 22 +++++ src/utils/joinsUrls.test.ts | 28 +++++++ yarn.lock | 4 +- 17 files changed, 207 insertions(+), 57 deletions(-) create mode 100644 src/utils/index.ts create mode 100644 src/utils/isAbsoluteUrl.ts create mode 100644 src/utils/isValidUrl.ts create mode 100644 src/utils/joinUrls.ts create mode 100644 src/utils/joinsUrls.test.ts diff --git a/examples/posts-and-counter/package.json b/examples/posts-and-counter/package.json index 09b45e60..1761f9a3 100644 --- a/examples/posts-and-counter/package.json +++ b/examples/posts-and-counter/package.json @@ -5,7 +5,7 @@ "keywords": [], "main": "./index.html", "dependencies": { - "@reduxjs/toolkit": "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/56994225/@reduxjs/toolkit", + "@reduxjs/toolkit": "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/2c869f4d/@reduxjs/toolkit", "@rtk-incubator/simple-query": "https://pkg.csb.dev/rtk-incubator/simple-query/commit/fcea624c/@rtk-incubator/simple-query", "msw": "0.21.3", "react": "17.0.0", diff --git a/examples/posts-and-counter/yarn.lock b/examples/posts-and-counter/yarn.lock index 811f5d6f..a147cba4 100644 --- a/examples/posts-and-counter/yarn.lock +++ b/examples/posts-and-counter/yarn.lock @@ -1369,9 +1369,9 @@ resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== -"@reduxjs/toolkit@https://pkg.csb.dev/reduxjs/redux-toolkit/commit/56994225/@reduxjs/toolkit": +"@reduxjs/toolkit@https://pkg.csb.dev/reduxjs/redux-toolkit/commit/2c869f4d/@reduxjs/toolkit": version "1.4.0" - resolved "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/56994225/@reduxjs/toolkit#4e2beed60d6e564d2911d475d3d0055b07b914a1" + resolved "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/2c869f4d/@reduxjs/toolkit#4ef0bb45ceca425759da283a7de3377ef369f17c" dependencies: immer "^7.0.3" redux "^4.0.0" diff --git a/examples/svelte-counter/package.json b/examples/svelte-counter/package.json index 1dc43873..931e7e22 100644 --- a/examples/svelte-counter/package.json +++ b/examples/svelte-counter/package.json @@ -26,7 +26,7 @@ "typescript": "^3.9.3" }, "dependencies": { - "@reduxjs/toolkit": "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/56994225/@reduxjs/toolkit", + "@reduxjs/toolkit": "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/2c869f4d/@reduxjs/toolkit", "sirv-cli": "^1.0.0" } } diff --git a/examples/svelte-counter/sandbox.config.json b/examples/svelte-counter/sandbox.config.json index aa8b40c1..021318f2 100644 --- a/examples/svelte-counter/sandbox.config.json +++ b/examples/svelte-counter/sandbox.config.json @@ -1,3 +1,3 @@ { "template": "node" -} \ No newline at end of file +} diff --git a/examples/svelte-counter/src/App.svelte b/examples/svelte-counter/src/App.svelte index e97a7e62..7d223ecf 100644 --- a/examples/svelte-counter/src/App.svelte +++ b/examples/svelte-counter/src/App.svelte @@ -17,6 +17,10 @@ onMount(async () => { ({ refetch: getCount } = store.dispatch(counterApi.queryActions.getCount())); + store.dispatch(counterApi.queryActions.getAbsoluteTest()) + store.dispatch(counterApi.queryActions.getError()); + store.dispatch(counterApi.queryActions.getNetworkError()); + store.dispatch(counterApi.queryActions.getHeaderError()); }); diff --git a/examples/svelte-counter/src/mocks/handlers.ts b/examples/svelte-counter/src/mocks/handlers.ts index 796a4e09..a17461a3 100644 --- a/examples/svelte-counter/src/mocks/handlers.ts +++ b/examples/svelte-counter/src/mocks/handlers.ts @@ -6,6 +6,28 @@ let count = 0; let counters = {}; export const handlers = [ + rest.get('/error', (req, res, ctx) => { + return res( + ctx.status(500), + ctx.json({ + message: 'what is this doing!', + data: [{ some: 'key' }], + }), + ); + }), + rest.get('/network-error', (req, res, ctx) => { + return res.networkError('Fake network error'); + }), + rest.get('/mismatched-header-error', (req, res, ctx) => { + return res(ctx.text('oh hello there'), ctx.set('Content-Type', 'application/hal+banana')); + }), + rest.get('https://mocked.data', (req, res, ctx) => { + return res( + ctx.json({ + great: 'success', + }), + ); + }), rest.put<{ amount: number }>('/increment', (req, res, ctx) => { const { amount } = req.body; count = count += amount; diff --git a/examples/svelte-counter/src/services/counter.ts b/examples/svelte-counter/src/services/counter.ts index 665fe818..d67a0d9e 100644 --- a/examples/svelte-counter/src/services/counter.ts +++ b/examples/svelte-counter/src/services/counter.ts @@ -6,55 +6,72 @@ interface CountResponse { export const counterApi = createApi({ reducerPath: 'counterApi', - baseQuery: fetchBaseQuery(), + baseQuery: fetchBaseQuery({ + baseUrl: '/', + }), entityTypes: ['Counter'], endpoints: (build) => ({ + getError: build.query({ + query: (_: void) => '/error', + }), + getNetworkError: build.query({ + query: (_: void) => '/network-error', + }), + getHeaderError: build.query({ + query: (_: void) => '/mismatched-header-error', + }), + getAbsoluteTest: build.query({ + query: () => ({ + url: 'https://mocked.data', + params: { + hello: 'friend', + }, + }), + }), getCount: build.query({ - query: () => 'count', + query: () => ({ + url: `/count?=${'whydothis'}`, + params: { + test: 'param', + additional: 1, + }, + }), provides: ['Counter'], }), - getCountById: build.query({ - query: (id: number) => `${id}`, - provides: (_, id) => [{ type: 'Counter', id }], - }), incrementCount: build.mutation({ - query(amount) { - return { - url: `increment`, + query: (amount) => ({ + url: `/increment`, + method: 'PUT', + body: { amount }, + }), + invalidates: ['Counter'], + }), + decrementCount: build.mutation({ + query: (amount) => ({ + url: `decrement`, method: 'PUT', - body: JSON.stringify({ amount }), - }; - }, + body: { amount }, + }), invalidates: ['Counter'], }), + getCountById: build.query({ + query: (id: number) => `${id}`, + provides: (_, id) => [{ type: 'Counter', id }], + }), incrementCountById: build.mutation({ - query({ id, amount }) { - return { + query: ({ id, amount }) => ({ url: `${id}/increment`, method: 'PUT', - body: JSON.stringify({ amount }), - }; - }, + body: { amount }, + }), invalidates: (_, { id }) => [{ type: 'Counter', id }], }), - decrementCount: build.mutation({ - query(amount) { - return { - url: `decrement`, - method: 'PUT', - body: JSON.stringify({ amount }), - }; - }, - invalidates: ['Counter'], - }), decrementCountById: build.mutation({ - query({ id, amount }) { - return { + query: ({ id, amount }) => ({ url: `${id}/decrement`, method: 'PUT', - body: JSON.stringify({ amount }), - }; - }, + body: { amount }, + }), invalidates: (_, { id }) => [{ type: 'Counter', id }], }), }), diff --git a/examples/svelte-counter/yarn.lock b/examples/svelte-counter/yarn.lock index 4222c738..f58e6333 100644 --- a/examples/svelte-counter/yarn.lock +++ b/examples/svelte-counter/yarn.lock @@ -33,10 +33,9 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.11.tgz#aeb16f50649a91af79dbe36574b66d0f9e4d9f71" integrity sha512-3NsZsJIA/22P3QUyrEDNA2D133H4j224twJrdipXN38dpnIOzAbUDtOwkcJ5pXmn75w7LSQDjA4tO9dm1XlqlA== -"@reduxjs/toolkit@^1.4.0": +"@reduxjs/toolkit@https://pkg.csb.dev/reduxjs/redux-toolkit/commit/2c869f4d/@reduxjs/toolkit": version "1.4.0" - resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.4.0.tgz#ee2e2384cc3d1d76780d844b9c2da3580d32710d" - integrity sha512-hkxQwVx4BNVRsYdxjNF6cAseRmtrkpSlcgJRr3kLUcHPIAMZAmMJkXmHh/eUEGTMqPzsYpJLM7NN2w9fxQDuGw== + resolved "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/2c869f4d/@reduxjs/toolkit#4ef0bb45ceca425759da283a7de3377ef369f17c" dependencies: immer "^7.0.3" redux "^4.0.0" diff --git a/package.json b/package.json index ba5e05fc..97d37106 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "react-redux": "^7.2.1" }, "devDependencies": { - "@reduxjs/toolkit": "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/56994225/@reduxjs/toolkit", + "@reduxjs/toolkit": "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/2c869f4d/@reduxjs/toolkit", "@size-limit/preset-small-lib": "^4.6.0", "@testing-library/react": "^11.1.0", "@testing-library/react-hooks": "^3.4.2", diff --git a/src/buildActionMaps.ts b/src/buildActionMaps.ts index 4318d44e..8e9567ca 100644 --- a/src/buildActionMaps.ts +++ b/src/buildActionMaps.ts @@ -194,7 +194,7 @@ function assertIsNewRTKPromise(action: ReturnType; + body?: any; + responseHandler?: 'json' | 'text' | ((response: Response) => Promise); + validateStatus?: (response: Response, body: any) => boolean; } -export function fetchBaseQuery({ baseUrl }: { baseUrl: string } = { baseUrl: '' }) { +const defaultValidateStatus = (response: Response) => response.status >= 200 && response.status <= 299; + +const isJsonContentType = (headers: Headers) => headers.get('content-type')?.trim()?.startsWith('application/json'); + +export function fetchBaseQuery({ baseUrl }: { baseUrl?: string } = {}) { return async (arg: string | FetchArgs, { signal, rejectWithValue }: QueryApi) => { - const { url, method = 'GET', ...rest } = typeof arg == 'string' ? { url: arg } : arg; - const result = await fetch(`${baseUrl}/${url}`, { + let { + url, + method = 'GET' as const, + headers = undefined, + body = undefined, + params = undefined, + responseHandler = 'json' as const, + validateStatus = defaultValidateStatus, + ...rest + } = typeof arg == 'string' ? { url: arg } : arg; + let config: RequestInit = { method, - headers: { - 'Content-Type': 'application/json', - }, signal, + body, ...rest, - }); + }; + + config.headers = new Headers(headers); + + if (!config.headers.has('content-type')) { + config.headers.set('content-type', 'application/json'); + } + + if (body && isPlainObject(body) && isJsonContentType(config.headers)) { + config.body = JSON.stringify(body); + } + + if (params) { + const divider = ~url.indexOf('?') ? '&' : '?'; + const query = new URLSearchParams(params); + url += divider + query; + } + + url = joinUrls(baseUrl, url); + + const response = await fetch(url, config); - let resultData = - result.headers.has('Content-Type') && !result.headers.get('Content-Type')?.trim()?.startsWith('application/json') - ? await result.text() - : await result.json(); + const resultData = + typeof responseHandler === 'function' + ? await responseHandler(response) + : await response[responseHandler || 'text'](); - return result.status >= 200 && result.status <= 299 + return validateStatus(response, resultData) ? resultData - : rejectWithValue({ status: result.status, data: resultData }); + : rejectWithValue({ status: response.status, data: resultData }); }; } diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..c44144f6 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './isAbsoluteUrl'; +export * from './isValidUrl'; +export * from './joinUrls'; diff --git a/src/utils/isAbsoluteUrl.ts b/src/utils/isAbsoluteUrl.ts new file mode 100644 index 00000000..87e17011 --- /dev/null +++ b/src/utils/isAbsoluteUrl.ts @@ -0,0 +1,9 @@ +/** + * If either :// or // is present consider it to be an absolute url + * + * @param url string + */ + +export function isAbsoluteUrl(url: string) { + return new RegExp(`(^|:)//`).test(url); +} diff --git a/src/utils/isValidUrl.ts b/src/utils/isValidUrl.ts new file mode 100644 index 00000000..7387a16e --- /dev/null +++ b/src/utils/isValidUrl.ts @@ -0,0 +1,9 @@ +export function isValidUrl(string: string) { + try { + new URL(string); + } catch (_) { + return false; + } + + return true; +} diff --git a/src/utils/joinUrls.ts b/src/utils/joinUrls.ts new file mode 100644 index 00000000..12402ce9 --- /dev/null +++ b/src/utils/joinUrls.ts @@ -0,0 +1,22 @@ +import { isAbsoluteUrl } from '.'; + +const withoutTrailingSlash = (url: string) => url.replace(/\/$/, ''); +const withoutLeadingSlash = (url: string) => url.replace(/^\//, ''); + +export function joinUrls(base: string | undefined, url: string | undefined): string { + if (!base) { + return url!; + } + if (!url) { + return base; + } + + if (isAbsoluteUrl(url)) { + return url; + } + + base = withoutTrailingSlash(base); + url = withoutLeadingSlash(url); + + return `${base}/${url}`; +} diff --git a/src/utils/joinsUrls.test.ts b/src/utils/joinsUrls.test.ts new file mode 100644 index 00000000..2fe7d95d --- /dev/null +++ b/src/utils/joinsUrls.test.ts @@ -0,0 +1,28 @@ +import { joinUrls } from './joinUrls'; + +test('correctly joins variations relative urls', () => { + expect(joinUrls('/api/', '/banana')).toBe('/api/banana'); + expect(joinUrls('/api', '/banana')).toBe('/api/banana'); + + expect(joinUrls('/api/', 'banana')).toBe('/api/banana'); + expect(joinUrls('/api/', '/banana/')).toBe('/api/banana/'); + + expect(joinUrls('/', '/banana/')).toBe('/banana/'); + expect(joinUrls('/', 'banana/')).toBe('/banana/'); + + expect(joinUrls('/', '/banana')).toBe('/banana'); + expect(joinUrls('/', 'banana')).toBe('/banana'); + + expect(joinUrls('', '/banana')).toBe('/banana'); + expect(joinUrls('', 'banana')).toBe('banana'); +}); + +test('correctly joins variations of absolute urls', () => { + expect(joinUrls('https://apple.com', '/api/banana/')).toBe('https://apple.com/api/banana/'); + expect(joinUrls('https://apple.com', '/api/banana')).toBe('https://apple.com/api/banana'); + + expect(joinUrls('https://apple.com/', 'api/banana/')).toBe('https://apple.com/api/banana/'); + expect(joinUrls('https://apple.com/', 'api/banana')).toBe('https://apple.com/api/banana'); + + expect(joinUrls('https://apple.com/', 'api/banana/')).toBe('https://apple.com/api/banana/'); +}); diff --git a/yarn.lock b/yarn.lock index 2462e933..47ef842e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1102,9 +1102,9 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" -"@reduxjs/toolkit@https://pkg.csb.dev/reduxjs/redux-toolkit/commit/56994225/@reduxjs/toolkit": +"@reduxjs/toolkit@https://pkg.csb.dev/reduxjs/redux-toolkit/commit/2c869f4d/@reduxjs/toolkit": version "1.4.0" - resolved "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/56994225/@reduxjs/toolkit#4e2beed60d6e564d2911d475d3d0055b07b914a1" + resolved "https://pkg.csb.dev/reduxjs/redux-toolkit/commit/2c869f4d/@reduxjs/toolkit#4ef0bb45ceca425759da283a7de3377ef369f17c" dependencies: immer "^7.0.3" redux "^4.0.0"