55package com.branddev.api.core.http
66
77import com.branddev.api.core.MultipartField
8+ import com.branddev.api.core.toImmutable
89import com.branddev.api.errors.BrandDevInvalidDataException
910import com.fasterxml.jackson.databind.JsonNode
1011import com.fasterxml.jackson.databind.json.JsonMapper
1112import com.fasterxml.jackson.databind.node.JsonNodeType
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,92 +37,231 @@ 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
81+
82+ override fun contentLength (): Long = - 1L
5583
56- parts.forEach { (name, bytes) ->
57- addBinaryBody(
58- name,
59- bytes,
60- ContentType .parseLenient(field.contentType),
61- field.filename().getOrNull(),
62- )
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()
68102
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- sequenceOf(
84- name to
85- node
86- .elements()
87- .asSequence()
88- .mapNotNull { element ->
89- when (element.nodeType) {
90- JsonNodeType .MISSING ,
91- JsonNodeType .NULL -> null
92- JsonNodeType .STRING -> node.textValue()
93- JsonNodeType .BOOLEAN -> node.booleanValue().toString()
94- JsonNodeType .NUMBER -> node.numberValue().toString()
95- null ,
96- JsonNodeType .BINARY ,
97- JsonNodeType .ARRAY ,
98- JsonNodeType .OBJECT ,
99- JsonNodeType .POJO ->
100- throw BrandDevInvalidDataException (
101- " Unexpected JsonNode type in array: ${node.nodeType} "
102- )
103- }
104- }
105- .joinToString(" ," )
106- .inputStream()
107- )
108- JsonNodeType .OBJECT ->
109- node.fields().asSequence().flatMap { (key, value) ->
110- serializePart(" $name [$key ]" , value)
111- }
112- JsonNodeType .POJO ,
113- null ->
114- throw BrandDevInvalidDataException (" Unexpected JsonNode type: ${node.nodeType} " )
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+ sequenceOf(
113+ name to
114+ node
115+ .elements()
116+ .asSequence()
117+ .mapNotNull { element ->
118+ when (element.nodeType) {
119+ JsonNodeType .MISSING ,
120+ JsonNodeType .NULL -> null
121+ JsonNodeType .STRING -> element.textValue()
122+ JsonNodeType .BOOLEAN -> element.booleanValue().toString()
123+ JsonNodeType .NUMBER -> element.numberValue().toString()
124+ null ,
125+ JsonNodeType .BINARY ,
126+ JsonNodeType .ARRAY ,
127+ JsonNodeType .OBJECT ,
128+ JsonNodeType .POJO ->
129+ throw BrandDevInvalidDataException (
130+ " Unexpected JsonNode type in array: ${element.nodeType} "
131+ )
132+ }
133+ }
134+ .joinToString(" ," )
135+ .byteInputStream()
136+ )
137+ JsonNodeType .OBJECT ->
138+ node.fields().asSequence().flatMap { (key, value) ->
139+ serializePart(" $name [$key ]" , value)
140+ }
141+ JsonNodeType .POJO ,
142+ null -> throw BrandDevInvalidDataException (" Unexpected JsonNode type: ${node.nodeType} " )
143+ }
144+
145+ private class MultipartBody
146+ private constructor (private val boundary: String , private val parts: List <Part >) : HttpRequestBody {
147+ private val boundaryBytes: ByteArray = boundary.toByteArray()
148+ private val contentType = " multipart/form-data; boundary=$boundary "
149+
150+ // This must remain in sync with `contentLength`.
151+ override fun writeTo (outputStream : OutputStream ) {
152+ parts.forEach { part ->
153+ outputStream.write(DASHDASH )
154+ outputStream.write(boundaryBytes)
155+ outputStream.write(CRLF )
156+
157+ outputStream.write(CONTENT_DISPOSITION )
158+ outputStream.write(part.contentDisposition.toByteArray())
159+ outputStream.write(CRLF )
160+
161+ outputStream.write(CONTENT_TYPE )
162+ outputStream.write(part.contentType.toByteArray())
163+ outputStream.write(CRLF )
164+
165+ outputStream.write(CRLF )
166+ part.body.writeTo(outputStream)
167+ outputStream.write(CRLF )
168+ }
169+
170+ outputStream.write(DASHDASH )
171+ outputStream.write(boundaryBytes)
172+ outputStream.write(DASHDASH )
173+ outputStream.write(CRLF )
174+ }
175+
176+ override fun contentType (): String = contentType
177+
178+ // This must remain in sync with `writeTo`.
179+ override fun contentLength (): Long {
180+ var byteCount = 0L
181+
182+ parts.forEach { part ->
183+ val contentLength = part.body.contentLength()
184+ if (contentLength == - 1L ) {
185+ return - 1L
115186 }
116187
117- private fun String.inputStream (): InputStream = toByteArray().inputStream()
188+ byteCount + =
189+ DASHDASH .size +
190+ boundaryBytes.size +
191+ CRLF .size +
192+ CONTENT_DISPOSITION .size +
193+ part.contentDisposition.toByteArray().size +
194+ CRLF .size +
195+ CONTENT_TYPE .size +
196+ part.contentType.toByteArray().size +
197+ CRLF .size +
198+ CRLF .size +
199+ contentLength +
200+ CRLF .size
201+ }
118202
119- override fun writeTo (outputStream : OutputStream ) = entity.writeTo(outputStream)
203+ byteCount + = DASHDASH .size + boundaryBytes.size + DASHDASH .size + CRLF .size
204+ return byteCount
205+ }
120206
121- override fun contentType (): String = entity.contentType
207+ override fun repeatable (): Boolean = parts.all { it.body.repeatable() }
122208
123- override fun contentLength (): Long = entity.contentLength
209+ override fun close () {
210+ parts.forEach { it.body.close() }
211+ }
124212
125- override fun repeatable (): Boolean = entity.isRepeatable
213+ class Builder {
214+ private val boundary = UUID .randomUUID().toString()
215+ private val parts: MutableList <Part > = mutableListOf ()
126216
127- override fun close () = entity.close()
217+ fun addPart (part : Part ) = apply { parts.add(part) }
218+
219+ fun build () = MultipartBody (boundary, parts.toImmutable())
220+ }
221+
222+ class Part
223+ private constructor (
224+ val contentDisposition: String ,
225+ val contentType: String ,
226+ val body: HttpRequestBody ,
227+ ) {
228+ companion object {
229+ fun create (
230+ name : String ,
231+ filename : String? ,
232+ contentType : String ,
233+ body : HttpRequestBody ,
234+ ): Part {
235+ val disposition = buildString {
236+ append(" form-data; name=" )
237+ appendQuotedString(name)
238+ if (filename != null ) {
239+ append(" ; filename=" )
240+ appendQuotedString(filename)
241+ }
242+ }
243+ return Part (disposition, contentType, body)
244+ }
245+ }
246+ }
247+
248+ companion object {
249+ private val CRLF = byteArrayOf(' \r ' .code.toByte(), ' \n ' .code.toByte())
250+ private val DASHDASH = byteArrayOf(' -' .code.toByte(), ' -' .code.toByte())
251+ private val CONTENT_DISPOSITION = " Content-Disposition: " .toByteArray()
252+ private val CONTENT_TYPE = " Content-Type: " .toByteArray()
253+
254+ private fun StringBuilder.appendQuotedString (key : String ) {
255+ append(' "' )
256+ for (ch in key) {
257+ when (ch) {
258+ ' \n ' -> append(" %0A" )
259+ ' \r ' -> append(" %0D" )
260+ ' "' -> append(" %22" )
261+ else -> append(ch)
262+ }
263+ }
264+ append(' "' )
265+ }
128266 }
267+ }
0 commit comments