URI Template parser and matcher
This commit is contained in:
parent
bb28c9b519
commit
7f0cf7305d
2 changed files with 396 additions and 0 deletions
167
src/shared/uriTemplate.test.ts
Normal file
167
src/shared/uriTemplate.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { UriTemplate } from "./uriTemplate.js";
|
||||
|
||||
describe("UriTemplate", () => {
|
||||
describe("simple string expansion", () => {
|
||||
it("should expand simple string variables", () => {
|
||||
const template = new UriTemplate("http://example.com/users/{username}");
|
||||
expect(template.expand({ username: "fred" })).toBe(
|
||||
"http://example.com/users/fred",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle multiple variables", () => {
|
||||
const template = new UriTemplate("{x,y}");
|
||||
expect(template.expand({ x: "1024", y: "768" })).toBe("1024,768");
|
||||
});
|
||||
|
||||
it("should encode reserved characters", () => {
|
||||
const template = new UriTemplate("{var}");
|
||||
expect(template.expand({ var: "value with spaces" })).toBe(
|
||||
"value+with+spaces",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reserved expansion", () => {
|
||||
it("should not encode reserved characters with + operator", () => {
|
||||
const template = new UriTemplate("{+path}/here");
|
||||
expect(template.expand({ path: "/foo/bar" })).toBe("/foo/bar/here");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fragment expansion", () => {
|
||||
it("should add # prefix and not encode reserved chars", () => {
|
||||
const template = new UriTemplate("X{#var}");
|
||||
expect(template.expand({ var: "/test" })).toBe("X#/test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("label expansion", () => {
|
||||
it("should add . prefix", () => {
|
||||
const template = new UriTemplate("X{.var}");
|
||||
expect(template.expand({ var: "test" })).toBe("X.test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("path expansion", () => {
|
||||
it("should add / prefix", () => {
|
||||
const template = new UriTemplate("X{/var}");
|
||||
expect(template.expand({ var: "test" })).toBe("X/test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("query expansion", () => {
|
||||
it("should add ? prefix and name=value format", () => {
|
||||
const template = new UriTemplate("X{?var}");
|
||||
expect(template.expand({ var: "test" })).toBe("X?var=test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("form continuation expansion", () => {
|
||||
it("should add & prefix and name=value format", () => {
|
||||
const template = new UriTemplate("X{&var}");
|
||||
expect(template.expand({ var: "test" })).toBe("X&var=test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matching", () => {
|
||||
it("should match simple strings and extract variables", () => {
|
||||
const template = new UriTemplate("http://example.com/users/{username}");
|
||||
const match = template.match("http://example.com/users/fred");
|
||||
expect(match).toEqual({ username: "fred" });
|
||||
});
|
||||
|
||||
it("should match multiple variables", () => {
|
||||
const template = new UriTemplate("/users/{username}/posts/{postId}");
|
||||
const match = template.match("/users/fred/posts/123");
|
||||
expect(match).toEqual({ username: "fred", postId: "123" });
|
||||
});
|
||||
|
||||
it("should return null for non-matching URIs", () => {
|
||||
const template = new UriTemplate("/users/{username}");
|
||||
const match = template.match("/posts/123");
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle exploded arrays", () => {
|
||||
const template = new UriTemplate("{/list*}");
|
||||
const match = template.match("/red,green,blue");
|
||||
expect(match).toEqual({ list: ["red", "green", "blue"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty variables", () => {
|
||||
const template = new UriTemplate("{empty}");
|
||||
expect(template.expand({})).toBe("");
|
||||
expect(template.expand({ empty: "" })).toBe("");
|
||||
});
|
||||
|
||||
it("should handle undefined variables", () => {
|
||||
const template = new UriTemplate("{a}{b}{c}");
|
||||
expect(template.expand({ b: "2" })).toBe("2");
|
||||
});
|
||||
|
||||
it("should handle special characters in variable names", () => {
|
||||
const template = new UriTemplate("{$var_name}");
|
||||
expect(template.expand({ "$var_name": "value" })).toBe("value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex patterns", () => {
|
||||
it("should handle nested path segments", () => {
|
||||
const template = new UriTemplate("/api/{version}/{resource}/{id}");
|
||||
expect(template.expand({
|
||||
version: "v1",
|
||||
resource: "users",
|
||||
id: "123"
|
||||
})).toBe("/api/v1/users/123");
|
||||
});
|
||||
|
||||
it("should handle query parameters with arrays", () => {
|
||||
const template = new UriTemplate("/search{?tags*}");
|
||||
expect(template.expand({
|
||||
tags: ["nodejs", "typescript", "testing"]
|
||||
})).toBe("/search?tags=nodejs,typescript,testing");
|
||||
});
|
||||
|
||||
it("should handle multiple query parameters", () => {
|
||||
const template = new UriTemplate("/search{?q,page,limit}");
|
||||
expect(template.expand({
|
||||
q: "test",
|
||||
page: "1",
|
||||
limit: "10"
|
||||
})).toBe("/search?q=test&page=1&limit=10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matching complex patterns", () => {
|
||||
it("should match nested path segments", () => {
|
||||
const template = new UriTemplate("/api/{version}/{resource}/{id}");
|
||||
const match = template.match("/api/v1/users/123");
|
||||
expect(match).toEqual({
|
||||
version: "v1",
|
||||
resource: "users",
|
||||
id: "123"
|
||||
});
|
||||
});
|
||||
|
||||
it("should match query parameters", () => {
|
||||
const template = new UriTemplate("/search{?q}");
|
||||
const match = template.match("/search?q=test");
|
||||
expect(match).toEqual({ q: "test" });
|
||||
});
|
||||
|
||||
it("should match multiple query parameters", () => {
|
||||
const template = new UriTemplate("/search{?q,page}");
|
||||
const match = template.match("/search?q=test&page=1");
|
||||
expect(match).toEqual({ q: "test", page: "1" });
|
||||
});
|
||||
|
||||
it("should handle partial matches correctly", () => {
|
||||
const template = new UriTemplate("/users/{id}");
|
||||
expect(template.match("/users/123/extra")).toBeNull();
|
||||
expect(template.match("/users")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
229
src/shared/uriTemplate.ts
Normal file
229
src/shared/uriTemplate.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// Claude-authored implementation of RFC 6570 URI Templates
|
||||
|
||||
type Variables = Record<string, string | string[]>;
|
||||
|
||||
export class UriTemplate {
|
||||
private readonly parts: Array<
|
||||
| string
|
||||
| { name: string; operator: string; names: string[]; exploded: boolean }
|
||||
>;
|
||||
|
||||
constructor(template: string) {
|
||||
this.parts = this.parse(template);
|
||||
}
|
||||
|
||||
private parse(
|
||||
template: string,
|
||||
): Array<
|
||||
| string
|
||||
| { name: string; operator: string; names: string[]; exploded: boolean }
|
||||
> {
|
||||
const parts: Array<
|
||||
| string
|
||||
| { name: string; operator: string; names: string[]; exploded: boolean }
|
||||
> = [];
|
||||
let currentText = "";
|
||||
let i = 0;
|
||||
|
||||
while (i < template.length) {
|
||||
if (template[i] === "{") {
|
||||
if (currentText) {
|
||||
parts.push(currentText);
|
||||
currentText = "";
|
||||
}
|
||||
const end = template.indexOf("}", i);
|
||||
if (end === -1) throw new Error("Unclosed template expression");
|
||||
|
||||
const expr = template.slice(i + 1, end);
|
||||
const operator = this.getOperator(expr);
|
||||
const exploded = expr.includes("*");
|
||||
const names = this.getNames(expr);
|
||||
const name = names[0];
|
||||
parts.push({ name, operator, names, exploded });
|
||||
i = end + 1;
|
||||
} else {
|
||||
currentText += template[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
parts.push(currentText);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
private getOperator(expr: string): string {
|
||||
const operators = ["+", "#", ".", "/", "?", "&"];
|
||||
return operators.find((op) => expr.startsWith(op)) || "";
|
||||
}
|
||||
|
||||
private getNames(expr: string): string[] {
|
||||
const operator = this.getOperator(expr);
|
||||
return expr
|
||||
.slice(operator.length)
|
||||
.split(",")
|
||||
.map((name) => name.replace("*", "").trim())
|
||||
.filter((name) => name.length > 0);
|
||||
}
|
||||
|
||||
private encodeValue(value: string, operator: string): string {
|
||||
if (operator === "+" || operator === "#") {
|
||||
return encodeURI(value);
|
||||
}
|
||||
return encodeURIComponent(value).replace(/%20/g, "+");
|
||||
}
|
||||
|
||||
private expandPart(
|
||||
part: {
|
||||
name: string;
|
||||
operator: string;
|
||||
names: string[];
|
||||
exploded: boolean;
|
||||
},
|
||||
variables: Variables,
|
||||
): string {
|
||||
if (part.operator === "?" || part.operator === "&") {
|
||||
const pairs = part.names
|
||||
.map((name) => {
|
||||
const value = variables[name];
|
||||
if (value === undefined) return "";
|
||||
const encoded = Array.isArray(value)
|
||||
? value.map((v) => this.encodeValue(v, part.operator)).join(",")
|
||||
: this.encodeValue(value.toString(), part.operator);
|
||||
return `${name}=${encoded}`;
|
||||
})
|
||||
.filter((pair) => pair.length > 0);
|
||||
|
||||
if (pairs.length === 0) return "";
|
||||
const separator = part.operator === "?" ? "?" : "&";
|
||||
return separator + pairs.join("&");
|
||||
}
|
||||
|
||||
if (part.names.length > 1) {
|
||||
const values = part.names
|
||||
.map((name) => variables[name])
|
||||
.filter((v) => v !== undefined);
|
||||
if (values.length === 0) return "";
|
||||
return values.map((v) => (Array.isArray(v) ? v[0] : v)).join(",");
|
||||
}
|
||||
|
||||
const value = variables[part.name];
|
||||
if (value === undefined) return "";
|
||||
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
const encoded = values.map((v) => this.encodeValue(v, part.operator));
|
||||
|
||||
switch (part.operator) {
|
||||
case "":
|
||||
return encoded.join(",");
|
||||
case "+":
|
||||
return encoded.join(",");
|
||||
case "#":
|
||||
return "#" + encoded.join(",");
|
||||
case ".":
|
||||
return "." + encoded.join(".");
|
||||
case "/":
|
||||
return "/" + encoded.join("/");
|
||||
default:
|
||||
return encoded.join(",");
|
||||
}
|
||||
}
|
||||
|
||||
expand(variables: Variables): string {
|
||||
return this.parts
|
||||
.map((part) => {
|
||||
if (typeof part === "string") return part;
|
||||
return this.expandPart(part, variables);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
private escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
private partToRegExp(part: {
|
||||
name: string;
|
||||
operator: string;
|
||||
names: string[];
|
||||
exploded: boolean;
|
||||
}): Array<{ pattern: string; name: string }> {
|
||||
const patterns: Array<{ pattern: string; name: string }> = [];
|
||||
|
||||
if (part.operator === "?" || part.operator === "&") {
|
||||
for (let i = 0; i < part.names.length; i++) {
|
||||
const name = part.names[i];
|
||||
const prefix = i === 0 ? "\\" + part.operator : "&";
|
||||
patterns.push({
|
||||
pattern: prefix + this.escapeRegExp(name) + "=([^&]+)",
|
||||
name,
|
||||
});
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
let pattern: string;
|
||||
const name = part.name;
|
||||
|
||||
switch (part.operator) {
|
||||
case "":
|
||||
pattern = part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)";
|
||||
break;
|
||||
case "+":
|
||||
case "#":
|
||||
pattern = "(.+)";
|
||||
break;
|
||||
case ".":
|
||||
pattern = "\\.([^/,]+)";
|
||||
break;
|
||||
case "/":
|
||||
pattern = "/" + (part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)");
|
||||
break;
|
||||
default:
|
||||
pattern = "([^/]+)";
|
||||
}
|
||||
|
||||
patterns.push({ pattern, name });
|
||||
return patterns;
|
||||
}
|
||||
|
||||
match(uri: string): Variables | null {
|
||||
let pattern = "^";
|
||||
const names: Array<{ name: string; exploded: boolean }> = [];
|
||||
|
||||
for (const part of this.parts) {
|
||||
if (typeof part === "string") {
|
||||
pattern += this.escapeRegExp(part);
|
||||
} else {
|
||||
const patterns = this.partToRegExp(part);
|
||||
for (const { pattern: partPattern, name } of patterns) {
|
||||
pattern += partPattern;
|
||||
names.push({ name, exploded: part.exploded });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pattern += "$";
|
||||
const regex = new RegExp(pattern);
|
||||
const match = uri.match(regex);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const result: Variables = {};
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const { name, exploded } = names[i];
|
||||
const value = match[i + 1];
|
||||
const cleanName = name.replace("*", "");
|
||||
|
||||
if (exploded && value.includes(",")) {
|
||||
result[cleanName] = value.split(",");
|
||||
} else {
|
||||
result[cleanName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue