Misk JSON, Forms, Multipart & Downloads: Marshalling the Wire


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

Part 5 returned a data class and Misk magically produced JSON. This post is what “magically” actually means — and what happens when the wire isn’t JSON. Real services take form posts, accept file uploads, and stream downloads, and Misk has a clean, annotation-driven answer for each. We’ll cover misk json moshi marshalling, form bodies, misk multipart upload handling, and downloads, all from the exemplar.

JSON with Moshi

Misk’s JSON marshalling is Moshi, Square’s Kotlin-friendly JSON library (via the misk-moshi module). The contract is the one you already saw: declare @ResponseContentType(MediaTypes.APPLICATION_JSON), return a Kotlin data class, and Misk serializes it. For input, declare @RequestContentType(MediaTypes.APPLICATION_JSON) and take a @RequestBody parameter of your data-class type, and Misk deserializes the body into it. No ObjectMapper to configure, no annotations on the data class.

Moshi is a deliberate choice over Jackson, and a good one for Kotlin: it understands non-null types and Kotlin defaults, so a missing field maps to your = default rather than to a surprise null punched into a non-null property. When you need to marshal a type Moshi doesn’t handle out of the box — a value class, a domain-specific string format — you supply a custom Moshi adapter and contribute it through Guice, the same multibinding pattern every Misk feature uses. (misk-moshi is where to look; keep custom adapters small and total.)

The headline: for the overwhelmingly common case, JSON marshalling is zero code beyond the data classes you’d write anyway.

Form-encoded bodies

Not every client sends JSON. HTML forms and plenty of internal tools send application/x-www-form-urlencoded, and Misk binds those straight into a typed object with @FormValue. Here’s the exemplar’s EchoFormAction:

@Singleton
class EchoFormAction @Inject constructor() : WebAction {
  @Post("/hello")
  @Unauthenticated
  @RequestContentType(MediaTypes.APPLICATION_FORM_URLENCODED)
  @ResponseContentType(MediaTypes.APPLICATION_JSON)
  fun echo(@FormValue form: Form): Form {
    return form
  }

  data class Form(
    val string: String,
    val int: Int,
    val nullable: String?,
    val optional: String = "optional",
    @FormField("list-of-strings") val list: List<String>,
  )
}

Everything you’d want is here. Form fields bind by property name; types are coerced (int becomes an Int); a nullable property is optional, a non-null one is required, and a Kotlin default fills in when the field is absent. When the wire name doesn’t match a legal Kotlin identifier — list-of-strings@FormField("list-of-strings") maps it, and repeated fields collect into a List. It’s the same typed-binding philosophy as path and query params, applied to form bodies: you describe the shape with a data class and Misk does the parsing.

Multipart uploads

File uploads use multipart/form-data, which is a stream of parts rather than a single value, so Misk hands you an okhttp3.MultipartReader instead of a bound object. You declare you accept multipart and take the reader as your @RequestBody:

import okhttp3.MultipartReader

class UploadFileAction @Inject constructor() : WebAction {
  @Post("/internal/upload-file")
  @RequestContentType("multipart/*")
  @ResponseContentType(MediaTypes.TEXT_PLAIN_UTF8)
  fun upload(@RequestBody reader: MultipartReader): String {
    var part = reader.nextPart()
    while (part != null) {
      val content = part.body.readUtf8()
      val idempotenceToken = part.headers["X-Idempotence-Token"]
      // ...process this part...
      part = reader.nextPart()
    }
    return "Success"
  }
}

nextPart() walks the parts; each part exposes its body (an Okio BufferedSource, so readUtf8(), or stream it for large files) and its own headers. Per-part headers are genuinely useful — the example pulls an X-Idempotence-Token so a retried upload processes each row exactly once. Because you’re handed a streaming reader rather than the whole payload buffered into memory, large uploads don’t blow your heap, as long as you actually stream each part instead of slurping it.

File downloads

Downloads are just a response with the right body and headers. Return a Response<T> and set Content-Disposition to make the browser save rather than render — the exemplar’s DownloadAFileWebAction:

@Singleton
class DownloadAFileWebAction @Inject constructor() : WebAction {
  @Get("/download/{name}")
  @Unauthenticated
  @ResponseContentType(MediaTypes.TEXT_PLAIN_UTF8)
  fun download(@PathParam name: String): Response<String> {
    return Response(
      body = "Hey $name, I made you this file",
      headers = headersOf(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$name.txt\""),
    )
  }
}

Response<T> is the same escape hatch from Part 5 — it’s how you control headers and status. Content-Disposition: attachment; filename="..." is the standard “download this” signal. For real files you’d return a streaming body type rather than a String, but the shape is identical: pick the body, set the disposition header, choose the content type.

Content types are explicit, on purpose

Notice that every action declares @RequestContentType and @ResponseContentType rather than negotiating implicitly from an Accept header. That’s a deliberate stance: an action states exactly what it consumes and produces, so the contract is visible in the code and a mismatched request fails predictably instead of silently falling back to some default marshaller. It’s more annotations than a framework that guesses, and it’s the right trade for a service whose wire contract you’ll be maintaining for years. The MediaTypes constants (APPLICATION_JSON, APPLICATION_FORM_URLENCODED, TEXT_PLAIN_UTF8, APPLICATION_PROTOBUF) keep those declarations typo-proof.

Production notes & gotchas

  • Stream large uploads and downloads. part.body.readUtf8() is fine for small parts; for real files, stream the Okio source and return a streaming response body, or you’ll buffer entire payloads into the heap.
  • Use per-part headers for idempotency. Multipart parts carry their own headers — an idempotence token per part is the clean way to make retried uploads safe. Don’t push that into query params.
  • Let Moshi’s null/default semantics work for you. Model genuinely-optional fields as nullable or with defaults; a non-null field missing from the body is a deserialization failure, which is usually what you want — fail the bad request, don’t paper over it.
  • Keep custom Moshi adapters total and small. A custom adapter that throws on edge cases turns into 500s at the marshalling layer, below your action’s error handling. Make them handle every value of the type.
  • Content-type mismatches fail fast. Because content types are declared, sending application/json to a multipart/* action is rejected up front — a feature, not a bug. Match the client’s Content-Type to the action’s declaration.

What’s next

You can move bytes in and out of an action in every format that matters. Next we go orthogonal to the request: cross-cutting concerns that wrap every action. In Part 7: Interceptors — Cross-Cutting Concerns we’ll cover NetworkInterceptor vs. ApplicationInterceptor, where each sits in the request pipeline, and how to write your own for logging, metrics, and request shaping.


Target keywords: misk json moshi, misk multipart upload, misk form handling.

Comments