Skip to content

Middleware and the request pipeline

This guide covers Neton's request pipeline, the built-in security middleware, access logging, trace ID propagation and how to pass your own data along the pipeline. Neton v1 uses an implicit middleware architecture: security, logging and tracing are built into the request flow.


1. Request flow

Every HTTP request goes through these stages:

HTTP request
  -> HttpAdapter (accepts the connection)
    -> RequestEngine (route matching)
      -> Security pipeline (Authenticator, then Guard)
        -> Rate limiting (@RateLimit, when present)
          -> Route handler (your controller method)
            -> Response serialization (JSON / text / redirect)
              -> Access log
                -> HTTP response
StageResponsibilityModule
HttpAdapterAccept the TCP connection, parse HTTP, build the HttpContextneton-http
RequestEngineMatch method + path to a route, build HandlerArgsneton-core
Security pipelineAuthentication and authorizationneton-security
Rate limitingEnforce @RateLimit through the RateLimitGateneton-http / neton-routing
Route handlerRun your controller methodyour code
Response serializationTurn the return value into a response bodyneton-http
Access logEmit the structured request logneton-logging

2. The security pipeline

Security runs automatically before each handler and has two layers.

2.1 Authenticator — who are you

An authenticator extracts and verifies an identity, returning an Identity:

kotlin
interface Authenticator {
    val name: String
    suspend fun authenticate(context: RequestContext): Identity?
}
AuthenticatorNotes
MockAuthenticatorReturns a fixed user; for development and tests
AnonymousAuthenticatorReturns null, permitting anonymous access
JwtAuthenticatorParses a JWT token
SessionAuthenticatorNot included in 1.0registerSessionAuthenticator throws; implement your own Authenticator

2.2 Guard — what may you do

A guard runs after authentication and decides whether the identity may reach the resource:

kotlin
interface Guard {
    suspend fun checkPermission(identity: Identity?, context: RequestContext): Boolean
}
GuardNotes
PublicGuardAlways allows; for public endpoints
DefaultGuardAllows any non-null principal
AdminGuardRequires the admin role
RoleGuardRequires one or all of the given roles

2.3 Pipeline logic

handleRoute(route, call)
  |
  +-- build HttpContext (with traceId)
  +-- build HandlerArgs
  +-- security pre-handle:
  |     |
  |     +-- route annotated @AllowAnonymous?
  |     |     -> skip authentication, principal = null, allow
  |     |
  |     +-- security component not installed?
  |     |     +-- route annotated @RequireAuth -> 500 (misconfiguration: fail fast)
  |     |     +-- otherwise -> allow (open by default)
  |     |
  |     +-- security installed:
  |           +-- principal = authenticator.authenticate(ctx)
  |           +-- principal == null and @RequireAuth -> 401 Unauthorized
  |           +-- attributes["principal"] = principal
  |           +-- guard.authorize(principal, ctx) == false -> 403 Forbidden
  |
  +-- rate limit pre-handle: @RateLimit exceeded -> 429 Too Many Requests
  +-- handler.invoke(httpContext, args)
  +-- response serialization
  +-- access log

Rate limiting runs after authentication deliberately, so that a per-user limit can see who the caller is.

2.4 Security annotations

kotlin
@Controller("/api")
class UserController {

    // public: authentication skipped
    @Get("/public/info")
    @AllowAnonymous
    suspend fun publicInfo(): String = "public"

    // any authenticated user
    @Get("/profile")
    @RequireAuth
    suspend fun profile(@CurrentUser user: Identity): String {
        return "Hello ${user.id}"
    }

    // requires the admin role
    @Get("/admin/dashboard")
    @RolesAllowed("admin")
    suspend fun dashboard(): String = "admin only"
}

2.5 Per-group authentication and authorization

kotlin
security {
    // the default group uses JWT
    registerAuthenticator(JwtAuthenticator(secretKey = "xxx"))
    bindDefaultGuard()

    // the admin group uses a custom guard
    bindGuard("admin", CustomGuard("admin") { principal, ctx ->
        principal?.hasRole("admin") == true
    })
}

3. Rate limiting

@RateLimit is enforced on the live dispatch path, between authentication and the handler:

kotlin
@Post("/login")
@AllowAnonymous
@RateLimit(windowSeconds = 300, maxRequests = 10, scope = RateLimitScope.IP)
suspend fun login(@Body req: LoginRequest): TokenResponse { ... }
ParameterMeaning
windowSecondsWindow length
maxRequestsRequests permitted per window
scopeIP or USER — what the counter is keyed by
keyOptional explicit key
messageThe message returned when the limit is exceeded

Only the FIXED_WINDOW strategy is supported in v1; anything else is rejected at compile time. Exceeding the limit produces HTTP 429.

Upgrading from an earlier build

In releases before 1.0.0-beta1 this annotation parsed and generated metadata but was never enforced — the only enforcement point sat on a code path the HTTP adapter did not call. If you relied on @RateLimit for brute-force protection, you were unprotected. Re-check your limits after upgrading.


4. Access logging

Every request produces a structured access log automatically, written through Logger.info with the fixed message "http.access".

4.1 Fields

FieldMeaning
methodHTTP method
pathRequest path
statusResponse status code
latencyMsDuration in milliseconds
bytesInRequest body size in bytes
bytesOutResponse body size in bytes
traceIdRequest trace ID

4.2 Example

json
{
  "ts": "2026-02-14T10:21:33.123Z",
  "level": "INFO",
  "msg": "http.access",
  "method": "GET",
  "path": "/api/users/1",
  "status": 200,
  "latencyMs": 12,
  "bytesIn": 0,
  "bytesOut": 256,
  "traceId": "req-1707900093-a1b2c3"
}

4.3 Routing access logs to their own file

toml
[[logging.sinks]]
name = "access"
file = "logs/access.log"
levels = "INFO"
route = "http.access"

5. Trace ID propagation

Each request gets a unique traceId that follows it through its whole lifecycle.

5.1 How it is injected

  • EntryHttpAdapter generates the trace ID on arrival and writes it to HttpContext.traceId and LogContext.
  • Propagation — the logger reads it from LogContext, so every record carries it without your code passing anything.
  • ConsistencyHttpContext.traceId and LogContext.traceId are always the same value.

5.2 In practice

kotlin
@Get("/users/{id}")
suspend fun getUser(ctx: HttpContext, @PathVariable id: Long): User? {
    // this record carries the current request's traceId automatically
    log.info("fetching user", mapOf("userId" to id))
    return userService.findById(id)
}
json
{"ts":"...","level":"INFO","msg":"fetching user","userId":1,"traceId":"req-1707900093-a1b2c3"}
{"ts":"...","level":"INFO","msg":"http.access","path":"/users/1","status":200,"traceId":"req-1707900093-a1b2c3"}

One trace ID ties the business log to the access log, which is usually all you need to locate a problem.


6. HttpContext.attributes — per-request data

HttpContext.attributes is a request-scoped MutableMap<String, Any> for passing your own data between pipeline stages.

6.1 Reading and writing

kotlin
// set early in the pipeline, for example inside an Authenticator
ctx.setAttribute("requestTime", System.currentTimeMillis())
ctx.setAttribute("requestId", "req-001")

// read in a controller
val requestTime = ctx.getAttribute("requestTime") as Long
val requestId = ctx.getAttribute("requestId") as String

ctx.removeAttribute("tempData")

6.2 Built-in attributes

KeyTypeSet by
"principal"Identity?The security pipeline, after authentication

6.3 Typical use

kotlin
@Controller
class OrderController {

    @Post("/orders")
    @RequireAuth
    suspend fun createOrder(ctx: HttpContext, @Body order: CreateOrderReq): Order {
        val startTime = ctx.getAttribute("requestTime") as? Long
        val principal = ctx.getAttribute("principal") as? Identity
        return orderService.create(order, principal?.id)
    }
}

7. Config SPI: configuration-time middleware

@NetonConfig provides declarative global configuration — effectively middleware at the configuration layer. It runs during component startup (onStart) and is where you register authenticators, guards, data sources and so on.

kotlin
@NetonConfig("security", order = 0)
class AppSecurityConfig : SecurityConfigurer {
    override fun configure(ctx: NetonContext, target: SecurityBuilder) {
        // a mock authenticator for development
        target.registerMockAuthenticator(
            userId = "test-user",
            roles = listOf("user", "admin")
        )
        target.bindDefaultGuard()
    }
}

The principles behind it:

  • The DSL (security { }) installs components and sets infrastructure parameters, nothing more.
  • Business rules — authentication and permission policy — arrive through @NetonConfig configurers.
  • KSP finds every @NetonConfig class at compile time and generates the registry. No reflection.

8. Looking ahead

v1 builds security, logging and tracing into the request flow, which covers most needs. A formal middleware pipeline API is planned, adding:

  • registration and ordering of custom middleware
  • request and response interception
  • common middleware such as CORS
  • conditional execution per route or method

Until then, custom pipeline logic has three good homes:

  1. HttpContext.attributes — pass data between stages.
  2. @NetonConfig — inject global configuration at startup.
  3. Guard — implement custom authorization.

Neton Framework Documentation