Skip to content

feat: support parameter patterns #362

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,14 @@ Parameter names must be provided after `:` or `*`, and they must be a valid Java

Parameter names can be wrapped in double quote characters, and this error means you forgot to close the quote character.

### Unbalanced pattern

Parameter patterns must be wrapped in parentheses, and this error means you forgot to close the parentheses.

### Missing pattern

When defining a custom pattern for a parameter (e.g., `:id(<pattern>)`), you must provide a pattern.

### Express <= 4.x

Path-To-RegExp breaks compatibility with Express <= `4.x` in the following ways:
Expand Down
63 changes: 63 additions & 0 deletions src/cases.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ export const PARSER_TESTS: ParserTestSet[] = [
{ type: "text", value: "stuff" },
]),
},
{
path: "/:locale(de|en)",
expected: new TokenData([
{ type: "text", value: "/" },
{ type: "param", name: "locale", pattern: "de|en" },
]),
},
{
path: "/:foo(a|b|c)",
expected: new TokenData([
{ type: "text", value: "/" },
{ type: "param", name: "foo", pattern: "a|b|c" },
]),
},
];

export const STRINGIFY_TESTS: StringifyTestSet[] = [
Expand Down Expand Up @@ -270,6 +284,16 @@ export const COMPILE_TESTS: CompileTestSet[] = [
{ input: { test: "123/xyz" }, expected: "/123/xyz" },
],
},
{
path: "/:locale(de|en)",
tests: [
{ input: undefined, expected: null },
{ input: {}, expected: null },
{ input: { locale: "de" }, expected: "/de" },
{ input: { locale: "en" }, expected: "/en" },
{ input: { locale: "fr" }, expected: "/fr" },
],
},
];

/**
Expand Down Expand Up @@ -376,6 +400,45 @@ export const MATCH_TESTS: MatchTestSet[] = [
],
},

/**
* Patterns
*/
{
path: "/:locale(de|en)",
tests: [
{ input: "/de", expected: { path: "/de", params: { locale: "de" } } },
{ input: "/en", expected: { path: "/en", params: { locale: "en" } } },
{ input: "/fr", expected: false },
{ input: "/", expected: false },
],
},
{
path: "/:foo(\\\\d)",
tests: [
{
input: "/\\d",
expected: { path: "/\\d", params: { foo: "\\d" } },
},
],
},
{
path: "/file.*ext(png|jpg)",
tests: [
{
input: "/file.png",
expected: { path: "/file.png", params: { ext: ["png"] } },
},
{
input: "/file.webp",
expected: false,
},
{
input: "/file.jpg",
expected: { path: "/file.jpg", params: { ext: ["jpg"] } },
},
],
},

/**
* Case-sensitive paths.
*/
Expand Down
26 changes: 25 additions & 1 deletion src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parse, compile, match, stringify } from "./index.js";
import { parse, compile, match, stringify, pathToRegexp } from "./index.js";
import {
PARSER_TESTS,
COMPILE_TESTS,
Expand Down Expand Up @@ -50,6 +50,22 @@ describe("path-to-regexp", () => {
),
);
});

it("should throw on unbalanced pattern", () => {
expect(() => parse("/:foo((bar|sdfsdf)/")).toThrow(
new TypeError(
"Unbalanced pattern at 5: https://git.new/pathToRegexpError",
),
);
});

it("should throw on missing pattern", () => {
expect(() => parse("//:foo()")).toThrow(
new TypeError(
"Missing pattern at 6: https://git.new/pathToRegexpError",
),
);
});
});

describe("compile errors", () => {
Expand Down Expand Up @@ -94,6 +110,14 @@ describe("path-to-regexp", () => {
});
});

describe("pathToRegexp errors", () => {
it("should throw on not allowed characters in pattern", () => {
expect(() => pathToRegexp("/:foo(\\d)")).toThrow(
new TypeError(`Only "|" meta character is allowed in pattern: \\d`),
);
});
});

describe.each(PARSER_TESTS)(
"parse $path with $options",
({ path, options, expected }) => {
Expand Down
97 changes: 92 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,23 @@ const NOOP_VALUE = (value: string) => value;
const ID_START = /^[$_\p{ID_Start}]$/u;
const ID_CONTINUE = /^[$\u200c\u200d\p{ID_Continue}]$/u;
const DEBUG_URL = "https://git.new/pathToRegexpError";

const PATTERN_META_CHARS = new Set([
"*",
"+",
"?",
".",
"^",
"$",
"|",
"\\",
"(",
")",
"[",
"]",
"{",
"}",
]);
const ALLOWED_PATTERN_META_CHARS = new Set(["|"]);
/**
* Encode a string into another string.
*/
Expand Down Expand Up @@ -63,6 +79,7 @@ type TokenType =
| "}"
| "WILDCARD"
| "PARAM"
| "PATTERN"
| "CHAR"
| "ESCAPED"
| "END"
Expand All @@ -89,7 +106,7 @@ const SIMPLE_TOKENS: Record<string, TokenType> = {
"{": "{",
"}": "}",
// Reserved.
"(": "(",
// "(": "(",
")": ")",
"[": "[",
"]": "]",
Expand Down Expand Up @@ -156,6 +173,39 @@ function* lexer(str: string): Generator<LexToken, LexToken> {
return value;
}

function pattern() {
const pos = i++;
let depth = 1;
let pattern = "";

while (i < chars.length && depth > 0) {
const char = chars[i];

if (char === ")") {
depth--;
if (depth === 0) {
i++;
break;
}
} else if (char === "(") {
depth++;
}

pattern += char;
i++;
}

if (depth) {
throw new TypeError(`Unbalanced pattern at ${pos}: ${DEBUG_URL}`);
}

if (!pattern) {
throw new TypeError(`Missing pattern at ${pos}: ${DEBUG_URL}`);
}

return pattern;
}

while (i < chars.length) {
const value = chars[i];
const type = SIMPLE_TOKENS[value];
Expand All @@ -167,6 +217,9 @@ function* lexer(str: string): Generator<LexToken, LexToken> {
} else if (value === ":") {
const value = name();
yield { type: "PARAM", index: i, value };
} else if (value === "(") {
const value = pattern();
yield { type: "PATTERN", index: i, value };
} else if (value === "*") {
const value = name();
yield { type: "WILDCARD", index: i, value };
Expand Down Expand Up @@ -231,6 +284,7 @@ export interface Text {
export interface Parameter {
type: "param";
name: string;
pattern?: string;
}

/**
Expand All @@ -239,6 +293,7 @@ export interface Parameter {
export interface Wildcard {
type: "wildcard";
name: string;
pattern?: string;
}

/**
Expand Down Expand Up @@ -287,18 +342,22 @@ export function parse(str: string, options: ParseOptions = {}): TokenData {

const param = it.tryConsume("PARAM");
if (param) {
const pattern = it.tryConsume("PATTERN");
tokens.push({
type: "param",
name: param,
pattern,
});
continue;
}

const wildcard = it.tryConsume("WILDCARD");
if (wildcard) {
const pattern = it.tryConsume("PATTERN");
tokens.push({
type: "wildcard",
name: wildcard,
pattern,
});
continue;
}
Expand Down Expand Up @@ -578,10 +637,14 @@ function toRegExp(tokens: Flattened[], delimiter: string, keys: Keys) {
throw new TypeError(`Missing text after "${token.name}": ${DEBUG_URL}`);
}

if (token.type === "param") {
result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`;
if (token.pattern && isPatternSafe(token.pattern)) {
result += `(${token.pattern})`;
} else {
result += `([\\s\\S]+)`;
if (token.type === "param") {
result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`;
} else {
result += `([\\s\\S]+)`;
}
}

keys.push(token);
Expand Down Expand Up @@ -646,3 +709,27 @@ function isNextNameSafe(token: Token | undefined) {
if (!token || token.type !== "text") return true;
return !ID_CONTINUE.test(token.value[0]);
}

/**
* Validate the pattern contains only allowed meta characters.
*/
function isPatternSafe(pattern: string) {
let i = 0;
while (i < pattern.length) {
const char = pattern[i];

if (char === "\\" && PATTERN_META_CHARS.has(pattern[i + 1])) {
i += 2;
} else if (PATTERN_META_CHARS.has(char)) {
if (!ALLOWED_PATTERN_META_CHARS.has(char)) {
throw new TypeError(
`Only "${[...ALLOWED_PATTERN_META_CHARS].join(", ")}" meta character is allowed in pattern: ${pattern}`,
);
}
i++;
} else {
i++;
}
}
return true;
}