Multipart upload worked example?

Anyone have a worked example of properly setting up an endpoint that accepts a multipart-data body? I’m consistently getting Invalid value for: body (expected digit got '--0db4...' (line 1, column 1)) My current application is stuck on 0.19, but I’ve spiked a 1.x upgrade with the same result. I’ve also tried with both the Akka-HTTP backend and the HTTP4S backend with the same result.

import io.circe.Json
import sttp.model.{Part, StatusCode}
import sttp.tapir._
import sttp.tapir.json.circe._

import java.io.File
import sttp.tapir.generic.auto._
import io.circe.generic.auto._

case class MultipartData(operations: Json, file: Part[File])

object Endpoints {
  val uploadEndpoint
    : PublicEndpoint[MultipartData, Unit, Json, Any] =
    endpoint.post
      .in("upload")
      .in(multipartBody[MultipartData])
      .out(jsonBody[Json])
}

The requests I’m sending to this endpoint look like this

POST /upload HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=13d6528ff0084c82-7962ea0aa0fb1db2-44e7437e0695a499-ad43feb4ebb453c1
Host: localhost:8080

--13d6528ff0084c82-7962ea0aa0fb1db2-44e7437e0695a499-ad43feb4ebb453c1
Content-Disposition: form-data; name="file"; filename="Test_Upload.csv"
Content-Type: text/csv

... CSV Data ...

--13d6528ff0084c82-7962ea0aa0fb1db2-44e7437e0695a499-ad43feb4ebb453c1
Content-Disposition: form-data; name="operations"

... Some text ...

--13d6528ff0084c82-7962ea0aa0fb1db2-44e7437e0695a499-ad43feb4ebb453c1--

And the response is

HTTP/1.1 400 Bad Request
Content-Length: 75
Content-Type: text/plain; charset=UTF-8
Date: Mon, 06 Feb 2023 18:24:01 GMT
Server: akka-http/10.2.6

Invalid value for: body (expected digit got '--13d6...' (line 1, column 1))

Unfortunately I can’t reproduce this. My test server is as follows:

package sttp.tapir.examples

import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import sttp.model.Part
import sttp.tapir._
import sttp.tapir.generic.auto._
import sttp.tapir.server.akkahttp.AkkaHttpServerInterpreter

import java.io.File
import scala.concurrent.duration.Duration
import scala.concurrent.{Await, Future}

case class MultipartData(operations: String, file: Part[File])

object X extends App {
  val uploadEndpoint: PublicEndpoint[MultipartData, Unit, Unit, Any] =
    endpoint.post
      .in("upload")
      .in(multipartBody[MultipartData])

  //

  implicit val actorSystem: ActorSystem = ActorSystem()
  import actorSystem.dispatcher

  // starting the server
  val routes = AkkaHttpServerInterpreter().toRoute(List(uploadEndpoint.serverLogicPure[Future] { r =>
    println(r)
    Right(())
  }))

  val bindAndCheck = Http().newServerAt("localhost", 8080).bindFlow(routes).map { _ =>
    println("Press any key to exit ...")
    scala.io.StdIn.readLine()
  }

  // cleanup
  Await.result(bindAndCheck.transformWith { r => actorSystem.terminate().transform(_ => r) }, Duration.Inf)
}

I’m sending the request as you gave it, from Idea’s HTTP client:

POST /upload HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=13d6528ff0084c82-7962ea0aa0fb1db2-44e7437e0695a499-ad43feb4ebb453c1
Host: localhost:8080

--13d6528ff0084c82-7962ea0aa0fb1db2-44e7437e0695a499-ad43feb4ebb453c1
Content-Disposition: form-data; name="file"; filename="Test_Upload.csv"
Content-Type: text/csv

... CSV Data ...

--13d6528ff0084c82-7962ea0aa0fb1db2-44e7437e0695a499-ad43feb4ebb453c1
Content-Disposition: form-data; name="operations"

... Some text ...

--13d6528ff0084c82-7962ea0aa0fb1db2-44e7437e0695a499-ad43feb4ebb453c1--

And I’m getting a 200 OK, on the backend the println is as follows:

MultipartData(... Some text ...,Part(file,/var/folders/99/1jlks6pn7gz_7mdlt4n3hkbm0000gn/T/tapir6671885412493013947tmp,TreeMap(filename -> Test_Upload.csv),List(Content-Transfer-Encoding: binary, Content-Type: text/csv)))

So there must be sth different in your setup which causes the issue to surface

1 Like

Thank you so much for taking the time to (attempt to) reproduce. It helped me locate the source of my problem. I thought the issue was in the endpoint definition itself, but it was actually in the route definition where I was stitching all my endpoints together.

The real issue was that my real application has a second endpoint.post with no path qualification, and in my toRoutes call I had that route listed before the POST /upload, which I take to mean when the route matcher is constructed my upload calls were being fed to the unqualified endpoint and never reaching the upload route, at all. Reordering the endpoint list resolved the issue.

I’ll be much more careful about that and use .endpoint("") in the future. :smiley: Thanks again.

Great to know that it’s working :slight_smile:

Adding a test for shadowed endpoints might help detect such situations: Testing — tapir 1.x documentation

Oddly enough, I have a test for shadowed endpoints for exactly that reason, but it didn’t detect this particular overlap. I’ll see if I can figure out why that is.

Hm that’s weird - if you would be able to minimize the issue, please let us know :slight_smile: