Http.kt
| 1 | package de.upapp |
| 2 | |
| 3 | import java.io.ByteArrayOutputStream |
| 4 | import java.io.DataInputStream |
| 5 | import java.io.EOFException |
| 6 | import java.io.IOException |
| 7 | import java.io.InputStream |
| 8 | import java.io.OutputStream |
| 9 | |
| 10 | /** [reusable] is true if the connection can carry the next request. */ |
| 11 | class Response(val status: Int, val headers: Map<String, String>, val body: ByteArray, val reusable: Boolean = false) { |
| 12 | val text get() = body.decodeToString() |
| 13 | } |
| 14 | |
| 15 | /** Sends one HTTP/1.1 request and reads the response. Header names are lower case. */ |
| 16 | fun exchange( |
| 17 | input: InputStream, output: OutputStream, method: String, host: String, path: String, body: ByteArray? = null, |
| 18 | keepAlive: Boolean = false, extraHeaders: List<String> = emptyList(), |
| 19 | ): Response { |
| 20 | val head = buildString { |
| 21 | append("$method $path HTTP/1.1\r\nHost: $host\r\n") |
| 22 | if (!keepAlive) append("Connection: close\r\n") |
| 23 | extraHeaders.forEach { append("$it\r\n") } |
| 24 | if (body != null) { |
| 25 | // Same type as maps+more. Other types are untested on the car. |
| 26 | if (body.isNotEmpty()) append("Content-Type: text/plain; charset=UTF-8\r\n") |
| 27 | append("Content-Length: ${body.size}\r\n") |
| 28 | } |
| 29 | append("\r\n") |
| 30 | } |
| 31 | output.write(head.encodeToByteArray() + (body ?: ByteArray(0))) |
| 32 | output.flush() |
| 33 | |
| 34 | // No buffering. Read-ahead would swallow the start of the next response on a reused connection. |
| 35 | val inp = DataInputStream(input) |
| 36 | val statusLine = inp.line() |
| 37 | val status = statusLine.split(' ').getOrNull(1)?.toIntOrNull() ?: throw IOException("Bad status line: $statusLine") |
| 38 | val headers = generateSequence { inp.line().ifEmpty { null } } |
| 39 | .associate { it.substringBefore(':').trim().lowercase() to it.substringAfter(':').trim() } |
| 40 | val length = headers["content-length"]?.toInt() |
| 41 | var framed = true |
| 42 | val bytes = when { |
| 43 | headers["transfer-encoding"]?.contains("chunked", ignoreCase = true) == true -> inp.chunked() |
| 44 | length != null -> ByteArray(length).also { inp.readFully(it) } |
| 45 | status in 100..199 || status == 204 || status == 304 -> ByteArray(0) |
| 46 | else -> inp.untilEof().also { framed = false } |
| 47 | } |
| 48 | val reusable = keepAlive && framed && !headers["connection"].equals("close", ignoreCase = true) |
| 49 | return Response(status, headers, bytes, reusable) |
| 50 | } |
| 51 | |
| 52 | internal fun InputStream.line(): String { |
| 53 | val out = ByteArrayOutputStream() |
| 54 | while (true) { |
| 55 | val c = read() |
| 56 | if (c < 0) throw EOFException("Connection closed in HTTP header") |
| 57 | if (c == '\n'.code) break |
| 58 | out.write(c) |
| 59 | } |
| 60 | return out.toString(Charsets.ISO_8859_1.name()).trimEnd('\r') |
| 61 | } |
| 62 | |
| 63 | private fun DataInputStream.chunked(): ByteArray { |
| 64 | val out = ByteArrayOutputStream() |
| 65 | while (true) { |
| 66 | val size = line().substringBefore(';').trim().toInt(16) |
| 67 | if (size == 0) break |
| 68 | out.write(ByteArray(size).also { readFully(it) }) |
| 69 | line() |
| 70 | } |
| 71 | while (line().isNotEmpty()) Unit |
| 72 | return out.toByteArray() |
| 73 | } |
| 74 | |
| 75 | private fun InputStream.untilEof(): ByteArray { |
| 76 | val out = ByteArrayOutputStream() |
| 77 | val buf = ByteArray(4096) |
| 78 | try { |
| 79 | while (true) { |
| 80 | val n = read(buf) |
| 81 | if (n < 0) break |
| 82 | out.write(buf, 0, n) |
| 83 | } |
| 84 | } catch (_: EOFException) { |
| 85 | // A peer that closes without TLS close_notify still ends the body. |
| 86 | } |
| 87 | return out.toByteArray() |
| 88 | } |
| 89 |