Skip to content
This repository has been archived by the owner on May 22, 2024. It is now read-only.

Commit

Permalink
feat: add support for custom routes (#1523)
Browse files Browse the repository at this point in the history
* feat: add support for custom routes

* chore: fix tests

* refactor: restructure code

* refactor: gracefully handle invalid `URLPattern` instances
  • Loading branch information
eduardoboucas committed Aug 11, 2023
1 parent 13f5034 commit 70de542
Show file tree
Hide file tree
Showing 15 changed files with 305 additions and 19 deletions.
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"tmp-promise": "^3.0.2",
"toml": "^3.0.0",
"unixify": "^1.0.0",
"urlpattern-polyfill": "8.0.2",
"yargs": "^17.0.0"
},
"devDependencies": {
Expand Down
35 changes: 23 additions & 12 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { arch, platform } from 'process'
import type { FeatureFlags } from './feature_flags.js'
import type { InvocationMode } from './function.js'
import type { FunctionResult } from './utils/format_result.js'
import type { Route } from './utils/routes.js'

interface ManifestFunction {
invocationMode?: InvocationMode
mainFile: string
name: string
path: string
routes?: Route[]
runtime: string
runtimeVersion?: string
schedule?: string
Expand Down Expand Up @@ -60,20 +62,29 @@ const formatFunctionForManifest = (
mainFile,
name,
path,
routes,
runtime,
runtimeVersion,
schedule,
}: FunctionResult,
featureFlags: FeatureFlags,
): ManifestFunction => ({
bundler,
displayName,
generator,
invocationMode,
mainFile,
name,
runtimeVersion: featureFlags.functions_inherit_build_nodejs_version ? runtimeVersion : undefined,
path: resolve(path),
runtime,
schedule,
})
): ManifestFunction => {
const manifestFunction: ManifestFunction = {
bundler,
displayName,
generator,
invocationMode,
mainFile,
name,
runtimeVersion: featureFlags.functions_inherit_build_nodejs_version ? runtimeVersion : undefined,
path: resolve(path),
runtime,
schedule,
}

if (routes?.length !== 0) {
manifestFunction.routes = routes
}

return manifestFunction
}
3 changes: 3 additions & 0 deletions src/runtimes/node/in_source_config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { InvocationMode, INVOCATION_MODE } from '../../../function.js'
import { FunctionBundlingUserError } from '../../../utils/error.js'
import { Logger } from '../../../utils/logger.js'
import { nonNullable } from '../../../utils/non_nullable.js'
import { getRoutesFromPath, Route } from '../../../utils/routes.js'
import { RUNTIME } from '../../runtime.js'
import { createBindingsMethod } from '../parser/bindings.js'
import { getExports } from '../parser/exports.js'
Expand All @@ -17,6 +18,7 @@ export const IN_SOURCE_CONFIG_MODULE = '@netlify/functions'

export type ISCValues = {
invocationMode?: InvocationMode
routes?: Route[]
runtimeAPIVersion?: number
schedule?: string
}
Expand Down Expand Up @@ -81,6 +83,7 @@ export const findISCDeclarations = (

if (featureFlags.zisi_functions_api_v2 && isV2API) {
const config: ISCValues = {
routes: getRoutesFromPath(configExport.path, functionName),
runtimeAPIVersion: 2,
}

Expand Down
3 changes: 3 additions & 0 deletions src/utils/format_result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { FunctionArchive } from '../function.js'
import { RuntimeName } from '../runtimes/runtime.js'

import { removeUndefined } from './remove_undefined.js'
import type { Route } from './routes.js'

export type FunctionResult = Omit<FunctionArchive, 'runtime'> & {
routes?: Route[]
runtime: RuntimeName
schedule?: string
runtimeAPIVersion?: number
Expand All @@ -14,6 +16,7 @@ export const formatZipResult = (archive: FunctionArchive) => {
const functionResult: FunctionResult = {
...archive,
inSourceConfig: undefined,
routes: archive.inSourceConfig?.routes,
runtime: archive.runtime.name,
schedule: archive.inSourceConfig?.schedule ?? archive?.config?.schedule,
runtimeAPIVersion: archive.inSourceConfig?.runtimeAPIVersion,
Expand Down
62 changes: 62 additions & 0 deletions src/utils/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { RUNTIME } from '../runtimes/runtime.js'

import { FunctionBundlingUserError } from './error.js'
import { ExtendedURLPattern } from './urlpattern.js'

export type Route = { pattern: string } & ({ literal: string } | { expression: string })

// Based on https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API.
const isExpression = (part: string) =>
part.includes('*') || part.startsWith(':') || part.includes('{') || part.includes('[') || part.includes('(')

// Detects whether a path can be represented as a literal or whether it needs
// a regular expression.
const isPathLiteral = (path: string) => {
const parts = path.split('/')

return parts.every((part) => !isExpression(part))
}

export const getRoutesFromPath = (path: unknown, functionName: string): Route[] => {
if (!path) {
return []
}

if (typeof path !== 'string') {
throw new FunctionBundlingUserError(`'path' property must be a string, found '${typeof path}'`, {
functionName,
runtime: RUNTIME.JAVASCRIPT,
})
}

if (!path.startsWith('/')) {
throw new FunctionBundlingUserError(`'path' property must start with a '/'`, {
functionName,
runtime: RUNTIME.JAVASCRIPT,
})
}

if (isPathLiteral(path)) {
return [{ pattern: path, literal: path }]
}

try {
const pattern = new ExtendedURLPattern({ pathname: path })

// Removing the `^` and `$` delimiters because we'll need to modify what's
// between them.
const regex = pattern.regexp.pathname.source.slice(1, -1)

// Wrapping the expression source with `^` and `$`. Also, adding an optional
// trailing slash, so that a declaration of `path: "/foo"` matches requests
// for both `/foo` and `/foo/`.
const normalizedRegex = `^${regex}\\/?$`

return [{ pattern: path, expression: normalizedRegex }]
} catch {
throw new FunctionBundlingUserError(`'${path}' is not a valid path according to the URLPattern specification`, {
functionName,
runtime: RUNTIME.JAVASCRIPT,
})
}
}
7 changes: 7 additions & 0 deletions src/utils/urlpattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { URLPattern } from 'urlpattern-polyfill'

export class ExtendedURLPattern extends URLPattern {
// @ts-expect-error Internal property that the underlying class is using but
// not exposing.
regexp: Record<string, RegExp>
}
1 change: 0 additions & 1 deletion tests/fixtures-esm/v2-api-mjs/package.json

This file was deleted.

10 changes: 10 additions & 0 deletions tests/fixtures-esm/v2-api-with-invalid-path/function.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default async () =>
new Response('<h1>Hello world</h1>', {
headers: {
'content-type': 'text/html',
},
})

export const config = {
path: {},
}
10 changes: 10 additions & 0 deletions tests/fixtures-esm/v2-api-with-path/with-literal.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default async () =>
new Response('<h1>Hello world</h1>', {
headers: {
'content-type': 'text/html',
},
})

export const config = {
path: '/products',
}
10 changes: 10 additions & 0 deletions tests/fixtures-esm/v2-api-with-path/with-named-group.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default async () =>
new Response('<h1>Hello world</h1>', {
headers: {
'content-type': 'text/html',
},
})

export const config = {
path: '/products/:id',
}
10 changes: 10 additions & 0 deletions tests/fixtures-esm/v2-api-with-path/with-regex.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default async () =>
new Response('<h1>Hello world</h1>', {
headers: {
'content-type': 'text/html',
},
})

export const config = {
path: '/numbers/(\\d+)',
}
9 changes: 7 additions & 2 deletions tests/helpers/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,13 @@ export const zipCheckFunctions = async function (

const files = await zipFunctions(srcFolders, tmpDir, opts)

expect(Array.isArray(files)).toBe(true)
expect(files).toHaveLength(length)
if (!Array.isArray(files)) {
throw new TypeError(`Expected 'zipFunctions' to return an array, found ${typeof files}`)
}

if (files.length !== length) {
throw new Error(`Expected 'zipFunctions' to return ${length} items, found ${files.length}`)
}

return { files, tmpDir }
}
Expand Down
89 changes: 85 additions & 4 deletions tests/unit/runtimes/node/in_source_config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe('V2 API', () => {

expect(systemLog).toHaveBeenCalledOnce()
expect(systemLog).toHaveBeenCalledWith('detected v2 function')
expect(isc).toEqual({ runtimeAPIVersion: 2 })
expect(isc).toEqual({ routes: [], runtimeAPIVersion: 2 })
})

test('ESM file with a default export and a `handler` export', () => {
Expand All @@ -123,7 +123,7 @@ describe('V2 API', () => {

const isc = findISCDeclarations(source, options)

expect(isc).toEqual({ runtimeAPIVersion: 2 })
expect(isc).toEqual({ routes: [], runtimeAPIVersion: 2 })
})

test('TypeScript file with a default export and no `handler` export', () => {
Expand All @@ -133,7 +133,7 @@ describe('V2 API', () => {

const isc = findISCDeclarations(source, options)

expect(isc).toEqual({ runtimeAPIVersion: 2 })
expect(isc).toEqual({ routes: [], runtimeAPIVersion: 2 })
})

test('CommonJS file with a default export and a `handler` export', () => {
Expand Down Expand Up @@ -169,6 +169,87 @@ describe('V2 API', () => {

const isc = findISCDeclarations(source, options)

expect(isc).toEqual({ runtimeAPIVersion: 2, schedule: '@daily' })
expect(isc).toEqual({ routes: [], runtimeAPIVersion: 2, schedule: '@daily' })
})

describe('`path` property', () => {
test('Missing a leading slash', () => {
expect.assertions(4)

try {
const source = `export default async () => {
return new Response("Hello!")
}
export const config = {
path: "missing-slash"
}`

findISCDeclarations(source, options)
} catch (error) {
const { customErrorInfo, message } = error

expect(message).toBe(`'path' property must start with a '/'`)
expect(customErrorInfo.type).toBe('functionsBundling')
expect(customErrorInfo.location.functionName).toBe('func1')
expect(customErrorInfo.location.runtime).toBe('js')
}
})

test('With an invalid pattern', () => {
expect.assertions(4)

try {
const source = `export default async () => {
return new Response("Hello!")
}
export const config = {
path: "/products("
}`

findISCDeclarations(source, options)
} catch (error) {
const { customErrorInfo, message } = error

expect(message).toBe(`'/products(' is not a valid path according to the URLPattern specification`)
expect(customErrorInfo.type).toBe('functionsBundling')
expect(customErrorInfo.location.functionName).toBe('func1')
expect(customErrorInfo.location.runtime).toBe('js')
}
})

test('Using a literal pattern', () => {
const source = `export default async () => {
return new Response("Hello!")
}
export const config = {
path: "/products"
}`

const { routes } = findISCDeclarations(source, options)

expect(routes).toEqual([{ pattern: '/products', literal: '/products' }])
})

test('Using a pattern with named groupd', () => {
const source = `export default async () => {
return new Response("Hello!")
}
export const config = {
path: "/store/:category/products/:product-id"
}`

const { routes } = findISCDeclarations(source, options)

expect(routes).toEqual([
{
pattern: '/store/:category/products/:product-id',
expression: '^\\/store(?:\\/([^\\/]+?))\\/products(?:\\/([^\\/]+?))-id\\/?$',
},
])
})
})
})

1 comment on commit 70de542

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⏱ Benchmark results

  • largeDepsEsbuild: 3s
  • largeDepsNft: 9.6s
  • largeDepsZisi: 19.3s

Please sign in to comment.