Skip to content

Commit fa11ff9

Browse files
ptrthomasclaude
andcommitted
Route matching before file resolution, add TemplateRouteTest
Rewrite handleTemplate() to check templateRoute patterns BEFORE trying to resolve the template file. This avoids exception-based flow control and makes routes a clean "mounting registration" that takes priority. Resolution order: templateRoute match → file resolution → fallbackTemplate → 404 TemplateRouteTest: 7 tests with demo/detail.html template that uses request.pathMatches() to extract path params and render them. Tests: param extraction, multi-value, edit/view mode via route specificity, existing file not overridden, fallback chain. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1401396 commit fa11ff9

File tree

3 files changed

+143
-26
lines changed

3 files changed

+143
-26
lines changed

karate-core/src/main/java/io/karatelabs/http/ServerRequestCycle.java

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,15 @@ private HttpResponse handleTemplate() {
187187
return forbidden("Invalid path");
188188
}
189189

190-
// Convert path to template: /signin -> signin.html, / -> index.html
191-
String templatePath = path.equals("/") ? "index.html" : path.substring(1);
192-
if (!templatePath.endsWith(".html")) {
193-
templatePath = templatePath + ".html";
190+
// 1. Check registered template routes first (e.g., /sessions/{id} → session.html)
191+
String templatePath = resolveTemplateRoute(path);
192+
193+
// 2. If no route matched, convert path to template file: /signin → signin.html
194+
if (templatePath == null) {
195+
templatePath = path.equals("/") ? "index.html" : path.substring(1);
196+
if (!templatePath.endsWith(".html")) {
197+
templatePath = templatePath + ".html";
198+
}
194199
}
195200

196201
try {
@@ -214,35 +219,35 @@ private HttpResponse handleTemplate() {
214219
return response;
215220

216221
} catch (ResourceNotFoundException e) {
217-
// Try template routes first (pattern → template mapping)
218-
String resolvedTemplate = resolveTemplateRoute(path);
219-
if (resolvedTemplate == null) {
220-
// Then try global fallback
221-
resolvedTemplate = config.getFallbackTemplate();
222-
}
223-
if (resolvedTemplate != null) {
224-
try {
225-
context.setTemplateName(resolvedTemplate);
226-
Map<String, Object> vars = context.toVars();
227-
String html = markup.processPath(resolvedTemplate, vars);
228-
if (context.isSwitched()) {
229-
String newTemplate = context.getSwitchTemplate();
230-
context.setTemplateName(newTemplate);
231-
html = markup.processPath(newTemplate, vars);
232-
}
233-
response.setBody(html);
234-
response.setHeader("Content-Type", "text/html; charset=utf-8");
235-
return response;
236-
} catch (Exception fallbackError) {
237-
return notFound(path);
238-
}
222+
// Template file not found — try global fallback
223+
String fallback = config.getFallbackTemplate();
224+
if (fallback != null) {
225+
return renderFallback(fallback, path);
239226
}
240227
return notFound(path);
241228
} catch (Exception e) {
242229
return handleError(e);
243230
}
244231
}
245232

233+
private HttpResponse renderFallback(String templatePath, String originalPath) {
234+
try {
235+
context.setTemplateName(templatePath);
236+
Map<String, Object> vars = context.toVars();
237+
String html = markup.processPath(templatePath, vars);
238+
if (context.isSwitched()) {
239+
String newTemplate = context.getSwitchTemplate();
240+
context.setTemplateName(newTemplate);
241+
html = markup.processPath(newTemplate, vars);
242+
}
243+
response.setBody(html);
244+
response.setHeader("Content-Type", "text/html; charset=utf-8");
245+
return response;
246+
} catch (Exception e) {
247+
return notFound(originalPath);
248+
}
249+
}
250+
246251
private HttpResponse handleError(Exception e) {
247252
response.setStatus(500);
248253

@@ -269,6 +274,7 @@ private HttpResponse handleError(Exception e) {
269274
/**
270275
* Check if the request path matches any configured template routes.
271276
* Uses the same pathMatches() logic as HttpRequest for consistency.
277+
* Called BEFORE file resolution — routes take priority over file lookup.
272278
*/
273279
private String resolveTemplateRoute(String path) {
274280
java.util.LinkedHashMap<String, String> routes = config.getTemplateRoutes();
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package io.karatelabs.http;
2+
3+
import io.karatelabs.markup.RootResourceResolver;
4+
import org.junit.jupiter.api.BeforeAll;
5+
import org.junit.jupiter.api.Test;
6+
7+
import static org.junit.jupiter.api.Assertions.*;
8+
9+
/**
10+
* Tests for templateRoute() with path parameter extraction in templates.
11+
* Verifies that templates can use request.pathMatches() and request.pathParams
12+
* when served via route patterns.
13+
*/
14+
class TemplateRouteTest {
15+
16+
static InMemoryTestHarness harness;
17+
18+
@BeforeAll
19+
static void beforeAll() {
20+
ServerConfig config = new ServerConfig()
21+
.sessionStore(new InMemorySessionStore())
22+
.devMode(true)
23+
.csrfEnabled(false)
24+
.templateRoute("/items/{id}/edit", "detail.html")
25+
.templateRoute("/items/{id}", "detail.html")
26+
.fallbackTemplate("index.html");
27+
28+
RootResourceResolver resolver = new RootResourceResolver("classpath:demo");
29+
ServerRequestHandler handler = new ServerRequestHandler(config, resolver);
30+
harness = new InMemoryTestHarness(handler);
31+
}
32+
33+
@Test
34+
void testPathParamExtractedInTemplate() {
35+
HttpResponse response = harness.get("/items/42");
36+
assertEquals(200, response.getStatus());
37+
String body = response.getBodyString();
38+
// detail.html should extract itemId=42 via request.pathMatches('/items/{id}')
39+
assertTrue(body.contains("Item: 42"), "Template should render path param");
40+
assertTrue(body.contains("id=\"item-id\""));
41+
}
42+
43+
@Test
44+
void testPathParamWithDifferentValues() {
45+
HttpResponse r1 = harness.get("/items/abc-123");
46+
assertTrue(r1.getBodyString().contains("Item: abc-123"));
47+
48+
HttpResponse r2 = harness.get("/items/999");
49+
assertTrue(r2.getBodyString().contains("Item: 999"));
50+
}
51+
52+
@Test
53+
void testMoreSpecificRouteMatchesFirst() {
54+
// /items/42/edit should match the more specific route first
55+
HttpResponse response = harness.get("/items/42/edit");
56+
assertEquals(200, response.getStatus());
57+
String body = response.getBodyString();
58+
assertTrue(body.contains("Item: 42"));
59+
assertTrue(body.contains("id=\"edit-mode\""), "Should be in edit mode");
60+
assertFalse(body.contains("id=\"view-mode\""));
61+
}
62+
63+
@Test
64+
void testLessSpecificRouteIsViewMode() {
65+
HttpResponse response = harness.get("/items/42");
66+
String body = response.getBodyString();
67+
assertTrue(body.contains("id=\"view-mode\""), "Should be in view mode");
68+
assertFalse(body.contains("id=\"edit-mode\""));
69+
}
70+
71+
@Test
72+
void testExistingTemplateNotOverridden() {
73+
// /items matches items.html (existing file) — should NOT use the route
74+
HttpResponse response = harness.get("/items");
75+
assertEquals(200, response.getStatus());
76+
assertTrue(response.getBodyString().contains("Items List"),
77+
"Existing items.html should render, not detail.html via route");
78+
}
79+
80+
@Test
81+
void testFallbackForUnmatchedPaths() {
82+
// /unknown doesn't match any file or route → fallbackTemplate (index.html)
83+
HttpResponse response = harness.get("/unknown/page");
84+
assertEquals(200, response.getStatus());
85+
assertTrue(response.getBodyString().contains("Demo App"));
86+
}
87+
88+
@Test
89+
void testDirectTemplateFileStillWorks() {
90+
// /form matches form.html directly — normal template rendering
91+
HttpResponse response = harness.get("/form");
92+
assertEquals(200, response.getStatus());
93+
assertTrue(response.getBodyString().contains("id=\"main-form\""));
94+
}
95+
96+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script ka:scope="global">
2+
_.editMode = false;
3+
if (request.pathMatches('/items/{id}/edit')) {
4+
_.itemId = request.pathParams.id;
5+
_.editMode = true;
6+
} else if (request.pathMatches('/items/{id}')) {
7+
_.itemId = request.pathParams.id;
8+
}
9+
</script>
10+
<div id="detail-page">
11+
<h1 th:text="'Item: ' + (itemId || 'unknown')">Item</h1>
12+
<span id="item-id" th:text="itemId">id</span>
13+
<span id="edit-mode" th:if="editMode">EDIT</span>
14+
<span id="view-mode" th:unless="editMode">VIEW</span>
15+
</div>

0 commit comments

Comments
 (0)