Misk Web Actions: Building HTTP Endpoints (and How They Beat @RestController)


Series: Building Production Services with Misk — Part 5 of 24

Time to serve traffic. Misk’s unit for an HTTP endpoint is the web action: a small class implementing WebAction with a single handler method. If you’re coming from Spring’s @RestController, the model is similar in spirit but pointedly different in granularity, and that difference is a deliberate design stance worth understanding. This post writes real actions, taken straight from the exemplar.

What a WebAction is

A misk web action is a class that implements the WebAction marker interface and exposes one HTTP method via an annotation:

@Singleton
class HealthCheckAction @Inject constructor() : WebAction {
  @Get("/health/custom")
  @Unauthenticated
  @ResponseContentType(MediaTypes.APPLICATION_JSON)
  fun check(): HealthResponse = HealthResponse(status = "ok")
}

data class HealthResponse(val status: String)

The shape: an injectable class, a single annotated function, a typed return that Misk serializes for you. The contrast with Spring is the granularity. A Spring @RestController typically bundles many endpoints as methods on one class sharing a constructor’s worth of dependencies. Misk’s convention is one action class per endpoint — each endpoint is its own class, with its own precisely-scoped constructor dependencies, its own access rules, and its own test. An endpoint that needs a PaymentGateway injects exactly that; the endpoint next to it doesn’t carry the dependency just because they’re neighbors. It’s single-responsibility taken to the endpoint level, and it makes actions trivial to test in isolation.

There’s a subtle architectural payoff here too: the misk-actions module has no dependency on the Misk container itself. Actions are just annotated classes. That means your endpoint logic isn’t entangled with the web server, and can be exercised — and even hosted elsewhere — without dragging Jetty along.

A GET action: path, query, and header params

Here’s the exemplar’s real HelloWebAction, which shows the parameter-binding model end to end:

@Singleton
class HelloWebAction @Inject constructor(
  private val tokenGenerator: TokenGenerator,
) : WebAction {
  @Get("/hello/{name}")
  @Unauthenticated
  @ResponseContentType(MediaTypes.APPLICATION_JSON)
  fun hello(
    @PathParam name: String,
    @RequestHeaders headers: Headers,
    @QueryParam nickName: String?,
    @QueryParam greetings: List<String>?,
  ): HelloResponse {
    return HelloResponse(
      greetings?.joinToString(separator = " ") ?: tokenGenerator.generate(),
      nickName?.uppercase() ?: name.uppercase(),
    )
  }
}

data class HelloResponse(val greeting: String, val name: String)

Everything is bound by annotation, not by name-matching magic:

  • @Get("/hello/{name}") — the path, with {name} marking a path variable.
  • @PathParam name: String — binds the {name} segment. Because it’s non-nullable, it’s required; if the path pattern has a variable, you must bind it.
  • @QueryParam nickName: String? — a query param. Nullable means optional (/hello/abc?nickName=def). Misk even binds repeated query params into a List (?greetings=a&greetings=b), which is the kind of thing you’d otherwise hand-parse.
  • @RequestHeaders headers: Headers — the raw OkHttp Headers when you need them.
  • @ResponseContentType(MediaTypes.APPLICATION_JSON) — tells Misk to serialize the returned HelloResponse as JSON (Misk uses Moshi under the hood; more in Part 6).

Note tokenGenerator injected into the constructor — a real dependency, scoped to exactly this endpoint. The handler returns a data class and Misk marshals it. No ResponseEntity, no manual serialization.

Registering the action

Implementing a WebAction isn’t enough; it has to be registered, or it simply won’t be exposed. You register actions by installing a WebActionModule (which, recall from Part 3, is a multibinding contribution under the hood):

class ExemplarWebActionsModule : KAbstractModule() {
  override fun configure() {
    install(WebActionModule.create<HelloWebAction>())
    install(WebActionModule.create<HelloWebPostAction>())
    // ...one line per action
  }
}

Then that module goes onto the MiskApplication list. Forgetting to register an action is the single most common Misk web mistake — the class compiles, the test of the class passes, and the endpoint 404s in production because nothing ever bound it. Misk ships a WebActionRegistrationTester precisely to catch this; we’ll wire it into the testing post.

A POST action with a request body

POSTs add a request body, bound with @RequestBody and deserialized into a typed object. Here’s the exemplar’s HelloWebPostAction:

@Singleton
class HelloWebPostAction @Inject constructor() : WebAction {
  @Post("/hello/{name}")
  @Unauthenticated
  @RequestContentType(MediaTypes.APPLICATION_JSON)
  @ResponseContentType(MediaTypes.APPLICATION_JSON)
  fun hello(@PathParam name: String, @RequestBody body: PostBody): HelloPostResponse {
    return HelloPostResponse(body.greeting, name.uppercase())
  }
}

data class HelloPostResponse(val greeting: String, val name: String)
data class PostBody(val greeting: String)

@RequestContentType declares what the action accepts, @ResponseContentType what it returns, and @RequestBody body: PostBody gets the JSON body deserialized into your data class automatically. Request and response are both just Kotlin data classes; the wire format is the framework’s problem, not yours.

Controlling the response

Returning a data class gives you a 200 with that body serialized. When you need control over status and headers, change the return type to Response<T>:

@Get("/widget/{id}")
@Unauthenticated
@ResponseContentType(MediaTypes.APPLICATION_JSON)
fun get(@PathParam id: String): Response<WidgetBody> = Response(
  statusCode = 201,
  headers = headersOf("X-Trace", id),
  body = WidgetBody(id),
)

The other half of response control is exceptions mapped to status codes. Misk maps a family of exceptions to HTTP responses, so the happy path stays clean and errors are thrown, not hand-assembled:

@Get("/secret/{id}")
fun get(@PathParam id: String): SecretBody {
  if (!allowed(id)) throw UnauthenticatedException()   // → 401
  return repository.find(id) ?: throw BadRequestException("no such id")  // → 400
}

Throw BadRequestException, UnauthenticatedException, and friends from anywhere in the call stack, and Misk turns them into the right status without boilerplate. This pairs naturally with a service/repository layer that throws domain errors — the action doesn’t catch and translate, the framework does.

A note on gRPC

Actions aren’t only HTTP. The same WebAction interface hosts gRPC endpoints (via Wire), and Misk even auto-generates a JSON variant of every protobuf action so you can curl a gRPC service during debugging. That’s a whole post of its own — Part 8 — so we’ll set it aside here beyond noting that “action” is Misk’s unit for both protocols.

Production notes & gotchas

  • Register every action. An unregistered WebAction is invisible — no error, just a 404. Use WebActionRegistrationTester (Part 23) to fail the build when an action isn’t bound.
  • Access is not optional. Every action must declare its access posture — @Unauthenticated or @Authenticated(...). This is deliberate: Misk won’t let you accidentally ship an endpoint with undefined auth. We cover the access model in Part 9; for now, annotate honestly, and don’t slap @Unauthenticated on something that shouldn’t be.
  • One endpoint per action class. Resist the urge to recreate a fat @RestController. The per-action class is the unit Misk’s tooling, access model, and tests assume. Shared logic belongs in an injected collaborator, not in a second handler method.
  • Nullable means optional, non-null means required. Param requiredness is encoded in the Kotlin type. A non-null @QueryParam that’s missing from the request is a client error; model genuinely-optional params as nullable (or with defaults) on purpose.
  • Return types are the contract. A bare data class is a 200; reach for Response<T> only when you need status/headers, and throw mapped exceptions for error cases rather than constructing error responses by hand.

What’s next

You can serve and receive typed objects over HTTP. But what’s actually happening at the wire — how JSON gets marshalled, how to handle file uploads and downloads, how forms work? In Part 6: Request & Response — Marshalling, JSON, Multipart, and Downloads we go a layer down into Moshi, content negotiation, and the less-glamorous-but-essential bits of moving bytes in and out of an action.


Target keywords: misk web action, misk rest endpoint, WebAction.

Comments