Multipart and file uploads

This page covers Mokksy's multipart body-matching APIs: body { form { ... } }, file-part matchers, FormEncoding, and multipart(...) for non-form bodies.

Use these matchers when your client sends more than JSON. File uploads, mixed metadata-plus-binary requests, and strict form-encoding checks all work through the same request DSL.

Match multipart form fields and file uploads

body { form { ... } } matches both application/x-www-form-urlencoded and multipart/form-data by default. Add field(...) matchers for text parts and file(...) matchers for uploaded files.

 1mokksy.post {
 2  path("/upload")
 3  body {
 4    form {
 5      field("description", "Mokksy upload")
 6      file("avatar") {
 7        filename("photo.bin")
 8        contentType("application/octet-stream")
 9        bytes { it?.contentEquals(uploadFile.readBytes()) == true }
10      }
11    }
12  }
13} respondsWith {
14  body = "file-upload-ok"
15}
16
17val response = client.post("/upload") {
18  setBody(
19    MultiPartFormDataContent(
20      formData {
21        append("description", "Mokksy upload")
22        append(
23          "avatar",
24          uploadFile.readBytes(),
25          Headers.build {
26            append(
27              HttpHeaders.ContentDisposition,
28              "form-data; name=\"avatar\"; filename=\"photo.bin\"",
29            )
30            append(HttpHeaders.ContentType, "application/octet-stream")
31          },
32        )
33      },
34    ),
35  )
36}
37
38response.status shouldBe HttpStatusCode.OK
39response.bodyAsText() shouldBe "file-upload-ok"
 1var uploadFile = Files.createTempFile("avatar", ".bin");
 2Files.writeString(uploadFile, "expected");
 3
 4mokksy.post(spec -> spec
 5    .path("/upload")
 6    .body(body -> body.form(form -> form
 7        .field("description", "Mokksy upload")
 8        .file("avatar", file -> file
 9            .filename("photo.bin")
10            .contentType("application/octet-stream")
11            .bytesMatches(bytes -> {
12                try {
13                    org.assertj.core.api.Assertions.assertThat(bytes)
14                        .containsExactly(Files.readAllBytes(uploadFile));
15                    return true;
16                } catch (IOException e) {
17                    throw new UncheckedIOException(e);
18                }
19            })
20        )
21    ))
22).respondsWith(rb -> rb.body("file-upload-ok"));

For file parts, Mokksy supports filename(...), contentType(...), text(...), and bytes(...). Each matcher adds specificity, so a stub that checks field values and file content automatically outranks a looser fallback stub.

Restrict accepted form encoding

Use FormEncoding.MULTIPART or FormEncoding.URL_ENCODED when a stub must reject the other form style. Leave the default AUTO when either encoding is acceptable.

 1mokksy.post {
 2  path("/multipart-only")
 3  body {
 4    form(FormEncoding.MULTIPART) {
 5      field("key", "value")
 6    }
 7  }
 8} respondsWith {
 9  body = "multipart-only-ok"
10}
11
12val multipartResult = client.post("/multipart-only") {
13  setBody(
14    MultiPartFormDataContent(
15      formData { append("key", "value") },
16    ),
17  )
18}
19
20val urlEncodedResult =
21  client.submitForm(
22    url = "/multipart-only",
23    formParameters = parameters { append("key", "value") },
24  )
25
26multipartResult.status shouldBe HttpStatusCode.OK
27urlEncodedResult.status shouldBe HttpStatusCode.NotFound
1mokksy.post(spec -> spec
2    .path("/multipart-only")
3    .body(body -> body.form(FormEncoding.MULTIPART, form -> form
4        .field("key", "value")
5    ))
6).respondsWith(rb -> rb.body("multipart-only-ok"));

Match non-form multipart bodies

Not every multipart request is multipart/form-data. Use body { multipart(...) { ... } } for payloads such as multipart/mixed, where each part has its own content type and semantic role.

 1mokksy.post {
 2  path("/multipart-mixed")
 3  body {
 4    multipart("multipart/mixed") {
 5      boundary("WebAppBoundary")
 6      part("metadata") {
 7        contentType("application/json")
 8        text { it?.contains("Ktor logo") == true }
 9      }
10      part("image") {
11        contentType("image/png")
12        bytes { it?.isNotEmpty() == true }
13      }
14    }
15  }
16} respondsWith {
17  body = "multipart-mixed-ok"
18}
19
20val response = client.post("/multipart-mixed") {
21  setBody(
22    MultiPartFormDataContent(
23      formData {
24        append(
25          "metadata",
26          """{"description":"Ktor logo"}""".encodeToByteArray(),
27          Headers.build {
28            append(HttpHeaders.ContentDisposition, "form-data; name=\"metadata\"")
29            append(HttpHeaders.ContentType, "application/json")
30          },
31        )
32        append(
33          "image",
34          "png-data".encodeToByteArray(),
35          Headers.build {
36            append(HttpHeaders.ContentDisposition, "form-data; name=\"image\"")
37            append(HttpHeaders.ContentType, "image/png")
38          },
39        )
40      },
41      boundary = "WebAppBoundary",
42      contentType =
43        ContentType.MultiPart.Mixed.withParameter(
44          "boundary",
45          "WebAppBoundary",
46        ),
47    ),
48  )
49}
50
51response.status shouldBe HttpStatusCode.OK
52response.bodyAsText() shouldBe "multipart-mixed-ok"
 1mokksy.post(spec -> spec
 2    .path("/multipart-mixed")
 3    .body(body -> body.multipart("multipart/mixed", multipart -> multipart
 4        .boundary("WebAppBoundary")
 5        .part("metadata", part -> part
 6            .contentType("application/json")
 7            .textMatches(text -> text != null && text.contains("Ktor logo"))
 8        )
 9        .part("image", part -> part
10            .contentType("image/png")
11            .bytesMatches(bytes -> bytes != null && bytes.length > 0)
12        )
13    ))
14).respondsWith(rb -> rb.body("multipart-mixed-ok"));