Skip to content

Commit 7f0cf73

Browse files
committed
URI Template parser and matcher
1 parent bb28c9b commit 7f0cf73

File tree

2 files changed

+396
-0
lines changed

2 files changed

+396
-0
lines changed

src/shared/uriTemplate.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { UriTemplate } from "./uriTemplate.js";
2+
3+
describe("UriTemplate", () => {
4+
describe("simple string expansion", () => {
5+
it("should expand simple string variables", () => {
6+
const template = new UriTemplate("http://example.com/users/{username}");
7+
expect(template.expand({ username: "fred" })).toBe(
8+
"http://example.com/users/fred",
9+
);
10+
});
11+
12+
it("should handle multiple variables", () => {
13+
const template = new UriTemplate("{x,y}");
14+
expect(template.expand({ x: "1024", y: "768" })).toBe("1024,768");
15+
});
16+
17+
it("should encode reserved characters", () => {
18+
const template = new UriTemplate("{var}");
19+
expect(template.expand({ var: "value with spaces" })).toBe(
20+
"value+with+spaces",
21+
);
22+
});
23+
});
24+
25+
describe("reserved expansion", () => {
26+
it("should not encode reserved characters with + operator", () => {
27+
const template = new UriTemplate("{+path}/here");
28+
expect(template.expand({ path: "/foo/bar" })).toBe("/foo/bar/here");
29+
});
30+
});
31+
32+
describe("fragment expansion", () => {
33+
it("should add # prefix and not encode reserved chars", () => {
34+
const template = new UriTemplate("X{#var}");
35+
expect(template.expand({ var: "/test" })).toBe("X#/test");
36+
});
37+
});
38+
39+
describe("label expansion", () => {
40+
it("should add . prefix", () => {
41+
const template = new UriTemplate("X{.var}");
42+
expect(template.expand({ var: "test" })).toBe("X.test");
43+
});
44+
});
45+
46+
describe("path expansion", () => {
47+
it("should add / prefix", () => {
48+
const template = new UriTemplate("X{/var}");
49+
expect(template.expand({ var: "test" })).toBe("X/test");
50+
});
51+
});
52+
53+
describe("query expansion", () => {
54+
it("should add ? prefix and name=value format", () => {
55+
const template = new UriTemplate("X{?var}");
56+
expect(template.expand({ var: "test" })).toBe("X?var=test");
57+
});
58+
});
59+
60+
describe("form continuation expansion", () => {
61+
it("should add & prefix and name=value format", () => {
62+
const template = new UriTemplate("X{&var}");
63+
expect(template.expand({ var: "test" })).toBe("X&var=test");
64+
});
65+
});
66+
67+
describe("matching", () => {
68+
it("should match simple strings and extract variables", () => {
69+
const template = new UriTemplate("http://example.com/users/{username}");
70+
const match = template.match("http://example.com/users/fred");
71+
expect(match).toEqual({ username: "fred" });
72+
});
73+
74+
it("should match multiple variables", () => {
75+
const template = new UriTemplate("/users/{username}/posts/{postId}");
76+
const match = template.match("/users/fred/posts/123");
77+
expect(match).toEqual({ username: "fred", postId: "123" });
78+
});
79+
80+
it("should return null for non-matching URIs", () => {
81+
const template = new UriTemplate("/users/{username}");
82+
const match = template.match("/posts/123");
83+
expect(match).toBeNull();
84+
});
85+
86+
it("should handle exploded arrays", () => {
87+
const template = new UriTemplate("{/list*}");
88+
const match = template.match("/red,green,blue");
89+
expect(match).toEqual({ list: ["red", "green", "blue"] });
90+
});
91+
});
92+
93+
describe("edge cases", () => {
94+
it("should handle empty variables", () => {
95+
const template = new UriTemplate("{empty}");
96+
expect(template.expand({})).toBe("");
97+
expect(template.expand({ empty: "" })).toBe("");
98+
});
99+
100+
it("should handle undefined variables", () => {
101+
const template = new UriTemplate("{a}{b}{c}");
102+
expect(template.expand({ b: "2" })).toBe("2");
103+
});
104+
105+
it("should handle special characters in variable names", () => {
106+
const template = new UriTemplate("{$var_name}");
107+
expect(template.expand({ "$var_name": "value" })).toBe("value");
108+
});
109+
});
110+
111+
describe("complex patterns", () => {
112+
it("should handle nested path segments", () => {
113+
const template = new UriTemplate("/api/{version}/{resource}/{id}");
114+
expect(template.expand({
115+
version: "v1",
116+
resource: "users",
117+
id: "123"
118+
})).toBe("/api/v1/users/123");
119+
});
120+
121+
it("should handle query parameters with arrays", () => {
122+
const template = new UriTemplate("/search{?tags*}");
123+
expect(template.expand({
124+
tags: ["nodejs", "typescript", "testing"]
125+
})).toBe("/search?tags=nodejs,typescript,testing");
126+
});
127+
128+
it("should handle multiple query parameters", () => {
129+
const template = new UriTemplate("/search{?q,page,limit}");
130+
expect(template.expand({
131+
q: "test",
132+
page: "1",
133+
limit: "10"
134+
})).toBe("/search?q=test&page=1&limit=10");
135+
});
136+
});
137+
138+
describe("matching complex patterns", () => {
139+
it("should match nested path segments", () => {
140+
const template = new UriTemplate("/api/{version}/{resource}/{id}");
141+
const match = template.match("/api/v1/users/123");
142+
expect(match).toEqual({
143+
version: "v1",
144+
resource: "users",
145+
id: "123"
146+
});
147+
});
148+
149+
it("should match query parameters", () => {
150+
const template = new UriTemplate("/search{?q}");
151+
const match = template.match("/search?q=test");
152+
expect(match).toEqual({ q: "test" });
153+
});
154+
155+
it("should match multiple query parameters", () => {
156+
const template = new UriTemplate("/search{?q,page}");
157+
const match = template.match("/search?q=test&page=1");
158+
expect(match).toEqual({ q: "test", page: "1" });
159+
});
160+
161+
it("should handle partial matches correctly", () => {
162+
const template = new UriTemplate("/users/{id}");
163+
expect(template.match("/users/123/extra")).toBeNull();
164+
expect(template.match("/users")).toBeNull();
165+
});
166+
});
167+
});

src/shared/uriTemplate.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Claude-authored implementation of RFC 6570 URI Templates
2+
3+
type Variables = Record<string, string | string[]>;
4+
5+
export class UriTemplate {
6+
private readonly parts: Array<
7+
| string
8+
| { name: string; operator: string; names: string[]; exploded: boolean }
9+
>;
10+
11+
constructor(template: string) {
12+
this.parts = this.parse(template);
13+
}
14+
15+
private parse(
16+
template: string,
17+
): Array<
18+
| string
19+
| { name: string; operator: string; names: string[]; exploded: boolean }
20+
> {
21+
const parts: Array<
22+
| string
23+
| { name: string; operator: string; names: string[]; exploded: boolean }
24+
> = [];
25+
let currentText = "";
26+
let i = 0;
27+
28+
while (i < template.length) {
29+
if (template[i] === "{") {
30+
if (currentText) {
31+
parts.push(currentText);
32+
currentText = "";
33+
}
34+
const end = template.indexOf("}", i);
35+
if (end === -1) throw new Error("Unclosed template expression");
36+
37+
const expr = template.slice(i + 1, end);
38+
const operator = this.getOperator(expr);
39+
const exploded = expr.includes("*");
40+
const names = this.getNames(expr);
41+
const name = names[0];
42+
parts.push({ name, operator, names, exploded });
43+
i = end + 1;
44+
} else {
45+
currentText += template[i];
46+
i++;
47+
}
48+
}
49+
50+
if (currentText) {
51+
parts.push(currentText);
52+
}
53+
54+
return parts;
55+
}
56+
57+
private getOperator(expr: string): string {
58+
const operators = ["+", "#", ".", "/", "?", "&"];
59+
return operators.find((op) => expr.startsWith(op)) || "";
60+
}
61+
62+
private getNames(expr: string): string[] {
63+
const operator = this.getOperator(expr);
64+
return expr
65+
.slice(operator.length)
66+
.split(",")
67+
.map((name) => name.replace("*", "").trim())
68+
.filter((name) => name.length > 0);
69+
}
70+
71+
private encodeValue(value: string, operator: string): string {
72+
if (operator === "+" || operator === "#") {
73+
return encodeURI(value);
74+
}
75+
return encodeURIComponent(value).replace(/%20/g, "+");
76+
}
77+
78+
private expandPart(
79+
part: {
80+
name: string;
81+
operator: string;
82+
names: string[];
83+
exploded: boolean;
84+
},
85+
variables: Variables,
86+
): string {
87+
if (part.operator === "?" || part.operator === "&") {
88+
const pairs = part.names
89+
.map((name) => {
90+
const value = variables[name];
91+
if (value === undefined) return "";
92+
const encoded = Array.isArray(value)
93+
? value.map((v) => this.encodeValue(v, part.operator)).join(",")
94+
: this.encodeValue(value.toString(), part.operator);
95+
return `${name}=${encoded}`;
96+
})
97+
.filter((pair) => pair.length > 0);
98+
99+
if (pairs.length === 0) return "";
100+
const separator = part.operator === "?" ? "?" : "&";
101+
return separator + pairs.join("&");
102+
}
103+
104+
if (part.names.length > 1) {
105+
const values = part.names
106+
.map((name) => variables[name])
107+
.filter((v) => v !== undefined);
108+
if (values.length === 0) return "";
109+
return values.map((v) => (Array.isArray(v) ? v[0] : v)).join(",");
110+
}
111+
112+
const value = variables[part.name];
113+
if (value === undefined) return "";
114+
115+
const values = Array.isArray(value) ? value : [value];
116+
const encoded = values.map((v) => this.encodeValue(v, part.operator));
117+
118+
switch (part.operator) {
119+
case "":
120+
return encoded.join(",");
121+
case "+":
122+
return encoded.join(",");
123+
case "#":
124+
return "#" + encoded.join(",");
125+
case ".":
126+
return "." + encoded.join(".");
127+
case "/":
128+
return "/" + encoded.join("/");
129+
default:
130+
return encoded.join(",");
131+
}
132+
}
133+
134+
expand(variables: Variables): string {
135+
return this.parts
136+
.map((part) => {
137+
if (typeof part === "string") return part;
138+
return this.expandPart(part, variables);
139+
})
140+
.join("");
141+
}
142+
143+
private escapeRegExp(str: string): string {
144+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
145+
}
146+
147+
private partToRegExp(part: {
148+
name: string;
149+
operator: string;
150+
names: string[];
151+
exploded: boolean;
152+
}): Array<{ pattern: string; name: string }> {
153+
const patterns: Array<{ pattern: string; name: string }> = [];
154+
155+
if (part.operator === "?" || part.operator === "&") {
156+
for (let i = 0; i < part.names.length; i++) {
157+
const name = part.names[i];
158+
const prefix = i === 0 ? "\\" + part.operator : "&";
159+
patterns.push({
160+
pattern: prefix + this.escapeRegExp(name) + "=([^&]+)",
161+
name,
162+
});
163+
}
164+
return patterns;
165+
}
166+
167+
let pattern: string;
168+
const name = part.name;
169+
170+
switch (part.operator) {
171+
case "":
172+
pattern = part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)";
173+
break;
174+
case "+":
175+
case "#":
176+
pattern = "(.+)";
177+
break;
178+
case ".":
179+
pattern = "\\.([^/,]+)";
180+
break;
181+
case "/":
182+
pattern = "/" + (part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)");
183+
break;
184+
default:
185+
pattern = "([^/]+)";
186+
}
187+
188+
patterns.push({ pattern, name });
189+
return patterns;
190+
}
191+
192+
match(uri: string): Variables | null {
193+
let pattern = "^";
194+
const names: Array<{ name: string; exploded: boolean }> = [];
195+
196+
for (const part of this.parts) {
197+
if (typeof part === "string") {
198+
pattern += this.escapeRegExp(part);
199+
} else {
200+
const patterns = this.partToRegExp(part);
201+
for (const { pattern: partPattern, name } of patterns) {
202+
pattern += partPattern;
203+
names.push({ name, exploded: part.exploded });
204+
}
205+
}
206+
}
207+
208+
pattern += "$";
209+
const regex = new RegExp(pattern);
210+
const match = uri.match(regex);
211+
212+
if (!match) return null;
213+
214+
const result: Variables = {};
215+
for (let i = 0; i < names.length; i++) {
216+
const { name, exploded } = names[i];
217+
const value = match[i + 1];
218+
const cleanName = name.replace("*", "");
219+
220+
if (exploded && value.includes(",")) {
221+
result[cleanName] = value.split(",");
222+
} else {
223+
result[cleanName] = value;
224+
}
225+
}
226+
227+
return result;
228+
}
229+
}

0 commit comments

Comments
 (0)