From 0cf3e4cc7c376ab8a53d37dd5f401ec72a42ea4a Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 8 Nov 2022 16:48:27 -0800 Subject: [PATCH] Add missing matcher support (#42660) This adds support for a `missing` field which is complimentary to `has` to only match when `query`, `header`, or `cookie` values are not present. The `missing` field can be applied for middleware matchers as well to prevent running middleware when these fields are present. x-ref: https://github.com/vercel/vercel/pull/8874 x-ref: [slack thread](https://vercel.slack.com/archives/C03S8ED1DKM/p1667935428788529?thread_ts=1667850697.542269&cid=C03S8ED1DKM) x-ref: https://github.com/vercel/next.js/discussions/25653 x-ref: https://github.com/vercel/next.js/issues/34046 x-ref: https://github.com/vercel/next.js/issues/28185 x-ref: https://github.com/vercel/next.js/issues/25930 x-ref: https://github.com/vercel/next.js/issues/24608 x-ref: https://github.com/vercel/next.js/pull/27431 ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- .../build/analysis/get-page-static-info.ts | 1 + .../loaders/next-serverless-loader/utils.ts | 9 +- packages/next/lib/load-custom-routes.ts | 107 ++++++++++++------ packages/next/server/router.ts | 10 +- .../router/utils/middleware-route-matcher.ts | 4 +- .../lib/router/utils/prepare-destination.ts | 13 ++- .../lib/router/utils/resolve-rewrites.ts | 5 +- .../app/middleware.js | 20 ++++ .../test/index.test.ts | 22 ++++ test/integration/custom-routes/next.config.js | 32 ++++++ .../custom-routes/test/index.test.js | 81 +++++++++++++ 11 files changed, 256 insertions(+), 48 deletions(-) diff --git a/packages/next/build/analysis/get-page-static-info.ts b/packages/next/build/analysis/get-page-static-info.ts index 56d594a40ca47d7..98a58a74077c0cb 100644 --- a/packages/next/build/analysis/get-page-static-info.ts +++ b/packages/next/build/analysis/get-page-static-info.ts @@ -25,6 +25,7 @@ export interface MiddlewareMatcher { regexp: string locale?: false has?: RouteHas[] + missing?: RouteHas[] } export interface PageStaticInfo { diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts index 98e4834a59159d2..86488b29de2bdba 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts @@ -178,8 +178,13 @@ export function getUtils({ ) let params = matcher(parsedUrl.pathname) - if (rewrite.has && params) { - const hasParams = matchHas(req, rewrite.has, parsedUrl.query) + if ((rewrite.has || rewrite.missing) && params) { + const hasParams = matchHas( + req, + parsedUrl.query, + rewrite.has, + rewrite.missing + ) if (hasParams) { Object.assign(params, hasParams) diff --git a/packages/next/lib/load-custom-routes.ts b/packages/next/lib/load-custom-routes.ts index a6316ce25c36b68..9c3c126f6e57185 100644 --- a/packages/next/lib/load-custom-routes.ts +++ b/packages/next/lib/load-custom-routes.ts @@ -24,6 +24,7 @@ export type Rewrite = { basePath?: false locale?: false has?: RouteHas[] + missing?: RouteHas[] } export type Header = { @@ -32,6 +33,7 @@ export type Header = { locale?: false headers: Array<{ key: string; value: string }> has?: RouteHas[] + missing?: RouteHas[] } // internal type used for validation (not user facing) @@ -41,6 +43,7 @@ export type Redirect = { basePath?: false locale?: false has?: RouteHas[] + missing?: RouteHas[] } & ( | { statusCode?: never @@ -56,6 +59,7 @@ export type Middleware = { source: string locale?: false has?: RouteHas[] + missing?: RouteHas[] } const allowedHasTypes = new Set(['header', 'cookie', 'query', 'host']) @@ -132,8 +136,9 @@ export function checkCustomRoutes( let numInvalidRoutes = 0 let hadInvalidStatus = false let hadInvalidHas = false + let hadInvalidMissing = false - const allowedKeys = new Set(['source', 'locale', 'has']) + const allowedKeys = new Set(['source', 'locale', 'has', 'missing']) if (type === 'rewrite') { allowedKeys.add('basePath') @@ -198,48 +203,65 @@ export function checkCustomRoutes( invalidParts.push('`locale` must be undefined or false') } - if (typeof route.has !== 'undefined' && !Array.isArray(route.has)) { - invalidParts.push('`has` must be undefined or valid has object') - hadInvalidHas = true - } else if (route.has) { - const invalidHasItems = [] + const checkInvalidHasMissing = ( + items: any, + fieldName: 'has' | 'missing' + ) => { + let hadInvalidItem = false - for (const hasItem of route.has) { - let invalidHasParts = [] + if (typeof items !== 'undefined' && !Array.isArray(items)) { + invalidParts.push( + `\`${fieldName}\` must be undefined or valid has object` + ) + hadInvalidItem = true + } else if (items) { + const invalidHasItems = [] - if (!allowedHasTypes.has(hasItem.type)) { - invalidHasParts.push(`invalid type "${hasItem.type}"`) - } - if (typeof hasItem.key !== 'string' && hasItem.type !== 'host') { - invalidHasParts.push(`invalid key "${hasItem.key}"`) - } - if ( - typeof hasItem.value !== 'undefined' && - typeof hasItem.value !== 'string' - ) { - invalidHasParts.push(`invalid value "${hasItem.value}"`) - } - if (typeof hasItem.value === 'undefined' && hasItem.type === 'host') { - invalidHasParts.push(`value is required for "host" type`) - } + for (const hasItem of items) { + let invalidHasParts = [] - if (invalidHasParts.length > 0) { - invalidHasItems.push( - `${invalidHasParts.join(', ')} for ${JSON.stringify(hasItem)}` - ) + if (!allowedHasTypes.has(hasItem.type)) { + invalidHasParts.push(`invalid type "${hasItem.type}"`) + } + if (typeof hasItem.key !== 'string' && hasItem.type !== 'host') { + invalidHasParts.push(`invalid key "${hasItem.key}"`) + } + if ( + typeof hasItem.value !== 'undefined' && + typeof hasItem.value !== 'string' + ) { + invalidHasParts.push(`invalid value "${hasItem.value}"`) + } + if (typeof hasItem.value === 'undefined' && hasItem.type === 'host') { + invalidHasParts.push(`value is required for "host" type`) + } + + if (invalidHasParts.length > 0) { + invalidHasItems.push( + `${invalidHasParts.join(', ')} for ${JSON.stringify(hasItem)}` + ) + } } - } - if (invalidHasItems.length > 0) { - hadInvalidHas = true - const itemStr = `item${invalidHasItems.length === 1 ? '' : 's'}` + if (invalidHasItems.length > 0) { + hadInvalidItem = true + const itemStr = `item${invalidHasItems.length === 1 ? '' : 's'}` - console.error( - `Invalid \`has\` ${itemStr}:\n` + invalidHasItems.join('\n') - ) - console.error() - invalidParts.push(`invalid \`has\` ${itemStr} found`) + console.error( + `Invalid \`${fieldName}\` ${itemStr}:\n` + + invalidHasItems.join('\n') + ) + console.error() + invalidParts.push(`invalid \`${fieldName}\` ${itemStr} found`) + } } + return hadInvalidItem + } + if (checkInvalidHasMissing(route.has, 'has')) { + hadInvalidHas = true + } + if (checkInvalidHasMissing(route.missing, 'missing')) { + hadInvalidMissing = true } if (!route.source) { @@ -421,6 +443,19 @@ export function checkCustomRoutes( )}` ) } + if (hadInvalidMissing) { + console.error( + `\nValid \`missing\` object shape is ${JSON.stringify( + { + type: [...allowedHasTypes].join(', '), + key: 'the key to check for', + value: 'undefined or a value string to match against', + }, + null, + 2 + )}` + ) + } console.error() console.error( `Error: Invalid ${type}${numInvalidRoutes === 1 ? '' : 's'} found` diff --git a/packages/next/server/router.ts b/packages/next/server/router.ts index a7251b0e1bd5590..8a5bd6c4fe2b605 100644 --- a/packages/next/server/router.ts +++ b/packages/next/server/router.ts @@ -30,6 +30,7 @@ type RouteResult = { export type Route = { match: RouteMatch has?: RouteHas[] + missing?: RouteHas[] type: string check?: boolean statusCode?: number @@ -416,8 +417,13 @@ export default class Router { }) let params = route.match(matchPathname) - if (route.has && params) { - const hasParams = matchHas(req, route.has, parsedUrlUpdated.query) + if ((route.has || route.missing) && params) { + const hasParams = matchHas( + req, + parsedUrlUpdated.query, + route.has, + route.missing + ) if (hasParams) { Object.assign(params, hasParams) } else { diff --git a/packages/next/shared/lib/router/utils/middleware-route-matcher.ts b/packages/next/shared/lib/router/utils/middleware-route-matcher.ts index 87465e3d8a631cd..3ab5c3b047219b9 100644 --- a/packages/next/shared/lib/router/utils/middleware-route-matcher.ts +++ b/packages/next/shared/lib/router/utils/middleware-route-matcher.ts @@ -25,8 +25,8 @@ export function getMiddlewareRouteMatcher( continue } - if (matcher.has) { - const hasParams = matchHas(req, matcher.has, query) + if (matcher.has || matcher.missing) { + const hasParams = matchHas(req, query, matcher.has, matcher.missing) if (!hasParams) { continue } diff --git a/packages/next/shared/lib/router/utils/prepare-destination.ts b/packages/next/shared/lib/router/utils/prepare-destination.ts index d45ee6f1b94d541..fe8c8b6bc037002 100644 --- a/packages/next/shared/lib/router/utils/prepare-destination.ts +++ b/packages/next/shared/lib/router/utils/prepare-destination.ts @@ -42,12 +42,13 @@ function unescapeSegments(str: string) { export function matchHas( req: BaseNextRequest | IncomingMessage, - has: RouteHas[], - query: Params + query: Params, + has: RouteHas[] = [], + missing: RouteHas[] = [] ): false | Params { const params: Params = {} - const allMatch = has.every((hasItem) => { + const hasMatch = (hasItem: RouteHas) => { let value: undefined | string let key = hasItem.key @@ -100,7 +101,11 @@ export function matchHas( } } return false - }) + } + + const allMatch = + has.every((item) => hasMatch(item)) && + !missing.some((item) => hasMatch(item)) if (allMatch) { return params diff --git a/packages/next/shared/lib/router/utils/resolve-rewrites.ts b/packages/next/shared/lib/router/utils/resolve-rewrites.ts index 66bd11d09e4301d..2bbaa2dfb9be5b9 100644 --- a/packages/next/shared/lib/router/utils/resolve-rewrites.ts +++ b/packages/next/shared/lib/router/utils/resolve-rewrites.ts @@ -44,7 +44,7 @@ export default function resolveRewrites( let params = matcher(parsedAs.pathname) - if (rewrite.has && params) { + if ((rewrite.has || rewrite.missing) && params) { const hasParams = matchHas( { headers: { @@ -58,8 +58,9 @@ export default function resolveRewrites( return acc }, {}), } as any, + parsedAs.query, rewrite.has, - parsedAs.query + rewrite.missing ) if (hasParams) { diff --git a/test/e2e/middleware-custom-matchers/app/middleware.js b/test/e2e/middleware-custom-matchers/app/middleware.js index fdea3f1f6f0fd79..99ec191d359ce87 100644 --- a/test/e2e/middleware-custom-matchers/app/middleware.js +++ b/test/e2e/middleware-custom-matchers/app/middleware.js @@ -57,5 +57,25 @@ export const config = { }, ], }, + { + source: '/missing-match-1', + missing: [ + { + type: 'header', + key: 'hello', + value: '(.*)', + }, + ], + }, + { + source: '/missing-match-2', + missing: [ + { + type: 'query', + key: 'test', + value: 'value', + }, + ], + }, ], } diff --git a/test/e2e/middleware-custom-matchers/test/index.test.ts b/test/e2e/middleware-custom-matchers/test/index.test.ts index 0c33cf5e2f6e34f..57bc8b2d6285f47 100644 --- a/test/e2e/middleware-custom-matchers/test/index.test.ts +++ b/test/e2e/middleware-custom-matchers/test/index.test.ts @@ -21,6 +21,28 @@ describe('Middleware custom matchers', () => { afterAll(() => next.destroy()) const runTests = () => { + it('should match missing header correctly', async () => { + const res = await fetchViaHTTP(next.url, '/missing-match-1') + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/missing-match-1', undefined, { + headers: { + hello: 'world', + }, + }) + expect(res2.headers.get('x-from-middleware')).toBeFalsy() + }) + + it('should match missing query correctly', async () => { + const res = await fetchViaHTTP(next.url, '/missing-match-2') + expect(res.headers.get('x-from-middleware')).toBeDefined() + + const res2 = await fetchViaHTTP(next.url, '/missing-match-2', { + test: 'value', + }) + expect(res2.headers.get('x-from-middleware')).toBeFalsy() + }) + it('should match source path', async () => { const res = await fetchViaHTTP(next.url, '/source-match') expect(res.status).toBe(200) diff --git a/test/integration/custom-routes/next.config.js b/test/integration/custom-routes/next.config.js index 8ffd186d8296ec8..dcdda22d31824ee 100644 --- a/test/integration/custom-routes/next.config.js +++ b/test/integration/custom-routes/next.config.js @@ -204,6 +204,38 @@ module.exports = { ], destination: '/blog-catchall/:post', }, + { + source: '/missing-rewrite-1', + missing: [ + { + type: 'header', + key: 'x-my-header', + value: '(?.*)', + }, + ], + destination: '/with-params', + }, + { + source: '/missing-rewrite-2', + missing: [ + { + type: 'query', + key: 'my-query', + }, + ], + destination: '/with-params', + }, + { + source: '/missing-rewrite-3', + missing: [ + { + type: 'cookie', + key: 'loggedIn', + value: '(?true)', + }, + ], + destination: '/with-params?authorized=1', + }, { source: '/blog/about', destination: '/hello', diff --git a/test/integration/custom-routes/test/index.test.js b/test/integration/custom-routes/test/index.test.js index 3217bd51401570c..1614533ed2e0356 100644 --- a/test/integration/custom-routes/test/index.test.js +++ b/test/integration/custom-routes/test/index.test.js @@ -913,6 +913,52 @@ const runTests = (isDev = false) => { ) }) + it('should match missing header rewrite correctly', async () => { + const res = await fetchViaHTTP(appPort, '/missing-rewrite-1', undefined, { + headers: { + 'x-my-header': 'hello world!!', + }, + }) + + expect(res.status).toBe(404) + + const res2 = await fetchViaHTTP(appPort, '/missing-rewrite-1') + const $2 = cheerio.load(await res2.text()) + + expect(res2.status).toBe(200) + expect(JSON.parse($2('#query').text())).toEqual({}) + }) + + it('should match missing query rewrite correctly', async () => { + const res = await fetchViaHTTP(appPort, '/missing-rewrite-2', { + 'my-query': 'hellooo', + }) + + expect(res.status).toBe(404) + + const res2 = await fetchViaHTTP(appPort, '/missing-rewrite-2') + const $2 = cheerio.load(await res2.text()) + expect(res2.status).toBe(200) + expect(JSON.parse($2('#query').text())).toEqual({}) + }) + + it('should match missing cookie rewrite correctly', async () => { + const res = await fetchViaHTTP(appPort, '/missing-rewrite-3', undefined, { + headers: { + cookie: 'loggedIn=true', + }, + }) + + expect(res.status).toBe(404) + + const res2 = await fetchViaHTTP(appPort, '/missing-rewrite-3') + const $2 = cheerio.load(await res2.text()) + expect(JSON.parse($2('#query').text())).toEqual({ + authorized: '1', + }) + expect(res2.status).toBe(200) + }) + it('should match has header rewrite correctly', async () => { const res = await fetchViaHTTP(appPort, '/has-rewrite-1', undefined, { headers: { @@ -2079,6 +2125,41 @@ const runTests = (isDev = false) => { regex: normalizeRegEx('^\\/has-rewrite-8(?:\\/)?$'), source: '/has-rewrite-8', }, + { + destination: '/with-params', + missing: [ + { + key: 'x-my-header', + type: 'header', + value: '(?.*)', + }, + ], + regex: normalizeRegEx('^\\/missing-rewrite-1(?:\\/)?$'), + source: '/missing-rewrite-1', + }, + { + destination: '/with-params', + missing: [ + { + key: 'my-query', + type: 'query', + }, + ], + regex: normalizeRegEx('^\\/missing-rewrite-2(?:\\/)?$'), + source: '/missing-rewrite-2', + }, + { + destination: '/with-params?authorized=1', + missing: [ + { + key: 'loggedIn', + type: 'cookie', + value: '(?true)', + }, + ], + regex: normalizeRegEx('^\\/missing-rewrite-3(?:\\/)?$'), + source: '/missing-rewrite-3', + }, { destination: '/hello', regex: normalizeRegEx('^\\/blog\\/about(?:\\/)?$'),