Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ratelimit config from source #1714

Merged
merged 4 commits into from Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/manifest.ts
Expand Up @@ -6,6 +6,25 @@ import type { InvocationMode } from './function.js'
import type { FunctionResult } from './utils/format_result.js'
import type { Route } from './utils/routes.js'

export interface TrafficRulesConfig {
action: {
type: string
config: {
rateLimitConfig: {
algorithm: string
windowSize: number
windowLimit: number
}
aggregate: {
keys: {
type: string
}[]
}
to?: string
}
}
}

interface ManifestFunction {
buildData?: Record<string, unknown>
invocationMode?: InvocationMode
Expand All @@ -20,6 +39,7 @@ interface ManifestFunction {
bundler?: string
generator?: string
priority?: number
trafficRulesConfig?: TrafficRulesConfig
paulo marked this conversation as resolved.
Show resolved Hide resolved
}

export interface Manifest {
Expand Down Expand Up @@ -55,6 +75,7 @@ const formatFunctionForManifest = ({
name,
path,
priority,
trafficRulesConfig,
routes,
runtime,
runtimeVersion,
Expand All @@ -70,6 +91,7 @@ const formatFunctionForManifest = ({
mainFile,
name,
priority,
trafficRulesConfig,
runtimeVersion,
path: resolve(path),
runtime,
Expand Down
30 changes: 30 additions & 0 deletions src/ratelimit.ts
@@ -0,0 +1,30 @@
export enum RatelimitAlgorithm {
SlidingWindow = 'sliding_window',
}

export enum RatelimitAggregator {
Domain = 'domain',
IP = 'ip',
}

export enum RatelimitAction {
Limit = 'rate_limit',
Rewrite = 'rewrite',
}

interface SlidingWindow {
windowLimit: number
windowSize: number
}

export type RewriteActionConfig = SlidingWindow & {
to: string
}

interface RatelimitConfig {
action?: RatelimitAction
aggregateBy?: RatelimitAggregator | RatelimitAggregator[]
algorithm?: RatelimitAlgorithm
}

export type Ratelimit = RatelimitConfig & (SlidingWindow | RewriteActionConfig)
61 changes: 61 additions & 0 deletions src/runtimes/node/in_source_config/index.ts
@@ -1,6 +1,8 @@
import type { ArgumentPlaceholder, Expression, SpreadElement, JSXNamespacedName } from '@babel/types'

import { InvocationMode, INVOCATION_MODE } from '../../../function.js'
import { TrafficRulesConfig } from '../../../manifest.js'
import { RatelimitAction, RatelimitAggregator, RatelimitAlgorithm, RewriteActionConfig } from '../../../ratelimit.js'
import { FunctionBundlingUserError } from '../../../utils/error.js'
import { nonNullable } from '../../../utils/non_nullable.js'
import { getRoutes, Route } from '../../../utils/routes.js'
Expand All @@ -20,6 +22,7 @@ export type ISCValues = {
routes?: Route[]
schedule?: string
methods?: string[]
trafficRulesConfig?: TrafficRulesConfig
}

export interface StaticAnalysisResult extends ISCValues {
Expand Down Expand Up @@ -71,6 +74,60 @@ const normalizeMethods = (input: unknown, name: string): string[] | undefined =>
})
}

/**
* Extracts the `ratelimit` configuration from the exported config.
*/
const getTrafficRulesConfig = (input: unknown, name: string): TrafficRulesConfig | undefined => {
if (typeof input !== 'object' || input === null) {
throw new FunctionBundlingUserError(
`Could not parse ratelimit declaration of function '${name}'. Expecting an object, got ${input}`,
{
functionName: name,
runtime: RUNTIME.JAVASCRIPT,
bundler: NODE_BUNDLER.ESBUILD,
},
)
}

const { windowSize, windowLimit, algorithm, aggregateBy, action } = input as Record<string, unknown>

if (
typeof windowSize !== 'number' ||
typeof windowLimit !== 'number' ||
!Number.isInteger(windowSize) ||
!Number.isInteger(windowLimit)
) {
paulo marked this conversation as resolved.
Show resolved Hide resolved
throw new FunctionBundlingUserError(
`Could not parse ratelimit declaration of function '${name}'. Expecting 'windowSize' and 'limitSize' integer properties, got ${input}`,
{
functionName: name,
runtime: RUNTIME.JAVASCRIPT,
bundler: NODE_BUNDLER.ESBUILD,
},
)
}

const ratelimitAgg = Array.isArray(aggregateBy) ? aggregateBy : [RatelimitAggregator.Domain]
const rewriteConfig = (input as RewriteActionConfig).to ? { to: (input as RewriteActionConfig).to } : undefined
paulo marked this conversation as resolved.
Show resolved Hide resolved

return {
action: {
type: (action as RatelimitAction) || RatelimitAction.Limit,
config: {
...rewriteConfig,
rateLimitConfig: {
windowLimit,
windowSize,
algorithm: (algorithm as RatelimitAlgorithm) || RatelimitAlgorithm.SlidingWindow,
},
aggregate: {
keys: ratelimitAgg.map((agg) => ({ type: agg })),
},
},
},
}
}

/**
* Loads a file at a given path, parses it into an AST, and returns a series of
* data points, such as in-source configuration properties and other metadata.
Expand Down Expand Up @@ -131,6 +188,10 @@ export const parseSource = (source: string, { functionName }: FindISCDeclaration
preferStatic: configExport.preferStatic === true,
})

if (configExport.ratelimit !== undefined) {
result.trafficRulesConfig = getTrafficRulesConfig(configExport.ratelimit, functionName)
}

return result
}

Expand Down
3 changes: 3 additions & 0 deletions src/runtimes/node/index.ts
Expand Up @@ -141,6 +141,8 @@ const zipFunction: ZipFunction = async function ({
invocationMode = INVOCATION_MODE.Background
}

const { trafficRulesConfig } = staticAnalysisResult

const outputModuleFormat =
extname(finalMainFile) === MODULE_FILE_EXTENSION.MJS ? MODULE_FORMAT.ESM : MODULE_FORMAT.COMMONJS
const priority = isInternal ? Priority.GeneratedFunction : Priority.UserFunction
Expand All @@ -160,6 +162,7 @@ const zipFunction: ZipFunction = async function ({
nativeNodeModules,
path: zipPath.path,
priority,
trafficRulesConfig,
runtimeVersion:
runtimeAPIVersion === 2 ? getNodeRuntimeForV2(config.nodeVersion) : getNodeRuntime(config.nodeVersion),
}
Expand Down
2 changes: 2 additions & 0 deletions src/runtimes/runtime.ts
Expand Up @@ -3,6 +3,7 @@ import type { FunctionConfig } from '../config.js'
import type { FeatureFlags } from '../feature_flags.js'
import type { FunctionSource, InvocationMode, SourceFile } from '../function.js'
import type { ModuleFormat } from '../main.js'
import { TrafficRulesConfig } from '../manifest.js'
import { ObjectValues } from '../types/utils.js'
import type { RuntimeCache } from '../utils/cache.js'
import { Logger } from '../utils/logger.js'
Expand Down Expand Up @@ -54,6 +55,7 @@ export interface ZipFunctionResult {
nativeNodeModules?: object
path: string
priority?: number
trafficRulesConfig?: TrafficRulesConfig
runtimeVersion?: string
staticAnalysisResult?: StaticAnalysisResult
entryFilename: string
Expand Down
14 changes: 14 additions & 0 deletions tests/fixtures/ratelimit/netlify/functions/ratelimit.ts
@@ -0,0 +1,14 @@
import { Config, Context } from "@netlify/functions";

export default async (req: Request, context: Context) => {
return new Response(`Something!`);
};

export const config: Config = {
path: "/ratelimited",
ratelimit: {
windowLimit: 60,
windowSize: 50,
aggregateBy: ["ip", "domain"],
}
};
16 changes: 16 additions & 0 deletions tests/fixtures/ratelimit/netlify/functions/rewrite.ts
@@ -0,0 +1,16 @@
import { Config, Context } from "@netlify/functions";

export default async (req: Request, context: Context) => {
return new Response(`Something!`);
};

export const config: Config = {
path: "/rewrite",
ratelimit: {
paulo marked this conversation as resolved.
Show resolved Hide resolved
action: "rewrite",
to: "/rewritten",
windowSize: 20,
windowLimit: 200,
aggregateBy: ["ip", "domain"],
}
};
36 changes: 36 additions & 0 deletions tests/main.test.ts
Expand Up @@ -2898,3 +2898,39 @@ test('Adds a `priority` field to the generated manifest file', async () => {
const generatedFunction1 = manifest.functions.find((fn) => fn.name === 'function_internal')
expect(generatedFunction1.priority).toBe(0)
})

test('Adds a `ratelimit` field to the generated manifest file', async () => {
const { path: tmpDir } = await getTmpDir({ prefix: 'zip-it-test' })
const fixtureName = 'ratelimit'
const manifestPath = join(tmpDir, 'manifest.json')
const path = `${fixtureName}/netlify/functions`

await zipFixture(path, {
length: 2,
opts: { manifest: manifestPath },
})

const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'))

expect(manifest.version).toBe(1)
expect(manifest.system.arch).toBe(arch)
expect(manifest.system.platform).toBe(platform)
expect(manifest.timestamp).toBeTypeOf('number')

const ratelimitFunction = manifest.functions.find((fn) => fn.name === 'ratelimit')
const { type: ratelimitType, config: ratelimitConfig } = ratelimitFunction.trafficRulesConfig.action
expect(ratelimitType).toBe('rate_limit')
expect(ratelimitConfig.rateLimitConfig.windowLimit).toBe(60)
expect(ratelimitConfig.rateLimitConfig.windowSize).toBe(50)
expect(ratelimitConfig.rateLimitConfig.algorithm).toBe('sliding_window')
expect(ratelimitConfig.aggregate.keys).toStrictEqual([{ type: 'ip' }, { type: 'domain' }])

const rewriteFunction = manifest.functions.find((fn) => fn.name === 'rewrite')
const { type: rewriteType, config: rewriteConfig } = rewriteFunction.trafficRulesConfig.action
expect(rewriteType).toBe('rewrite')
expect(rewriteConfig.to).toBe('/rewritten')
expect(rewriteConfig.rateLimitConfig.windowLimit).toBe(200)
expect(rewriteConfig.rateLimitConfig.windowSize).toBe(20)
expect(rewriteConfig.rateLimitConfig.algorithm).toBe('sliding_window')
expect(rewriteConfig.aggregate.keys).toStrictEqual([{ type: 'ip' }, { type: 'domain' }])
})