Initial Commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
CompiledPathPattern,
|
||||
PathMatchResult,
|
||||
} from "./path-matcher.js";
|
||||
export {
|
||||
compilePathPattern,
|
||||
isPathMatch,
|
||||
matchPath,
|
||||
} from "./path-matcher.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
# `path-matcher` — Hono-compatible path-pattern compilation and matching
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Compiles a Hono-style path pattern string into a regular expression
|
||||
|
||||
+ parameter-name list, then matches it against concrete inbound paths
|
||||
and returns the captured params. The plugin pre-compiles every
|
||||
`RouteRule.path` once at init so per-request route lookup is constant
|
||||
time per rule and zero-allocation in the happy path.
|
||||
|
||||
The matcher accepts the same forms Hono's router accepts so route
|
||||
rules written for the framework's router transparently apply to
|
||||
rate-limit rules without translation. The compiled-pattern cache is
|
||||
exposed so consumers wiring `RateLimitService.check(...)` manually
|
||||
(without the middleware) can pre-compile their own rules.
|
||||
|
||||
## Features
|
||||
|
||||
+ Exact-literal, trailing wildcard (`/api/users/*`), inline segment
|
||||
wildcard (`/api/*/avatar`), and named-param (`/api/users/:id`)
|
||||
pattern forms.
|
||||
+ Captured params returned as a frozen `Record<string, string>` so
|
||||
callers can rely on referential stability.
|
||||
+ All metacharacters in the literal portions are escaped before
|
||||
pattern composition — `?`, `.`, `+` in a path do not turn into
|
||||
regex operators by accident.
|
||||
+ Defensive: every `RegExp` construction is wrapped so a future
|
||||
pattern-syntax mistake surfaces as `RateLimitPluginError(INVALID_PATH_PATTERN)`
|
||||
rather than a bare `SyntaxError`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { isPathMatch } from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
isPathMatch("/api/users/:id", "/api/users/123"); // true
|
||||
isPathMatch("/api/users/:id", "/api/posts/123"); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
compilePathPattern,
|
||||
matchPath,
|
||||
} from "@suaveplan/api-plugin-rate-limit";
|
||||
|
||||
const pattern = compilePathPattern("/api/orgs/:org/users/:user");
|
||||
const result = matchPath(pattern, "/api/orgs/acme/users/u42");
|
||||
if (result.matched) {
|
||||
console.log(result.params); // { org: "acme", user: "u42" }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
+ `interface CompiledPathPattern` — `{ source, regex, paramNames }`.
|
||||
+ `interface PathMatchResult` — `{ matched, params }`.
|
||||
+ `function compilePathPattern(source: string): CompiledPathPattern` —
|
||||
compile a pattern. Throws `RateLimitPluginError(INVALID_PATH_PATTERN)`
|
||||
on regex construction failure.
|
||||
+ `function matchPath(pattern: CompiledPathPattern, path: string): PathMatchResult`
|
||||
— match a compiled pattern against a path.
|
||||
+ `function isPathMatch(source: string, path: string): boolean` —
|
||||
compile + match convenience helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
+ Pattern compilation is `O(n)` over the source length; matching is
|
||||
`O(n)` over the path length once the regex is JIT'd by the engine.
|
||||
+ Trailing `/*` matches zero or more path segments (the trailing slash
|
||||
is optional), so `/api/users/*` matches both `/api/users/123` and
|
||||
`/api/users/` — but not `/api/users` (no trailing slash). This
|
||||
mirrors Hono's behavior.
|
||||
+ Inline `*` matches exactly one segment (`[^/]+`) — `/api/*/avatar`
|
||||
matches `/api/u1/avatar` but not `/api/u1/sub/avatar`.
|
||||
+ Named params (`:name`) bind to the captured value; the value is
|
||||
not currently used by the rate-limit service (rules apply to the
|
||||
pattern as a whole) but is preserved so downstream consumers may
|
||||
use it.
|
||||
+ The matcher is universal — zero runtime dependencies.
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
RATE_LIMIT_PLUGIN_CODES,
|
||||
RateLimitPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { compilePathPattern, isPathMatch, matchPath } from "./path-matcher.js";
|
||||
|
||||
describe("compilePathPattern + matchPath", () => {
|
||||
it("matches an exact literal", { timeout: 5000 }, () => {
|
||||
const p = compilePathPattern("/api/users");
|
||||
expect(matchPath(p, "/api/users").matched).toBe(true);
|
||||
expect(matchPath(p, "/api/users/").matched).toBe(false);
|
||||
expect(matchPath(p, "/api/posts").matched).toBe(false);
|
||||
});
|
||||
|
||||
it("matches trailing wildcard sub-paths", { timeout: 5000 }, () => {
|
||||
expect(isPathMatch("/api/users/*", "/api/users/42/profile")).toBe(true);
|
||||
expect(isPathMatch("/api/users/*", "/api/users/")).toBe(true);
|
||||
expect(isPathMatch("/api/users/*", "/api/posts")).toBe(false);
|
||||
});
|
||||
|
||||
it("captures :id named param", { timeout: 5000 }, () => {
|
||||
const p = compilePathPattern("/api/users/:id");
|
||||
const result = matchPath(p, "/api/users/42");
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.params.id).toBe("42");
|
||||
});
|
||||
|
||||
it("captures multiple named params", { timeout: 5000 }, () => {
|
||||
const p = compilePathPattern("/api/orgs/:org/users/:user");
|
||||
const r = matchPath(p, "/api/orgs/acme/users/u42");
|
||||
expect(r.matched).toBe(true);
|
||||
expect(r.params).toEqual({ org: "acme", user: "u42" });
|
||||
});
|
||||
|
||||
it("inline segment wildcard binds exactly one segment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isPathMatch("/api/*/avatar", "/api/u1/avatar")).toBe(true);
|
||||
expect(isPathMatch("/api/*/avatar", "/api/u1/sub/avatar")).toBe(false);
|
||||
});
|
||||
|
||||
it("escapes regex metacharacters in literals", { timeout: 5000 }, () => {
|
||||
expect(isPathMatch("/a.b", "/aXb")).toBe(false);
|
||||
expect(isPathMatch("/a.b", "/a.b")).toBe(true);
|
||||
});
|
||||
|
||||
it("throws INVALID_PATH_PATTERN when regex construction fails", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const originalRegExp = global.RegExp;
|
||||
// Force RegExp to throw — defensive guard cover.
|
||||
(global as { RegExp: unknown }).RegExp = function ThrowingRegExp() {
|
||||
throw new SyntaxError("boom");
|
||||
} as unknown as RegExpConstructor;
|
||||
try {
|
||||
let caught: RateLimitPluginError | undefined;
|
||||
try {
|
||||
compilePathPattern("/api/users");
|
||||
} catch (err) {
|
||||
caught = err as RateLimitPluginError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(RateLimitPluginError);
|
||||
expect(caught?.code).toBe(
|
||||
RATE_LIMIT_PLUGIN_CODES.INVALID_PATH_PATTERN
|
||||
);
|
||||
} finally {
|
||||
(global as { RegExp: unknown }).RegExp = originalRegExp;
|
||||
}
|
||||
});
|
||||
|
||||
it("matchPath returns empty params on no match", { timeout: 5000 }, () => {
|
||||
const p = compilePathPattern("/api/users/:id");
|
||||
const r = matchPath(p, "/nope");
|
||||
expect(r.matched).toBe(false);
|
||||
expect(Object.keys(r.params)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
RATE_LIMIT_PLUGIN_CODES,
|
||||
RateLimitPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* A compiled path pattern ready for matching against inbound request
|
||||
* paths. Created once per route rule at plugin init and reused per
|
||||
* request.
|
||||
*/
|
||||
export interface CompiledPathPattern {
|
||||
readonly source: string;
|
||||
readonly regex: RegExp;
|
||||
readonly paramNames: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of matching a compiled pattern against a path.
|
||||
*/
|
||||
export interface PathMatchResult {
|
||||
readonly matched: boolean;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
const PARAM_RE = /:[A-Za-z_][A-Za-z0-9_]*/g;
|
||||
|
||||
function escapeRegex(literal: string): string {
|
||||
return literal.replace(/[.+?^${}()|[\]\\*]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a Hono-style path pattern into a `CompiledPathPattern`.
|
||||
*
|
||||
* Supported forms:
|
||||
* - exact literal: `"/api/users"` matches only `/api/users`
|
||||
* - trailing wildcard: `"/api/users/*"` matches anything under
|
||||
* `/api/users/`
|
||||
* - inline wildcard: `"/api/*"` matches one path segment
|
||||
* - named param: `"/api/users/:id"` captures `id`
|
||||
*
|
||||
* Throws `RateLimitPluginError(INVALID_PATH_PATTERN)` when the
|
||||
* compiled regex cannot be built — defensive guard so any future
|
||||
* pattern-syntax mistake surfaces via the typed code rather than a
|
||||
* bare `SyntaxError`.
|
||||
*/
|
||||
export function compilePathPattern(source: string): CompiledPathPattern {
|
||||
const paramNames: string[] = [];
|
||||
let cursor = 0;
|
||||
let regexBody = "";
|
||||
PARAM_RE.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = PARAM_RE.exec(source);
|
||||
if (match === null) break;
|
||||
const literal = source.slice(cursor, match.index);
|
||||
regexBody += escapeRegex(literal).replace(/\\\*/g, "[^/]+");
|
||||
const name = match[0].slice(1);
|
||||
paramNames.push(name);
|
||||
regexBody += "([^/]+)";
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
const tail = source.slice(cursor);
|
||||
if (tail.endsWith("/*")) {
|
||||
regexBody += escapeRegex(tail.slice(0, -2)).replace(/\\\*/g, "[^/]+");
|
||||
regexBody += "(?:/.*)?";
|
||||
} else {
|
||||
regexBody += escapeRegex(tail).replace(/\\\*/g, "[^/]+");
|
||||
}
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(`^${regexBody}$`);
|
||||
} catch (err) {
|
||||
throw new RateLimitPluginError(
|
||||
RATE_LIMIT_PLUGIN_CODES.INVALID_PATH_PATTERN,
|
||||
`Failed to compile path pattern '${source}'`,
|
||||
{ cause: err, context: { source, statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
return { source, regex, paramNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a compiled pattern against a concrete path. Returns a
|
||||
* `PathMatchResult` carrying any captured params keyed by the names
|
||||
* declared in the source pattern.
|
||||
*/
|
||||
export function matchPath(
|
||||
pattern: CompiledPathPattern,
|
||||
path: string
|
||||
): PathMatchResult {
|
||||
const match = pattern.regex.exec(path);
|
||||
if (match === null) return { matched: false, params: Object.freeze({}) };
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < pattern.paramNames.length; i++) {
|
||||
const name = pattern.paramNames[i] as string;
|
||||
// `([^/]+)` is non-optional so a successful regex match guarantees
|
||||
// every capture group resolved to a non-empty string.
|
||||
params[name] = match[i + 1] as string;
|
||||
}
|
||||
return { matched: true, params: Object.freeze(params) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper: compile and match in a single call. Suitable for
|
||||
* one-off tests; in production the plugin caches `CompiledPathPattern`
|
||||
* per route rule.
|
||||
*/
|
||||
export function isPathMatch(source: string, path: string): boolean {
|
||||
return matchPath(compilePathPattern(source), path).matched;
|
||||
}
|
||||
Reference in New Issue
Block a user