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 2 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
12 changes: 12 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ 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.

### Only '|' is allowed as a special character in patterns

When defining a custom pattern for a parameter (e.g., `:id(<pattern>)`), only the pipe character (`|`) is allowed as a special character inside the pattern.

### 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
37 changes: 37 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,19 @@ export const MATCH_TESTS: MatchTestSet[] = [
],
},

/**
* Parameter 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 },
],
},

/**
* Case-sensitive paths.
*/
Expand Down
24 changes: 24 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ 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 not allowed characters in pattern", () => {
expect(() => parse("/:foo(\\d)")).toThrow(
new TypeError(
`Only "|" is allowed as a special character in patterns at 6: 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
58 changes: 56 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 INVALID_PATTERN_CHARS = "^$.+*?[]{}\\^";

/**
* Encode a string into another string.
Expand Down Expand Up @@ -63,6 +64,7 @@ type TokenType =
| "}"
| "WILDCARD"
| "PARAM"
| "PATTERN"
| "CHAR"
| "ESCAPED"
| "END"
Expand All @@ -89,7 +91,7 @@ const SIMPLE_TOKENS: Record<string, TokenType> = {
"{": "{",
"}": "}",
// Reserved.
"(": "(",
// "(": "(",
")": ")",
"[": "[",
"]": "]",
Expand Down Expand Up @@ -156,6 +158,45 @@ 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 (INVALID_PATTERN_CHARS.includes(char)) {
Copy link
Member

Choose a reason for hiding this comment

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

Unfortunately it’s more complex than this since you can be escaping a meta character to make it a valid character to use, so we actually need to iterate over the string and do some light parsing.

I’d also recommend leaving the parsing out of this section, as it should be part of the logic translating it into a regex as the library does accept raw tokens.

throw new TypeError(
`Only "|" is allowed as a special character in patterns at ${i}: ${DEBUG_URL}`,
);
}

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 +208,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 +275,7 @@ export interface Text {
export interface Parameter {
type: "param";
name: string;
pattern?: string;
}

/**
Expand Down Expand Up @@ -287,9 +332,11 @@ 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;
}
Expand Down Expand Up @@ -579,7 +626,14 @@ function toRegExp(tokens: Flattened[], delimiter: string, keys: Keys) {
}

if (token.type === "param") {
result += `(${negate(delimiter, isSafeSegmentParam ? "" : backtrack)}+)`;
if (token.pattern) {
result += `(${token.pattern})`;
Copy link
Member

Choose a reason for hiding this comment

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

We should do the validation of the pattern here instead. Create a new function for validation so it can be expanded over time without refactoring inside this existing code.

} else {
result += `(${negate(
delimiter,
isSafeSegmentParam ? "" : backtrack,
)}+)`;
}
} else {
result += `([\\s\\S]+)`;
}
Expand Down