Skip to content

Commit 0094a4d

Browse files
🐛 Fix path parameters being replaced by same-named local variables (#79)
1 parent fc616db commit 0094a4d

4 files changed

Lines changed: 48 additions & 14 deletions

File tree

src/core/analyzer.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ function resolveVariables(
2525
path: string,
2626
variables: Map<string, string>,
2727
): string {
28+
// Match sentinel-wrapped names produced by extractPathFromNode for identifiers.
29+
// Using \uE000 (Unicode private use) as sentinel ensures FastAPI path parameters
30+
// like {id} are never substituted — only actual identifier references are resolved.
2831
return path.replace(
29-
/\{([^}]+)\}/g,
30-
(match, name) => variables.get(name) ?? match,
32+
/\uE000([^\uE000]+)\uE000/g,
33+
(_, name) => variables.get(name) ?? `{${name}}`,
3134
)
3235
}
3336

src/core/extractors.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function collectNodesByType(node: Node, type: string, results: Node[]): void {
6363

6464
/**
6565
* Collects string variable assignments from the AST for path resolution.
66-
* Only resolves simple assignments (e.g. `WEBHOOK_PATH = "/webhook"`).
66+
* Handles simple assignments like `WEBHOOK_PATH = "/webhook"`.
6767
*
6868
* Examples:
6969
* WEBHOOK_PATH = "/webhook" -> Map { "WEBHOOK_PATH" => "/webhook" }
@@ -140,11 +140,13 @@ export function extractPathFromNode(node: Node): string {
140140
return extractPathFromNode(left) + extractPathFromNode(right)
141141
}
142142
// For other operators, just return the raw text
143-
return `{${node.text}}`
143+
return `\uE000${node.text}\uE000`
144144
}
145145
default:
146-
// Dynamic values: variable, attribute access, or function call
147-
return `{${node.text}}`
146+
// Dynamic values: variable, attribute access, or function call.
147+
// Use \uE000 (Unicode private use) as sentinel so resolveVariables can
148+
// distinguish these from FastAPI path parameters like {id}.
149+
return `\uE000${node.text}\uE000`
148150
}
149151
}
150152

src/test/core/analyzer.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,35 @@ app.include_router(users.router, prefix=USERS_PREFIX)
182182
assert.strictEqual(result.includeRouters[0].prefix, "/users")
183183
})
184184

185+
test("does not substitute function-local variables into URL path parameters", () => {
186+
const code = `
187+
from fastapi import APIRouter
188+
189+
router = APIRouter()
190+
191+
@router.get("/integrations/{integration}/authorize")
192+
def initiate_oauth_flow(integration: str):
193+
integration = "redis"
194+
pass
195+
196+
@router.get("/integrations/{integration}/callback")
197+
def handle_callback(integration: str):
198+
pass
199+
`
200+
const tree = parse(code)
201+
const result = analyzeTree(tree, "/test/file.py")
202+
203+
assert.strictEqual(result.routes.length, 2)
204+
assert.strictEqual(
205+
result.routes[0].path,
206+
"/integrations/{integration}/authorize",
207+
)
208+
assert.strictEqual(
209+
result.routes[1].path,
210+
"/integrations/{integration}/callback",
211+
)
212+
})
213+
185214
test("sets filePath correctly", () => {
186215
const code = "x = 1"
187216
const tree = parse(code)

src/test/core/extractors.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def handler():
121121
const result = decoratorExtractor(decoratedDefs[0])
122122

123123
assert.ok(result)
124-
assert.strictEqual(result.path, "{BASE_PATH}")
124+
assert.strictEqual(result.path, "\uE000BASE_PATH\uE000")
125125
})
126126

127127
test("handles dynamic path with attribute", () => {
@@ -138,7 +138,7 @@ def handler():
138138
const result = decoratorExtractor(decoratedDefs[0])
139139

140140
assert.ok(result)
141-
assert.strictEqual(result.path, "{settings.API_PREFIX}")
141+
assert.strictEqual(result.path, "\uE000settings.API_PREFIX\uE000")
142142
})
143143

144144
test("handles path concatenation", () => {
@@ -155,7 +155,7 @@ def handler():
155155
const result = decoratorExtractor(decoratedDefs[0])
156156

157157
assert.ok(result)
158-
assert.strictEqual(result.path, "{BASE}/users")
158+
assert.strictEqual(result.path, "\uE000BASE\uE000/users")
159159
})
160160

161161
test("returns null for simple decorator without call", () => {
@@ -405,7 +405,7 @@ def list_users():
405405
const result = routerExtractor(assignments[0])
406406

407407
assert.ok(result)
408-
assert.strictEqual(result.prefix, "{settings.API_PREFIX}")
408+
assert.strictEqual(result.prefix, "\uE000settings.API_PREFIX\uE000")
409409
})
410410

411411
test("returns null for non-router assignment", () => {
@@ -563,7 +563,7 @@ def list_users():
563563
const result = includeRouterExtractor(calls[0])
564564

565565
assert.ok(result)
566-
assert.strictEqual(result.prefix, "{settings.PREFIX}")
566+
assert.strictEqual(result.prefix, "\uE000settings.PREFIX\uE000")
567567
})
568568

569569
test("extracts include_router with tags", () => {
@@ -615,7 +615,7 @@ def list_users():
615615
const result = mountExtractor(calls[0])
616616

617617
assert.ok(result)
618-
assert.strictEqual(result.path, "{settings.STATIC_PATH}")
618+
assert.strictEqual(result.path, "\uE000settings.STATIC_PATH\uE000")
619619
})
620620

621621
test("returns null for non-mount call", () => {
@@ -652,7 +652,7 @@ def list_users():
652652
const tree = parse(code)
653653
const ops = findNodesByType(tree.rootNode, "binary_operator")
654654
const result = extractPathFromNode(ops[0])
655-
assert.strictEqual(result, "{a - b}")
655+
assert.strictEqual(result, "\uE000a - b\uE000")
656656
})
657657
})
658658

@@ -688,7 +688,7 @@ def handler():
688688
const result = decoratorExtractor(decoratedDefs[0])
689689

690690
assert.ok(result)
691-
assert.strictEqual(result.path, "{get_path()}")
691+
assert.strictEqual(result.path, "\uE000get_path()\uE000")
692692
})
693693
})
694694
})

0 commit comments

Comments
 (0)