@@ -8,13 +8,13 @@ import com.fasterxml.jackson.databind.JsonNode
88import com.fasterxml.jackson.databind.json.JsonMapper
99import com.fasterxml.jackson.databind.node.JsonNodeType
1010import com.withorb.api.core.MultipartField
11+ import com.withorb.api.core.toImmutable
1112import com.withorb.api.errors.OrbInvalidDataException
13+ import java.io.ByteArrayInputStream
1214import java.io.InputStream
1315import java.io.OutputStream
16+ import java.util.UUID
1417import kotlin.jvm.optionals.getOrNull
15- import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder
16- import org.apache.hc.core5.http.ContentType
17- import org.apache.hc.core5.http.HttpEntity
1818
1919@JvmSynthetic
2020internal inline fun <reified T > json (jsonMapper : JsonMapper , value : T ): HttpRequestBody =
@@ -37,69 +37,207 @@ internal fun multipartFormData(
3737 jsonMapper : JsonMapper ,
3838 fields : Map <String , MultipartField <* >>,
3939): HttpRequestBody =
40- object : HttpRequestBody {
41- private val entity: HttpEntity by lazy {
42- MultipartEntityBuilder .create()
43- .apply {
44- fields.forEach { (name, field) ->
45- val knownValue = field.value.asKnown().getOrNull()
46- val parts =
47- if (knownValue is InputStream ) {
48- // Read directly from the `InputStream` instead of reading it all
49- // into memory due to the `jsonMapper` serialization below.
50- sequenceOf(name to knownValue)
51- } else {
52- val node = jsonMapper.valueToTree<JsonNode >(field.value)
53- serializePart(name, node)
40+ MultipartBody .Builder ()
41+ .apply {
42+ fields.forEach { (name, field) ->
43+ val knownValue = field.value.asKnown().getOrNull()
44+ val parts =
45+ if (knownValue is InputStream ) {
46+ // Read directly from the `InputStream` instead of reading it all
47+ // into memory due to the `jsonMapper` serialization below.
48+ sequenceOf(name to knownValue)
49+ } else {
50+ val node = jsonMapper.valueToTree<JsonNode >(field.value)
51+ serializePart(name, node)
52+ }
53+
54+ parts.forEach { (name, bytes) ->
55+ val partBody =
56+ if (bytes is ByteArrayInputStream ) {
57+ val byteArray = bytes.readBytes()
58+
59+ object : HttpRequestBody {
60+
61+ override fun writeTo (outputStream : OutputStream ) {
62+ outputStream.write(byteArray)
63+ }
64+
65+ override fun contentType (): String = field.contentType
66+
67+ override fun contentLength (): Long = byteArray.size.toLong()
68+
69+ override fun repeatable (): Boolean = true
70+
71+ override fun close () {}
5472 }
73+ } else {
74+ object : HttpRequestBody {
75+
76+ override fun writeTo (outputStream : OutputStream ) {
77+ bytes.copyTo(outputStream)
78+ }
79+
80+ override fun contentType (): String = field.contentType
5581
56- parts.forEach { (name, bytes) ->
57- addBinaryBody(
58- name,
59- bytes,
60- ContentType .parseLenient(field.contentType),
61- field.filename().getOrNull(),
62- )
82+ override fun contentLength (): Long = - 1L
83+
84+ override fun repeatable (): Boolean = false
85+
86+ override fun close () = bytes.close()
87+ }
6388 }
64- }
89+
90+ addPart(
91+ MultipartBody .Part .create(
92+ name,
93+ field.filename().getOrNull(),
94+ field.contentType,
95+ partBody,
96+ )
97+ )
6598 }
66- .build()
99+ }
67100 }
101+ .build()
102+
103+ private fun serializePart (name : String , node : JsonNode ): Sequence <Pair <String , InputStream >> =
104+ when (node.nodeType) {
105+ JsonNodeType .MISSING ,
106+ JsonNodeType .NULL -> emptySequence()
107+ JsonNodeType .BINARY -> sequenceOf(name to node.binaryValue().inputStream())
108+ JsonNodeType .STRING -> sequenceOf(name to node.textValue().byteInputStream())
109+ JsonNodeType .BOOLEAN -> sequenceOf(name to node.booleanValue().toString().byteInputStream())
110+ JsonNodeType .NUMBER -> sequenceOf(name to node.numberValue().toString().byteInputStream())
111+ JsonNodeType .ARRAY ->
112+ node.elements().asSequence().flatMap { element -> serializePart(" $name []" , element) }
113+ JsonNodeType .OBJECT ->
114+ node.fields().asSequence().flatMap { (key, value) ->
115+ serializePart(" $name [$key ]" , value)
116+ }
117+ JsonNodeType .POJO ,
118+ null -> throw OrbInvalidDataException (" Unexpected JsonNode type: ${node.nodeType} " )
119+ }
68120
69- private fun serializePart (
70- name : String ,
71- node : JsonNode ,
72- ): Sequence <Pair <String , InputStream >> =
73- when (node.nodeType) {
74- JsonNodeType .MISSING ,
75- JsonNodeType .NULL -> emptySequence()
76- JsonNodeType .BINARY -> sequenceOf(name to node.binaryValue().inputStream())
77- JsonNodeType .STRING -> sequenceOf(name to node.textValue().inputStream())
78- JsonNodeType .BOOLEAN ->
79- sequenceOf(name to node.booleanValue().toString().inputStream())
80- JsonNodeType .NUMBER ->
81- sequenceOf(name to node.numberValue().toString().inputStream())
82- JsonNodeType .ARRAY ->
83- node.elements().asSequence().flatMap { element ->
84- serializePart(" $name []" , element)
85- }
86- JsonNodeType .OBJECT ->
87- node.fields().asSequence().flatMap { (key, value) ->
88- serializePart(" $name [$key ]" , value)
89- }
90- JsonNodeType .POJO ,
91- null -> throw OrbInvalidDataException (" Unexpected JsonNode type: ${node.nodeType} " )
121+ private class MultipartBody
122+ private constructor (private val boundary: String , private val parts: List <Part >) : HttpRequestBody {
123+ private val boundaryBytes: ByteArray = boundary.toByteArray()
124+ private val contentType = " multipart/form-data; boundary=$boundary "
125+
126+ // This must remain in sync with `contentLength`.
127+ override fun writeTo (outputStream : OutputStream ) {
128+ parts.forEach { part ->
129+ outputStream.write(DASHDASH )
130+ outputStream.write(boundaryBytes)
131+ outputStream.write(CRLF )
132+
133+ outputStream.write(CONTENT_DISPOSITION )
134+ outputStream.write(part.contentDisposition.toByteArray())
135+ outputStream.write(CRLF )
136+
137+ outputStream.write(CONTENT_TYPE )
138+ outputStream.write(part.contentType.toByteArray())
139+ outputStream.write(CRLF )
140+
141+ outputStream.write(CRLF )
142+ part.body.writeTo(outputStream)
143+ outputStream.write(CRLF )
144+ }
145+
146+ outputStream.write(DASHDASH )
147+ outputStream.write(boundaryBytes)
148+ outputStream.write(DASHDASH )
149+ outputStream.write(CRLF )
150+ }
151+
152+ override fun contentType (): String = contentType
153+
154+ // This must remain in sync with `writeTo`.
155+ override fun contentLength (): Long {
156+ var byteCount = 0L
157+
158+ parts.forEach { part ->
159+ val contentLength = part.body.contentLength()
160+ if (contentLength == - 1L ) {
161+ return - 1L
92162 }
93163
94- private fun String.inputStream (): InputStream = toByteArray().inputStream()
164+ byteCount + =
165+ DASHDASH .size +
166+ boundaryBytes.size +
167+ CRLF .size +
168+ CONTENT_DISPOSITION .size +
169+ part.contentDisposition.toByteArray().size +
170+ CRLF .size +
171+ CONTENT_TYPE .size +
172+ part.contentType.toByteArray().size +
173+ CRLF .size +
174+ CRLF .size +
175+ contentLength +
176+ CRLF .size
177+ }
95178
96- override fun writeTo (outputStream : OutputStream ) = entity.writeTo(outputStream)
179+ byteCount + = DASHDASH .size + boundaryBytes.size + DASHDASH .size + CRLF .size
180+ return byteCount
181+ }
97182
98- override fun contentType (): String = entity.contentType
183+ override fun repeatable (): Boolean = parts.all { it.body.repeatable() }
99184
100- override fun contentLength (): Long = entity.contentLength
185+ override fun close () {
186+ parts.forEach { it.body.close() }
187+ }
101188
102- override fun repeatable (): Boolean = entity.isRepeatable
189+ class Builder {
190+ private val boundary = UUID .randomUUID().toString()
191+ private val parts: MutableList <Part > = mutableListOf ()
103192
104- override fun close () = entity.close()
193+ fun addPart (part : Part ) = apply { parts.add(part) }
194+
195+ fun build () = MultipartBody (boundary, parts.toImmutable())
196+ }
197+
198+ class Part
199+ private constructor (
200+ val contentDisposition: String ,
201+ val contentType: String ,
202+ val body: HttpRequestBody ,
203+ ) {
204+ companion object {
205+ fun create (
206+ name : String ,
207+ filename : String? ,
208+ contentType : String ,
209+ body : HttpRequestBody ,
210+ ): Part {
211+ val disposition = buildString {
212+ append(" form-data; name=" )
213+ appendQuotedString(name)
214+ if (filename != null ) {
215+ append(" ; filename=" )
216+ appendQuotedString(filename)
217+ }
218+ }
219+ return Part (disposition, contentType, body)
220+ }
221+ }
222+ }
223+
224+ companion object {
225+ private val CRLF = byteArrayOf(' \r ' .code.toByte(), ' \n ' .code.toByte())
226+ private val DASHDASH = byteArrayOf(' -' .code.toByte(), ' -' .code.toByte())
227+ private val CONTENT_DISPOSITION = " Content-Disposition: " .toByteArray()
228+ private val CONTENT_TYPE = " Content-Type: " .toByteArray()
229+
230+ private fun StringBuilder.appendQuotedString (key : String ) {
231+ append(' "' )
232+ for (ch in key) {
233+ when (ch) {
234+ ' \n ' -> append(" %0A" )
235+ ' \r ' -> append(" %0D" )
236+ ' "' -> append(" %22" )
237+ else -> append(ch)
238+ }
239+ }
240+ append(' "' )
241+ }
105242 }
243+ }
0 commit comments