diff --git a/src/main/kotlin/net/ccbluex/netty/http/HttpConductor.kt b/src/main/kotlin/net/ccbluex/netty/http/HttpConductor.kt index f4a968f..69d1e90 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/HttpConductor.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/HttpConductor.kt @@ -19,7 +19,6 @@ */ package net.ccbluex.netty.http -import io.netty.buffer.Unpooled import io.netty.handler.codec.http.* import net.ccbluex.netty.http.HttpServer.Companion.logger import net.ccbluex.netty.http.model.RequestContext @@ -27,6 +26,7 @@ import net.ccbluex.netty.http.util.httpBadRequest import net.ccbluex.netty.http.util.httpInternalServerError import net.ccbluex.netty.http.util.httpNotFound import net.ccbluex.netty.http.model.RequestObject +import net.ccbluex.netty.http.util.httpNoContent internal class HttpConductor(private val server: HttpServer) { @@ -48,22 +48,13 @@ internal class HttpConductor(private val server: HttpServer) { return@runCatching httpBadRequest("Incomplete request") } - if (method == HttpMethod.OPTIONS) { - val response = DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.OK, - Unpooled.wrappedBuffer(ByteArray(0)) - ) - - val httpHeaders = response.headers() - httpHeaders[HttpHeaderNames.CONTENT_TYPE] = "text/plain" - httpHeaders[HttpHeaderNames.CONTENT_LENGTH] = response.content().readableBytes() - return@runCatching response - } - val (node, params, remaining) = server.routeController.processPath(context.path, method) ?: return@runCatching httpNotFound(context.path, "Route not found") + if (method == HttpMethod.OPTIONS) { + return@runCatching httpNoContent() + } + logger.debug("Found destination {}", node) val requestObject = RequestObject( uri = context.uri, diff --git a/src/main/kotlin/net/ccbluex/netty/http/HttpServerHandler.kt b/src/main/kotlin/net/ccbluex/netty/http/HttpServerHandler.kt index 031659f..b6e2f12 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/HttpServerHandler.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/HttpServerHandler.kt @@ -86,7 +86,7 @@ internal class HttpServerHandler(private val server: HttpServer) : ChannelInboun } else { val requestContext = RequestContext( msg.method(), - URLDecoder.decode(msg.uri(), "UTF-8"), + URLDecoder.decode(msg.uri(), Charsets.UTF_8), msg.headers().associate { it.key to it.value }, ) @@ -95,13 +95,12 @@ internal class HttpServerHandler(private val server: HttpServer) : ChannelInboun } is HttpContent -> { - if (localRequestContext.get() == null) { + val requestContext = localRequestContext.get() ?: run { logger.warn("Received HttpContent without HttpRequest") return } // Append content to the buffer - val requestContext = localRequestContext.get() requestContext .contentBuffer .append(msg.content().toString(Charsets.UTF_8)) @@ -109,7 +108,7 @@ internal class HttpServerHandler(private val server: HttpServer) : ChannelInboun // If this is the last content, process the request if (msg is LastHttpContent) { localRequestContext.remove() - + val httpConductor = HttpConductor(server) val response = httpConductor.processRequestContext(requestContext) val httpResponse = server.middlewares.fold(response) { acc, f -> f(requestContext, acc) } diff --git a/src/main/kotlin/net/ccbluex/netty/http/model/RequestContext.kt b/src/main/kotlin/net/ccbluex/netty/http/model/RequestContext.kt index cf5bb6e..608902d 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/model/RequestContext.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/model/RequestContext.kt @@ -20,12 +20,10 @@ package net.ccbluex.netty.http.model import io.netty.handler.codec.http.HttpMethod -import java.util.* -import java.util.stream.Collectors data class RequestContext(var httpMethod: HttpMethod, var uri: String, var headers: Map) { val contentBuffer = StringBuilder() - val path = if (uri.contains("?")) uri.substring(0, uri.indexOf('?')) else uri + val path = uri.substringBefore('?', uri) val params = getUriParams(uri) } @@ -33,24 +31,21 @@ data class RequestContext(var httpMethod: HttpMethod, var uri: String, var heade * The received uri should be like: '...?param1=value¶m2=value' */ private fun getUriParams(uri: String): Map { - if (uri.contains("?")) { - val paramsString = uri.substring(uri.indexOf('?') + 1) + val queryString = uri.substringAfter('?', "") - // in case of duplicated params, will be used la last value - return Arrays.stream( - if (paramsString.contains("&")) paramsString.split("&".toRegex()).dropLastWhile { it.isEmpty() } - .toTypedArray() else arrayOf(paramsString)) - .map { value: String -> - value.split("=".toRegex()).dropLastWhile { it.isEmpty() } - .toTypedArray() - } - .collect( - Collectors.toMap( - { paramValue: Array -> paramValue[0] }, - { paramValue: Array -> paramValue[1] }, - { v1: String?, v2: String -> v2 }) - ) + if (queryString.isEmpty()) { + return emptyMap() } - return emptyMap() + // in case of duplicated params, will be used the last value + return queryString.split('&') + .mapNotNull { param -> + val index = param.indexOf('=') + if (index == -1) null + else { + val key = param.substring(0, index) + val value = param.substring(index + 1) + if (key.isNotEmpty()) key to value else null + } + }.toMap() } diff --git a/src/main/kotlin/net/ccbluex/netty/http/model/RequestObject.kt b/src/main/kotlin/net/ccbluex/netty/http/model/RequestObject.kt index cd73ad0..77c935f 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/model/RequestObject.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/model/RequestObject.kt @@ -19,8 +19,8 @@ */ package net.ccbluex.netty.http.model -import com.google.gson.Gson import io.netty.handler.codec.http.HttpMethod +import net.ccbluex.netty.http.util.gson /** * Represents an HTTP request object. @@ -51,7 +51,12 @@ data class RequestObject( * @return The JSON object of the specified type. */ inline fun asJson(): T { - return Gson().fromJson(body, T::class.java) + return GSON_INSTANCE.fromJson(body, T::class.java) + } + + companion object { + @JvmField + val GSON_INSTANCE = gson } } \ No newline at end of file diff --git a/src/main/kotlin/net/ccbluex/netty/http/rest/RouteControl.kt b/src/main/kotlin/net/ccbluex/netty/http/rest/RouteControl.kt index 84b5bc5..54a752d 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/rest/RouteControl.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/rest/RouteControl.kt @@ -55,7 +55,7 @@ class RouteController : Node("") { */ internal fun processPath(path: String, method: HttpMethod): Destination? { val pathArray = path.asPathArray() - .also { if (it.isEmpty()) throw IllegalArgumentException("Path cannot be empty") } + require(pathArray.isNotEmpty()) { "Path cannot be empty" } return travelNode(this, pathArray, method, 0, mutableMapOf()) } @@ -282,4 +282,8 @@ class FileServant(part: String, private val baseFolder: File) : Node(part) { * * @return An array of path parts. */ -private fun String.asPathArray() = split("/").drop(1).toTypedArray() \ No newline at end of file +private fun String.asPathArray(): Array { + val parts = split("/") + return if (parts.size <= 1) emptyArray() + else parts.subList(1, parts.size).toTypedArray() +} \ No newline at end of file diff --git a/src/main/kotlin/net/ccbluex/netty/http/util/ByteBufExtensions.kt b/src/main/kotlin/net/ccbluex/netty/http/util/ByteBufExtensions.kt new file mode 100644 index 0000000..271de03 --- /dev/null +++ b/src/main/kotlin/net/ccbluex/netty/http/util/ByteBufExtensions.kt @@ -0,0 +1,29 @@ +/* + * This file is part of Netty-Rest (https://github.com/CCBlueX/netty-rest) + * + * Copyright (c) 2024 CCBlueX + * + * LiquidBounce is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Netty-Rest is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Netty-Rest. If not, see . + * + */ +@file:Suppress("NOTHING_TO_INLINE") +package net.ccbluex.netty.http.util + +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufInputStream +import io.netty.buffer.ByteBufOutputStream + +inline fun ByteBuf.inputStream() = ByteBufInputStream(this) + +inline fun ByteBuf.outputStream() = ByteBufOutputStream(this) diff --git a/src/main/kotlin/net/ccbluex/netty/http/util/HttpResponse.kt b/src/main/kotlin/net/ccbluex/netty/http/util/HttpResponse.kt index eec1244..7d52945 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/util/HttpResponse.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/util/HttpResponse.kt @@ -19,13 +19,13 @@ */ package net.ccbluex.netty.http.util -import com.google.gson.Gson import com.google.gson.JsonElement -import com.google.gson.JsonObject -import io.netty.buffer.Unpooled +import io.netty.buffer.ByteBuf +import io.netty.buffer.PooledByteBufAllocator import io.netty.handler.codec.http.* import org.apache.tika.Tika import java.io.File +import java.io.IOException import java.io.InputStream /** @@ -33,15 +33,18 @@ import java.io.InputStream * * @param status The HTTP response status. * @param contentType The content type of the response. Defaults to "text/plain". - * @param content The content of the response. + * @param content The content of the response, in [io.netty.buffer.ByteBuf]. * @return A FullHttpResponse object. */ -fun httpResponse(status: HttpResponseStatus, contentType: String = "text/plain", - content: String): FullHttpResponse { +fun httpResponse( + status: HttpResponseStatus, + contentType: String = "text/plain", + content: ByteBuf +): FullHttpResponse { val response = DefaultFullHttpResponse( HttpVersion.HTTP_1_1, status, - Unpooled.wrappedBuffer(content.toByteArray()) + content ) val httpHeaders = response.headers() @@ -51,6 +54,37 @@ fun httpResponse(status: HttpResponseStatus, contentType: String = "text/plain", return response } +/** + * Creates an HTTP response with the given status, content type, and content. + * + * @param status The HTTP response status. + * @param contentType The content type of the response. Defaults to "text/plain". + * @param content The content of the response, in [String]. + * @return A FullHttpResponse object. + */ +fun httpResponse( + status: HttpResponseStatus, + contentType: String = "text/plain", + content: String +): FullHttpResponse { + val buf = PooledByteBufAllocator.DEFAULT.buffer(content.length * 3) + buf.writeCharSequence(content, Charsets.UTF_8) + return httpResponse(status, contentType, buf) +} + +/** + * Creates an HTTP response with the given status and JSON content. + * + * @param status The HTTP response status. + * @param json The JSON content of the response. + * @return A FullHttpResponse object. + */ +fun httpResponse(status: HttpResponseStatus, json: JsonElement) = httpResponse( + status, + "application/json", + PooledByteBufAllocator.DEFAULT.writeJson(json) +) + /** * Creates an HTTP response with the given status and JSON content. * @@ -58,8 +92,11 @@ fun httpResponse(status: HttpResponseStatus, contentType: String = "text/plain", * @param json The JSON content of the response. * @return A FullHttpResponse object. */ -fun httpResponse(status: HttpResponseStatus, json: JsonElement) - = httpResponse(status, "application/json", Gson().toJson(json)) +fun httpResponse(status: HttpResponseStatus, json: T) = httpResponse( + status, + "application/json", + PooledByteBufAllocator.DEFAULT.writeJson(json, json.javaClass) +) /** * Creates an HTTP 200 OK response with the given JSON content. @@ -67,8 +104,7 @@ fun httpResponse(status: HttpResponseStatus, json: JsonElement) * @param jsonElement The JSON content of the response. * @return A FullHttpResponse object. */ -fun httpOk(jsonElement: JsonElement) - = httpResponse(HttpResponseStatus.OK, jsonElement) +fun httpOk(jsonElement: JsonElement) = httpResponse(HttpResponseStatus.OK, jsonElement) /** * Creates an HTTP 404 Not Found response with the given path and reason. @@ -78,10 +114,8 @@ fun httpOk(jsonElement: JsonElement) * @return A FullHttpResponse object. */ fun httpNotFound(path: String, reason: String): FullHttpResponse { - val jsonObject = JsonObject() - jsonObject.addProperty("path", path) - jsonObject.addProperty("reason", reason) - return httpResponse(HttpResponseStatus.NOT_FOUND, jsonObject) + data class ResponseBody(val path: String, val reason: String) + return httpResponse(HttpResponseStatus.NOT_FOUND, ResponseBody(path, reason)) } /** @@ -91,9 +125,8 @@ fun httpNotFound(path: String, reason: String): FullHttpResponse { * @return A FullHttpResponse object. */ fun httpInternalServerError(exception: String): FullHttpResponse { - val jsonObject = JsonObject() - jsonObject.addProperty("reason", exception) - return httpResponse(HttpResponseStatus.INTERNAL_SERVER_ERROR, jsonObject) + data class ResponseBody(val reason: String) + return httpResponse(HttpResponseStatus.INTERNAL_SERVER_ERROR, ResponseBody(exception)) } /** @@ -103,9 +136,8 @@ fun httpInternalServerError(exception: String): FullHttpResponse { * @return A FullHttpResponse object. */ fun httpForbidden(reason: String): FullHttpResponse { - val jsonObject = JsonObject() - jsonObject.addProperty("reason", reason) - return httpResponse(HttpResponseStatus.FORBIDDEN, jsonObject) + data class ResponseBody(val reason: String) + return httpResponse(HttpResponseStatus.FORBIDDEN, ResponseBody(reason)) } /** @@ -115,9 +147,8 @@ fun httpForbidden(reason: String): FullHttpResponse { * @return A FullHttpResponse object. */ fun httpBadRequest(reason: String): FullHttpResponse { - val jsonObject = JsonObject() - jsonObject.addProperty("reason", reason) - return httpResponse(HttpResponseStatus.BAD_REQUEST, jsonObject) + data class ResponseBody(val reason: String) + return httpResponse(HttpResponseStatus.BAD_REQUEST, ResponseBody(reason)) } private val tika = Tika() @@ -129,38 +160,51 @@ private val tika = Tika() * @return A FullHttpResponse object. */ fun httpFile(file: File): FullHttpResponse { - val response = DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.OK, - Unpooled.wrappedBuffer(file.readBytes()) - ) + require(file.length() <= Int.MAX_VALUE) { "File is too big" } - val httpHeaders = response.headers() - httpHeaders[HttpHeaderNames.CONTENT_TYPE] = tika.detect(file) - httpHeaders[HttpHeaderNames.CONTENT_LENGTH] = response.content().readableBytes() - return response + return httpFileStream( + file.inputStream(), + contentType = tika.detect(file), + contentLength = file.length().toInt() + ) } /** * Creates an HTTP response for the given input stream. * - * @param stream The input stream to be included in the response. + * @param stream The input stream to be included in the response. It will be closed after reading. + * @param contentType The content type of [stream]. Defaults to `null`, which means to auto-detect by [Tika]. + * @param contentLength The predicated content length of [stream]. Defaults to `256`. * @return A FullHttpResponse object. */ -fun httpFileStream(stream: InputStream): FullHttpResponse { - val bytes = stream.readBytes() +@JvmOverloads +fun httpFileStream( + stream: InputStream, + contentType: String? = null, + contentLength: Int = 256, +): FullHttpResponse { + require(contentLength > 0) { "content length must be positive" } - val response = DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.OK, - Unpooled.wrappedBuffer(bytes) - ) + val allocator = PooledByteBufAllocator.DEFAULT + val buf = allocator.buffer(contentLength) - val httpHeaders = response.headers() - httpHeaders[HttpHeaderNames.CONTENT_TYPE] = tika.detect(bytes) - httpHeaders[HttpHeaderNames.CONTENT_LENGTH] = response.content().readableBytes() + try { + stream.use { + while (true) { + val read = buf.writeBytes(stream, 8192) + if (read == -1) break + } + } - return response + return httpResponse( + status = HttpResponseStatus.OK, + contentType = contentType ?: tika.detect(buf.duplicate().inputStream()), + content = buf + ) + } catch (e: IOException) { + buf.release() + return httpInternalServerError(e.stackTraceToString()) + } } /** @@ -186,9 +230,8 @@ fun httpNoContent(): FullHttpResponse { * @return A FullHttpResponse object. */ fun httpMethodNotAllowed(method: String): FullHttpResponse { - val jsonObject = JsonObject() - jsonObject.addProperty("method", method) - return httpResponse(HttpResponseStatus.METHOD_NOT_ALLOWED, jsonObject) + data class ResponseBody(val method: String) + return httpResponse(HttpResponseStatus.METHOD_NOT_ALLOWED, ResponseBody(method)) } /** @@ -198,9 +241,8 @@ fun httpMethodNotAllowed(method: String): FullHttpResponse { * @return A FullHttpResponse object. */ fun httpUnauthorized(reason: String): FullHttpResponse { - val jsonObject = JsonObject() - jsonObject.addProperty("reason", reason) - return httpResponse(HttpResponseStatus.UNAUTHORIZED, jsonObject) + data class ResponseBody(val reason: String) + return httpResponse(HttpResponseStatus.UNAUTHORIZED, ResponseBody(reason)) } /** @@ -210,9 +252,8 @@ fun httpUnauthorized(reason: String): FullHttpResponse { * @return A FullHttpResponse object. */ fun httpTooManyRequests(reason: String): FullHttpResponse { - val jsonObject = JsonObject() - jsonObject.addProperty("reason", reason) - return httpResponse(HttpResponseStatus.TOO_MANY_REQUESTS, jsonObject) + data class ResponseBody(val reason: String) + return httpResponse(HttpResponseStatus.TOO_MANY_REQUESTS, ResponseBody(reason)) } /** @@ -222,7 +263,6 @@ fun httpTooManyRequests(reason: String): FullHttpResponse { * @return A FullHttpResponse object. */ fun httpServiceUnavailable(reason: String): FullHttpResponse { - val jsonObject = JsonObject() - jsonObject.addProperty("reason", reason) - return httpResponse(HttpResponseStatus.SERVICE_UNAVAILABLE, jsonObject) + data class ResponseBody(val reason: String) + return httpResponse(HttpResponseStatus.SERVICE_UNAVAILABLE, ResponseBody(reason)) } diff --git a/src/main/kotlin/net/ccbluex/netty/http/util/Serializations.kt b/src/main/kotlin/net/ccbluex/netty/http/util/Serializations.kt new file mode 100644 index 0000000..4ff8280 --- /dev/null +++ b/src/main/kotlin/net/ccbluex/netty/http/util/Serializations.kt @@ -0,0 +1,58 @@ +/* + * This file is part of Netty-Rest (https://github.com/CCBlueX/netty-rest) + * + * Copyright (c) 2024 CCBlueX + * + * LiquidBounce is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Netty-Rest is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Netty-Rest. If not, see . + * + */ +package net.ccbluex.netty.http.util + +import com.google.gson.Gson +import com.google.gson.JsonElement +import io.netty.buffer.ByteBuf +import io.netty.buffer.ByteBufAllocator +import java.lang.reflect.Type + +internal val gson = Gson() + +/** + * Serialize [json] into [ByteBuf] with given [ByteBufAllocator]. + */ +fun ByteBufAllocator.writeJson( + json: JsonElement, +): ByteBuf { + val buf = buffer(256, Int.MAX_VALUE) + gson.newJsonWriter(buf.outputStream().writer(Charsets.UTF_8)).use { writer -> + gson.toJson(json, writer) + writer.flush() + } + return buf +} + +/** + * Serialize [obj] as [type] into [ByteBuf] with given [ByteBufAllocator]. + */ +@JvmOverloads +fun ByteBufAllocator.writeJson( + obj: T, + type: Type = obj.javaClass, +): ByteBuf { + val buf = buffer(256, Int.MAX_VALUE) + gson.newJsonWriter(buf.outputStream().writer(Charsets.UTF_8)).use { writer -> + gson.toJson(obj, type, writer) + writer.flush() + } + return buf +}