Every HTTP request in a Hoist application passes through a well-defined pipeline before reaching application code. This pipeline handles instance readiness, authentication, URL routing, role-based access control, and exception handling β ensuring that by the time a controller action executes, the user is authenticated and authorized.
Understanding this flow is essential for debugging request failures, implementing custom authentication, and knowing where to add cross-cutting concerns.
| File | Location | Role |
|---|---|---|
HoistCoreGrailsPlugin |
src/main/groovy/io/xh/hoist/ |
Plugin descriptor β registers filter, initializes Hazelcast |
HoistFilter |
src/main/groovy/io/xh/hoist/ |
Servlet filter β auth gating, instance readiness, top-level exception catching |
UrlMappings |
grails-app/controllers/io/xh/hoist/ |
URL pattern routing |
HoistInterceptor |
grails-app/controllers/io/xh/hoist/ |
Grails interceptor β role annotation checks |
BaseController |
grails-app/controllers/io/xh/hoist/ |
Base controller β JSON rendering, exception handling |
HTTP Request
β
βΌ
βββββββββββββββββββββββββββββββ
β 1. HoistFilter β Servlet filter (highest precedence)
β β’ ensureRunning() β Verify instance is ready to serve
β β’ allowRequest() β Authenticate user (or whitelist)
β β’ catch exceptions β Top-level exception safety net
βββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β 2. UrlMappings β Grails URL routing
β β’ /rest/$controller β REST CRUD endpoints
β β’ /$controller/$action β Standard controller endpoints
β β’ /proxy/$name/$url β Proxy pass-through
βββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β 3. HoistInterceptor β Grails interceptor (matches all)
β β’ Find controller methodβ Resolve action to a Method
β β’ Check @Access* ann. β Evaluate role annotations
β β’ 404 if no method β NotFoundException for bad routes
β β’ 403 if not authorized β NotAuthorizedException for role failures
β β’ catch exceptions β Renders errors directly to response
βββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β 4. Controller Action β Application code
β β’ parseRequestJSON() β Read request body
β β’ Business logic β Process the request
β β’ renderJSON() β Write JSON response
βββββββββββββββββββββββββββββββ
The plugin descriptor bootstraps the entire Hoist server-side framework. Its key responsibilities during startup:
- Configures logging β Creates a
LogbackConfig(app-customizable) before anything else. - Initializes Hazelcast β Calls
ClusterService.initializeHazelcast()to start the Hazelcast instance. This is required even for single-instance deployments, as Hoist's caching and distributed data structures are built on Hazelcast. - Registers
HoistFilterβ As aFilterRegistrationBeanatHIGHEST_PRECEDENCE + 40, ensuring it runs before Grails' built-in filters. - Optionally enables WebSocket β If
hoist.enableWebSocketsistruein application config. - Registers
ExceptionHandlerβ A Spring bean for consistent exception rendering.
On shutdown, it orchestrates cleanup in order: sets instance state to STOPPING, shuts down all
timers, then shuts down Hazelcast.
The outermost entry point for all HTTP requests. Registered as a Jakarta Filter at very high
precedence so it wraps even Grails' internal filters.
void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
try {
clusterService.ensureRunning()
if (authenticationService.allowRequest(httpRequest, httpResponse)) {
chain.doFilter(request, response)
}
} catch (Throwable t) {
Utils.handleException(exception: t, renderTo: httpResponse, logTo: this)
}
}The filter performs three critical functions:
-
Instance readiness β
clusterService.ensureRunning()verifies that the server instance is in aRUNNINGstate. If the instance is still starting up or shutting down, the request is rejected with anInstanceNotAvailableExceptionbefore any other processing. -
Authentication gating β
authenticationService.allowRequest()first checks if the request already has an authenticated user (identityService.findAuthUser(request)) or if the request URI is whitelisted (isWhitelist(request)). Whitelisted endpoints β including/ping,/xh/login,/xh/logout,/xh/ping,/xh/version, and/xh/authConfigβ are allowed through without authentication. If neither condition is met, the filter callscompleteAuthentication()to attempt authentication. IfallowRequest()returnsfalse, the request is halted (e.g., during an OAuth redirect). Seeauthentication.mdfor details. -
Top-level exception handling β Any uncaught exception from the rest of the request pipeline (e.g.,
ensureRunning()failures, Grails internals) is caught here and rendered as a JSON error response. Note thatallowRequest()handles its own exceptions internally (see Exception Handling in the Pipeline).
Defines URL patterns that route requests to controller actions:
| Pattern | Routes To | Purpose |
|---|---|---|
/ |
DefaultController (if app-provided) |
Root redirect |
/$controller/$action?/$id?(.$format)? |
Standard dispatch | General controller endpoints |
/rest/$controller/$id? |
POSTβcreate, GETβread, PUTβupdate, DELETEβdelete |
REST CRUD |
/rest/$controller/bulkUpdate |
bulkUpdate action |
Bulk update endpoint |
/rest/$controller/bulkDelete |
bulkDelete action |
Bulk delete endpoint |
/rest/$controller/lookupData |
lookupData action |
Lookup data for dropdowns |
/proxy/$name/$url** |
ProxyImplController |
API proxy pass-through |
/ping |
XhController.ping |
Legacy health check alias |
404 |
XhController.notFound |
Fallback for unmatched routes |
The REST URL pattern uses HTTP method dispatch β the same URL maps to different actions based on
whether the request is GET, POST, PUT, or DELETE.
A Grails interceptor that matches all requests (matchAll()) and enforces role-based access control
before any controller action executes.
The interceptor's before() method:
- Skips WebSocket handshakes and actuator endpoints β WebSocket requests are identified by
both the
upgrade: websocketheader and the configured WebSocket URI path. These have their own security. - Resolves the controller action β Looks up the
Methodobject for the requested action. If the method doesn't exist, throwsNotFoundException(404). - Evaluates access annotations β Checks the method first, then the class, for one of the
access annotations. The first annotation found is used:
@AccessAllβ Any authenticated user can access.@AccessRequiresRole("ROLE")β User must have the specified role. Takes a singleString.@AccessRequiresAnyRole(["R1", "R2"])β User must have at least one. Takes aString[].@AccessRequiresAllRoles(["R1", "R2"])β User must have all. Takes aString[].@Access(["R1", "R2"])β Deprecated; equivalent to@AccessRequiresAllRoles. Takes aString[].
- Throws
NotAuthorizedException(403) β If the user lacks the required role(s).
Every controller endpoint must have an access annotation β either on the method or the class. If none is found, the interceptor's behavior defaults to blocking the request (no annotation found means no access check passed).
See authorization.md for details on annotations and role management.
Exceptions can be thrown at multiple stages and are handled at four levels:
BaseAuthenticationService.allowRequest()try/catch β Wraps the entire authentication flow. If authentication throws, the exception is logged but a deliberately opaque HTTP status code is returned β no JSON error body is rendered to the unverified client. This is a security measure to avoid leaking information before the user's identity is confirmed.HoistFiltercatch block β Catches anyThrowablefrom the rest of the pipeline. This is the safety net for instance readiness failures (ensureRunning()), Grails internals, and any other unexpected errors that escape lower-level handlers.HoistInterceptor.before()try/catch β The interceptor wraps its own logic in a try/catch block. Exceptions thrown during access checks (e.g.,NotFoundException,NotAuthorizedException) are caught within the interceptor itself and rendered directly to the response viaUtils.handleException(exception: e, ..., renderTo: response). These exceptions do not propagate up toHoistFilter.BaseController.handleException()β Catches exceptions thrown within controller actions and renders them as JSON error responses. The Grails framework calls this automatically.
Handlers 2-4 delegate to Utils.handleException(), which:
- Determines the appropriate HTTP status code (e.g., 401, 403, 404, 500)
- Renders a JSON error response with
{ name, message, cause, isRoutine }structure (falsy values β null, false, empty strings β are filtered out, so e.g.isRoutineonly appears whentrue) - Logs the error (respecting
RoutineExceptionβ expected errors log at DEBUG, not ERROR)
If a controller action lacks an @AccessRequiresRole (or similar) annotation and the controller
class also lacks one, the request will be blocked. Always annotate either the action method or the
controller class.
// β
Do: Annotate the class for a default, override on specific methods
@AccessRequiresRole('APP_USER')
class MyController extends BaseController {
@AccessRequiresRole('APP_ADMIN')
def adminAction() { /* restricted */ }
def regularAction() { /* inherits APP_USER from class */ }
}
// β Don't: Leave endpoints without annotations
class MyController extends BaseController {
def someAction() { /* no annotation β will be blocked */ }
}Requests can be rejected at any stage before reaching the controller. If debugging a request that never hits your controller code, check:
- Is the instance running? (
ensureRunning()rejection) - Is the user authenticated? (
allowRequest()returningfalse) - Is the URL mapping correct? (wrong URL pattern β 404 from
UrlMappings) - Does the user have the right role? (
HoistInterceptorβ 403)
The HoistInterceptor explicitly skips WebSocket upgrade requests (matched by both the
upgrade: websocket header and the configured WebSocket URI path) and /actuator/ endpoints.
WebSocket security is handled separately by the WebSocket framework. Actuator endpoints should be
secured at the deployment/infrastructure level.