This document describes the controller architecture of Source Code Portal, including request flow, Spring MVC patterns, and the Thymeleaf view engine integration.
- Overview
- Request Flow
- Spring MVC Architecture
- Legacy Undertow Architecture
- Thymeleaf View Engine
- Controller Patterns
- Migration Guide
Source Code Portal uses Spring MVC as its primary web framework (since Phase 2). The architecture provides:
- Declarative Routing: URL mapping via annotations
- Automatic Serialization: JSON/HTML via content negotiation
- Type Safety: Strongly-typed request/response models
- Testability: Mock-based testing with
@WebMvcTest - Observability: Built-in metrics and tracing
The application has three types of controllers:
- Web Controllers (
@Controller) - Render HTML pages via Thymeleaf - REST Controllers (
@RestController) - Return JSON/XML data - Resource Controllers - Serve static resources (CSS, JS, images, badges)
HTTP Request (e.g., GET /dashboard)
│
├─→ Undertow Server (embedded)
│ │
│ ├─→ Spring DispatcherServlet
│ │
│ ├─→ HandlerMapping (finds controller method)
│ │ │
│ │ ├─→ @Controller / @RestController
│ │ │
│ │ ├─→ Web Page Controllers:
│ │ │ ├─ DashboardWebController (/dashboard, /)
│ │ │ ├─ GroupWebController (/group/{groupId})
│ │ │ ├─ CommitsWebController (/commits/*)
│ │ │ ├─ ContentsWebController (/contents/{org}/{repo}/{branch})
│ │ │ └─ WikiWebController (/wiki/{pageName})
│ │ │
│ │ ├─→ REST/API Controllers:
│ │ │ ├─ PingRestController (/ping)
│ │ │ ├─ HealthRestController (/health, /health/*)
│ │ │ ├─ EchoRestController (/echo)
│ │ │ └─ GitHubWebhookRestController (/github/webhook)
│ │ │
│ │ └─→ Resource Controllers:
│ │ ├─ BadgeResourceController (/badge/*)
│ │ └─ Static Resource Handler (CSS, JS, images)
│ │
│ ├─→ ViewResolver (for @Controller)
│ │ │
│ │ └─→ ThymeleafViewEngineProcessor
│ │ │
│ │ └─→ Render HTML template
│ │
│ └─→ HttpMessageConverter (for @RestController)
│ │
│ └─→ Serialize to JSON (via Jackson)
│
└─→ HTTP Response
1. Request Reception:
- Undertow receives HTTP request
- Routes to Spring DispatcherServlet
2. Handler Mapping:
- DispatcherServlet finds matching controller
- Uses
@RequestMappingannotations - Path variables extracted (e.g.,
{groupId})
3. Controller Execution:
- Spring creates controller instance (or reuses singleton)
- Injects dependencies via constructor
- Invokes handler method
- Returns model and view name (or data object)
4. View Resolution (Web Controllers):
- ThymeleafViewResolver finds template
- ThymeleafViewEngineProcessor renders HTML
- Model data merged with template
5. Response Serialization (REST Controllers):
- Jackson converts object to JSON
- Content-Type header set automatically
- HTTP status code applied
6. Response Transmission:
- Response sent via Undertow
- Metrics recorded
- Connection closed or kept alive
Web controllers render HTML pages using Thymeleaf templates.
File: src/main/java/no/cantara/docsite/controller/spring/DashboardWebController.java
/**
* Spring MVC controller for the main dashboard page.
*
* <p>Displays an overview of all repository groups with their associated
* repositories, build status, and recent activity.
*
* <p><b>Endpoints:</b>
* <ul>
* <li>GET / - Main dashboard (redirects to /dashboard)</li>
* <li>GET /dashboard - Dashboard page with all groups</li>
* </ul>
*
* @since 0.10.17-SNAPSHOT (Phase 2 - Spring Boot migration)
*/
@Controller
public class DashboardWebController {
private static final Logger log = LoggerFactory.getLogger(DashboardWebController.class);
private final RepositoryConfigLoader configLoader;
private final CacheStore cacheStore;
/**
* Constructor injection of dependencies.
*
* @param configLoader Repository configuration loader
* @param cacheStore Cache store for repository data
*/
public DashboardWebController(
RepositoryConfigLoader configLoader,
CacheStore cacheStore) {
this.configLoader = configLoader;
this.cacheStore = cacheStore;
}
/**
* Root endpoint - redirects to dashboard.
*/
@GetMapping("/")
public String root() {
return "redirect:/dashboard";
}
/**
* Main dashboard page.
*
* @param model Spring MVC model for template rendering
* @return Template name (index.html)
*/
@GetMapping("/dashboard")
public String dashboard(Model model) {
log.debug("Rendering dashboard page");
try {
// Load repository groups
List<RepositoryGroup> groups = configLoader.getRepositoryGroups();
// Enrich with cached data
for (RepositoryGroup group : groups) {
enrichGroupWithCachedData(group);
}
// Add to model for template
model.addAttribute("groups", groups);
model.addAttribute("pageTitle", "Dashboard");
return "index"; // Resolves to META-INF/views/index.html
} catch (Exception e) {
log.error("Error loading dashboard", e);
model.addAttribute("error", "Failed to load dashboard");
return "error";
}
}
private void enrichGroupWithCachedData(RepositoryGroup group) {
for (Repository repo : group.getRepositories()) {
// Load cached commit count
Integer commitCount = cacheStore.getCommitCount(repo);
repo.setCommitCount(commitCount);
// Load cached build status
BuildStatus status = cacheStore.getBuildStatus(repo);
repo.setBuildStatus(status);
}
}
}Key Patterns:
@Controller- Marks as web controller (returns view names)@GetMapping- Maps HTTP GET to methodModelparameter - Spring MVC model for template data- Constructor injection - Dependencies injected automatically
- Return view name - Resolved to template file
REST controllers return data (JSON/XML) instead of HTML.
File: src/main/java/no/cantara/docsite/controller/spring/HealthRestController.java
/**
* Spring MVC REST controller for health check endpoints.
*
* <p>Provides health status for the application and its dependencies:
* <ul>
* <li>Overall application health</li>
* <li>GitHub API connectivity and rate limits</li>
* <li>Thread pool status</li>
* </ul>
*
* <p><b>Note:</b> For Spring Boot Actuator health checks, use /actuator/health
*
* @since 0.10.17-SNAPSHOT (Phase 2 - Spring Boot migration)
*/
@RestController
@RequestMapping("/health")
public class HealthRestController {
private static final Logger log = LoggerFactory.getLogger(HealthRestController.class);
private final GitHubCommands gitHubCommands;
private final ExecutorService executorService;
public HealthRestController(
GitHubCommands gitHubCommands,
ExecutorService executorService) {
this.gitHubCommands = gitHubCommands;
this.executorService = executorService;
}
/**
* Overall health check.
*
* @return Health status with timestamp
*/
@GetMapping
public ResponseEntity<Map<String, Object>> health() {
log.debug("Health check requested");
Map<String, Object> health = new LinkedHashMap<>();
health.put("status", "UP");
health.put("timestamp", Instant.now().toString());
health.put("application", "Source Code Portal");
return ResponseEntity.ok(health);
}
/**
* GitHub API health check.
*
* @return GitHub API status and rate limit info
*/
@GetMapping("/github")
public ResponseEntity<Map<String, Object>> githubHealth() {
log.debug("GitHub health check requested");
try {
RateLimit rateLimit = gitHubCommands.getRateLimit();
Map<String, Object> health = new LinkedHashMap<>();
health.put("status", "UP");
health.put("rateLimit", Map.of(
"remaining", rateLimit.remaining(),
"limit", rateLimit.limit(),
"reset", rateLimit.reset()
));
// Warning if rate limit is low
if (rateLimit.remaining() < 100) {
health.put("warning", "Rate limit is low");
}
return ResponseEntity.ok(health);
} catch (Exception e) {
log.error("GitHub health check failed", e);
Map<String, Object> health = Map.of(
"status", "DOWN",
"error", e.getMessage()
);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(health);
}
}
/**
* Thread pool health check.
*
* @return Thread pool statistics
*/
@GetMapping("/threads")
public ResponseEntity<Map<String, Object>> threadHealth() {
log.debug("Thread health check requested");
ThreadPoolExecutor executor = (ThreadPoolExecutor) executorService;
Map<String, Object> health = Map.of(
"status", "UP",
"pool", Map.of(
"coreSize", executor.getCorePoolSize(),
"maxSize", executor.getMaximumPoolSize(),
"activeThreads", executor.getActiveCount(),
"queuedTasks", executor.getQueue().size(),
"completedTasks", executor.getCompletedTaskCount()
)
);
return ResponseEntity.ok(health);
}
}Key Patterns:
@RestController- Combines@Controller+@ResponseBody@RequestMapping("/health")- Base path for all methodsResponseEntity<T>- Typed response with status code controlMap<String, Object>- Generic JSON structure- Automatic JSON serialization via Jackson
Resource controllers serve binary content (images, badges, etc.).
@RestController
@RequestMapping("/badge")
public class BadgeResourceController {
private final BadgeService badgeService;
@GetMapping("/{type}/{org}/{repo}")
public ResponseEntity<byte[]> getBadge(
@PathVariable String type,
@PathVariable String org,
@PathVariable String repo) {
byte[] svg = badgeService.generateBadge(type, org, repo);
return ResponseEntity.ok()
.contentType(MediaType.valueOf("image/svg+xml"))
.body(svg);
}
}Key Patterns:
@PathVariable- Extract URL path segmentsbyte[]return type - Binary dataMediaType- Content-Type headerResponseEntity- Full control over response
HTTP Request
│
├─→ Undertow Server
│ │
│ ├─→ ApplicationController (main router) [@Deprecated]
│ │
│ ├─→ Path matching (manual)
│ │ │
│ │ ├─→ Static content? → StaticContentController
│ │ ├─→ Health check? → HealthController
│ │ ├─→ Webhook? → GithubWebhookController
│ │ └─→ Web page? → WebController
│ │
│ └─→ WebController (web pages) [@Deprecated]
│ │
│ ├─→ Path matching (manual)
│ │ │
│ │ ├─→ /dashboard → DashboardHandler
│ │ ├─→ /group/* → CardHandler
│ │ ├─→ /commits/* → CommitsHandler
│ │ ├─→ /contents/* → ContentsHandler
│ │ └─→ /wiki/* → CantaraWikiHandler
│ │
│ └─→ WebHandler implementation
│ │
│ └─→ ThymeleafViewEngineProcessor
│
└─→ HTTP Response
Before (Undertow):
@Deprecated(since = "0.10.17-SNAPSHOT", forRemoval = true)
public class HealthController implements HttpHandler {
@Override
public void handleRequest(HttpServerExchange exchange) throws Exception {
// Manual thread dispatch
if (exchange.isInIoThread()) {
exchange.dispatch(this);
return;
}
// Manual JSON building
String json = String.format(
"{\"status\": \"UP\", \"timestamp\": \"%s\"}",
Instant.now()
);
// Manual header setting
exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json");
exchange.setStatusCode(200);
// Manual response sending
exchange.getResponseSender().send(json);
}
}Code Characteristics:
- Manual thread dispatch (
isInIoThread()) - String-based JSON construction
- Manual header management
- No type safety
- Hard to test
After (Spring MVC):
@RestController
@RequestMapping("/health")
public class HealthRestController {
@GetMapping
public ResponseEntity<Map<String, Object>> health() {
return ResponseEntity.ok(Map.of(
"status", "UP",
"timestamp", Instant.now().toString()
));
}
}Benefits:
- 70% less code
- Automatic JSON serialization
- Type-safe response
- Easy to test with
@WebMvcTest - Standard Spring MVC patterns
Controller
│
├─→ Returns view name (e.g., "index")
│
└─→ Spring MVC ViewResolver
│
├─→ ThymeleafViewResolver
│ │
│ ├─→ Find template: META-INF/views/{viewName}.html
│ │
│ └─→ ThymeleafViewEngineProcessor
│ │
│ ├─→ Parse template
│ ├─→ Process th:* attributes
│ ├─→ Merge with model data
│ └─→ Render HTML
│
└─→ HTTP Response (text/html)
File: src/main/java/no/cantara/docsite/web/ThymeleafViewEngineProcessor.java
/**
* Thymeleaf view engine for server-side HTML rendering.
*
* <p>Supports both Spring MVC (recommended) and legacy Undertow modes.
*/
@Component
public class ThymeleafViewEngineProcessor {
private final TemplateEngine templateEngine;
public ThymeleafViewEngineProcessor() {
this.templateEngine = createTemplateEngine();
}
private TemplateEngine createTemplateEngine() {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("/META-INF/views/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding("UTF-8");
resolver.setCacheable(true);
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
return engine;
}
/**
* Render template with model data.
*
* @param templateName Template name (without .html extension)
* @param model Model data
* @return Rendered HTML
*/
public String render(String templateName, Map<String, Object> model) {
Context context = new Context();
context.setVariables(model);
return templateEngine.process(templateName, context);
}
/**
* Render template to HTTP response (Spring MVC).
*/
public void renderToResponse(
String templateName,
Map<String, Object> model,
HttpServletResponse response) throws IOException {
String html = render(templateName, model);
response.setContentType("text/html;charset=UTF-8");
response.getWriter().write(html);
}
/**
* Render template to Undertow exchange (legacy mode).
*
* @deprecated Use Spring MVC controllers instead
*/
@Deprecated(since = "0.10.17-SNAPSHOT")
public void renderToExchange(
String templateName,
Map<String, Object> model,
HttpServerExchange exchange) {
String html = render(templateName, model);
exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/html;charset=UTF-8");
exchange.getResponseSender().send(html);
}
}Base Template: src/main/resources/META-INF/views/template.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${pageTitle}">Source Code Portal</title>
<link rel="stylesheet" th:href="@{/css/main.css}">
</head>
<body>
<header th:insert="~{fragments/header :: header}"></header>
<main>
<div th:insert="~{${contentTemplate} :: content}"></div>
</main>
<footer th:insert="~{fragments/footer :: footer}"></footer>
<script th:src="@{/js/main.js}"></script>
</body>
</html>Page Template: src/main/resources/META-INF/views/index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="content">
<h1>Dashboard</h1>
<div th:each="group : ${groups}">
<h2 th:text="${group.displayName}">Group Name</h2>
<ul>
<li th:each="repo : ${group.repositories}">
<a th:href="@{/contents/{org}/{repo}/master(org=${repo.organization}, repo=${repo.name})}"
th:text="${repo.name}">Repository Name</a>
<span th:if="${repo.buildStatus != null}"
th:class="|badge badge-${repo.buildStatus.status}|"
th:text="${repo.buildStatus.status}">Status</span>
</li>
</ul>
</div>
</div>
</body>
</html>Thymeleaf Features Used:
th:text- Set element text contentth:each- Loop over collectionsth:if- Conditional renderingth:href- Dynamic URL generationth:class- Dynamic CSS classesth:insert- Template fragments@{...}- URL expression (context path aware)${...}- Variable expression
Use Case: Render a page with static or simple dynamic content.
@Controller
public class SimpleController {
@GetMapping("/about")
public String about(Model model) {
model.addAttribute("version", "1.0.0");
return "about";
}
}Use Case: Render a page with data from services/cache.
@Controller
public class DataDrivenController {
private final DataService dataService;
public DataDrivenController(DataService dataService) {
this.dataService = dataService;
}
@GetMapping("/data")
public String data(Model model) {
List<Item> items = dataService.getAllItems();
model.addAttribute("items", items);
return "data";
}
}Use Case: Dynamic URL segments (e.g., /group/{id}).
@Controller
public class PathVariableController {
@GetMapping("/group/{groupId}")
public String group(
@PathVariable String groupId,
Model model) {
RepositoryGroup group = findGroup(groupId);
model.addAttribute("group", group);
return "group/card";
}
}Use Case: Return JSON data for AJAX or external clients.
@RestController
@RequestMapping("/api")
public class ApiController {
@GetMapping("/stats")
public ResponseEntity<Statistics> stats() {
Statistics stats = calculateStats();
return ResponseEntity.ok(stats);
}
@PostMapping("/refresh")
public ResponseEntity<Void> refresh() {
triggerRefresh();
return ResponseEntity.accepted().build();
}
}Use Case: Custom error pages.
@Controller
public class ErrorController implements org.springframework.boot.web.servlet.error.ErrorController {
@RequestMapping("/error")
public String handleError(HttpServletRequest request, Model model) {
Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code");
model.addAttribute("statusCode", statusCode);
model.addAttribute("message", getErrorMessage(statusCode));
return "error";
}
}Step-by-Step Process:
-
Identify Handler Type:
- Web page? →
@Controller - JSON API? →
@RestController - Static resource? → Configure in
WebMvcConfiguration
- Web page? →
-
Create Spring Controller:
@Controller // or @RestController public class MyController { // Constructor injection public MyController(Dependencies deps) { ... } }
-
Add Request Mapping:
@GetMapping("/my-path") public String/ResponseEntity handle(...) { ... }
-
Extract Request Data:
- Path variable:
@PathVariable String id - Query param:
@RequestParam String filter - Request body:
@RequestBody MyData data
- Path variable:
-
Return Response:
- View name:
return "template-name"; - Data object:
return ResponseEntity.ok(data);
- View name:
-
Test:
@WebMvcTest(MyController.class) class MyControllerTest { ... }
Before (Undertow):
public class MyHandler implements HttpHandler {
@Override
public void handleRequest(HttpServerExchange exchange) throws Exception {
if (exchange.isInIoThread()) {
exchange.dispatch(this);
return;
}
String pathParam = extractPathParam(exchange);
String json = buildJsonResponse(pathParam);
exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json");
exchange.getResponseSender().send(json);
}
}After (Spring MVC):
@RestController
@RequestMapping("/my-endpoint")
public class MyRestController {
@GetMapping("/{id}")
public ResponseEntity<MyData> handle(@PathVariable String id) {
MyData data = buildResponse(id);
return ResponseEntity.ok(data);
}
}Code Reduction: 60-70%
- Spring Boot Architecture - Application initialization and configuration
- Caching Architecture - Cache integration with controllers
- Package Structure - Controller package organization
- Testing Guide - Controller testing strategies
Next Steps: Read the Caching Architecture document to understand how controllers interact with the cache layer.