From 2818977eefad6bd704f49bd3361a86bc0c6b039a Mon Sep 17 00:00:00 2001 From: 1zuna Date: Wed, 4 Sep 2024 12:19:28 +0200 Subject: [PATCH] release: 2.0.0 - Introduces support for inline URI parameters - Rewritten path processing - Fixes websocket channels broadcast and close --- README.md | 2 +- build.gradle.kts | 6 +- .../src/main/kotlin/EchoServerExample.kt | 10 +- examples/file-server/files/test.txt | 1 + .../file-server/files/{ => web}/index.html | 0 .../src/main/kotlin/FileServerExample.kt | 69 +++- .../src/main/kotlin/HelloWorldExample.kt | 34 +- settings.gradle.kts | 2 +- .../net/ccbluex/netty/http/HttpConductor.kt | 42 +- .../ccbluex/netty/http/HttpServerHandler.kt | 4 +- .../netty/http/model/RequestContext.kt | 3 +- .../ccbluex/netty/http/model/RequestObject.kt | 35 +- .../ccbluex/netty/http/rest/RouteControl.kt | 290 +++++++++----- .../http/websocket/WebSocketController.kt | 13 +- src/test/kotlin/HttpServerTest.kt | 372 ++++++++++++++++-- 15 files changed, 717 insertions(+), 166 deletions(-) create mode 100644 examples/file-server/files/test.txt rename examples/file-server/files/{ => web}/index.html (100%) diff --git a/README.md b/README.md index 2f959c3..c65c036 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Netty HttpServer is a Kotlin-based library for building web REST APIs on top of To include Netty HttpServer in your project, add the following dependency to your `build.gradle` file: ```gradle -implementation 'com.github.CCBlueX:netty-httpserver:1.0.0' +implementation 'com.github.CCBlueX:netty-httpserver:2.0.0' ``` ### Basic Usage diff --git a/build.gradle.kts b/build.gradle.kts index 61d5e9c..51bb9ca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ val authorName = "ccbluex" val projectUrl = "https://github.com/ccbluex/netty-httpserver" group = "net.ccbluex" -version = "1.0.0" +version = "2.0.0" repositories { mavenCentral() @@ -37,8 +37,8 @@ dependencies { implementation("org.apache.tika:tika-core:2.9.2") testImplementation(kotlin("test")) - // https://mvnrepository.com/artifact/org.mockito/mockito-core - testImplementation("org.mockito:mockito-core:5.13.0") + testImplementation("com.squareup.retrofit2:retrofit:2.9.0") + testImplementation("com.squareup.retrofit2:converter-gson:2.9.0") } tasks.test { diff --git a/examples/echo-server/src/main/kotlin/EchoServerExample.kt b/examples/echo-server/src/main/kotlin/EchoServerExample.kt index 418ebb8..09b82fe 100644 --- a/examples/echo-server/src/main/kotlin/EchoServerExample.kt +++ b/examples/echo-server/src/main/kotlin/EchoServerExample.kt @@ -1,15 +1,19 @@ import com.google.gson.JsonObject +import io.netty.handler.codec.http.FullHttpResponse import net.ccbluex.netty.http.HttpServer +import net.ccbluex.netty.http.model.RequestObject import net.ccbluex.netty.http.util.httpOk fun main() { val server = HttpServer() server.routeController.apply { - post("/echo") { request -> - httpOk(request.asJson()) - } + post("/echo", ::postEcho) // /echo } server.start(8080) // Start the server on port 8080 } + +fun postEcho(requestObject: RequestObject): FullHttpResponse { + return httpOk(requestObject.asJson()) +} \ No newline at end of file diff --git a/examples/file-server/files/test.txt b/examples/file-server/files/test.txt new file mode 100644 index 0000000..3462721 --- /dev/null +++ b/examples/file-server/files/test.txt @@ -0,0 +1 @@ +hello! \ No newline at end of file diff --git a/examples/file-server/files/index.html b/examples/file-server/files/web/index.html similarity index 100% rename from examples/file-server/files/index.html rename to examples/file-server/files/web/index.html diff --git a/examples/file-server/src/main/kotlin/FileServerExample.kt b/examples/file-server/src/main/kotlin/FileServerExample.kt index de8487c..86ee873 100644 --- a/examples/file-server/src/main/kotlin/FileServerExample.kt +++ b/examples/file-server/src/main/kotlin/FileServerExample.kt @@ -1,15 +1,78 @@ +import com.google.gson.JsonObject +import io.netty.handler.codec.http.FullHttpResponse import net.ccbluex.netty.http.HttpServer +import net.ccbluex.netty.http.model.RequestObject +import net.ccbluex.netty.http.util.httpBadRequest +import net.ccbluex.netty.http.util.httpOk import java.io.File +const val FOLDER_NAME = "files" +val folder = File(FOLDER_NAME) + fun main() { val server = HttpServer() - val folder = File("files") + println("Serving files from: ${folder.absolutePath}") server.routeController.apply { - // Serve files from the "files" directory - file("/files", folder) + get("/", ::getRoot) + get("/conflicting", ::getConflictingPath) + get("/a/b/c", ::getConflictingPath) + get("/file/:name", ::getFileInformation) + post("/file/:name", ::postFile) + + // register file serving at the bottom of the routing tree + // to avoid overwriting other routes + file("/", folder) } server.start(8080) // Start the server on port 8080 } + +@Suppress("UNUSED_PARAMETER") +fun getRoot(requestObject: RequestObject): FullHttpResponse { + // Count the number of files in the folder + // Walk the folder and count the number of files + return httpOk(JsonObject().apply { + addProperty("path", folder.absolutePath) + addProperty("files", folder.walk().count()) + }) +} + +@Suppress("UNUSED_PARAMETER") +fun getConflictingPath(requestObject: RequestObject): FullHttpResponse { + return httpOk(JsonObject().apply { + addProperty("message", "This is a conflicting path") + }) +} + +@Suppress("UNUSED_PARAMETER") +fun getFileInformation(requestObject: RequestObject): FullHttpResponse { + val name = requestObject.params["name"] ?: return httpBadRequest("Missing name parameter") + val file = File(folder, name) + + if (!file.exists()) { + return httpBadRequest("File not found") + } + + return httpOk(JsonObject().apply { + addProperty("name", file.name) + addProperty("size", file.length()) + addProperty("lastModified", file.lastModified()) + }) +} + +@Suppress("UNUSED_PARAMETER") +fun postFile(requestObject: RequestObject): FullHttpResponse { + val name = requestObject.params["name"] ?: return httpBadRequest("Missing name parameter") + val file = File(folder, name) + + if (file.exists()) { + return httpBadRequest("File already exists") + } + + file.writeText(requestObject.body) + return httpOk(JsonObject().apply { + addProperty("message", "File written") + }) +} \ No newline at end of file diff --git a/examples/hello-world/src/main/kotlin/HelloWorldExample.kt b/examples/hello-world/src/main/kotlin/HelloWorldExample.kt index 457bea2..a587571 100644 --- a/examples/hello-world/src/main/kotlin/HelloWorldExample.kt +++ b/examples/hello-world/src/main/kotlin/HelloWorldExample.kt @@ -1,17 +1,41 @@ import com.google.gson.JsonObject +import io.netty.handler.codec.http.FullHttpResponse import net.ccbluex.netty.http.HttpServer +import net.ccbluex.netty.http.model.RequestObject import net.ccbluex.netty.http.util.httpOk fun main() { val server = HttpServer() server.routeController.apply { - get("/hello") { - httpOk(JsonObject().apply { - addProperty("message", "Hello, World!") - }) - } + get("/", ::getRoot) + get("/hello", ::getHello) // /hello?name=World + get("/hello/:name", ::getHello) // /hello/World + get("/hello/:name/:age", ::getHelloWithAge) // /hello/World/20 } server.start(8080) // Start the server on port 8080 } + +@Suppress("UNUSED_PARAMETER") +fun getRoot(requestObject: RequestObject): FullHttpResponse { + return httpOk(JsonObject().apply { + addProperty("root", true) + }) +} + +fun getHello(requestObject: RequestObject): FullHttpResponse { + val name = requestObject.params["name"] ?: requestObject.queryParams["name"] ?: "World" + return httpOk(JsonObject().apply { + addProperty("message", "Hello, $name!") + }) +} + +fun getHelloWithAge(requestObject: RequestObject): FullHttpResponse { + val name = requestObject.params["name"] ?: "World" + val age = requestObject.params["age"] ?: "0" + return httpOk(JsonObject().apply { + addProperty("message", "Hello, $name! You are $age years old.") + }) +} + diff --git a/settings.gradle.kts b/settings.gradle.kts index 85cfcbf..8cafa58 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,4 +5,4 @@ rootProject.name = "netty-httpserver" include(":examples:hello-world") include(":examples:echo-server") -include(":examples:file-server") +include(":examples:file-server") \ No newline at end of file diff --git a/src/main/kotlin/net/ccbluex/netty/http/HttpConductor.kt b/src/main/kotlin/net/ccbluex/netty/http/HttpConductor.kt index 056f2ed..e0c6113 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/HttpConductor.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/HttpConductor.kt @@ -22,6 +22,7 @@ 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 import net.ccbluex.netty.http.util.httpBadRequest import net.ccbluex.netty.http.util.httpInternalServerError import net.ccbluex.netty.http.util.httpNotFound @@ -30,20 +31,20 @@ import net.ccbluex.netty.http.model.RequestObject internal class HttpConductor(private val server: HttpServer) { /** - * Processes the incoming request object and returns the response. + * Processes the incoming request context and returns the response. * - * @param requestObject The incoming request object. + * @param context The request context to process. * @return The response to the request. */ - fun processRequestObject(requestObject: RequestObject) = runCatching { - val context = requestObject.context + fun processRequestContext(context: RequestContext) = runCatching { + val content = context.contentBuffer.toString() val method = context.httpMethod - logger.debug("Request {}", requestObject) + logger.debug("Request {}", context) if (!context.headers["content-length"].isNullOrEmpty() && - context.headers["content-length"]?.toInt() != requestObject.content.toByteArray(Charsets.UTF_8).size) { - logger.warn("Received incomplete request: $requestObject") + context.headers["content-length"]?.toInt() != content.toByteArray(Charsets.UTF_8).size) { + logger.warn("Received incomplete request: $context") return@runCatching httpBadRequest("Incomplete request") } @@ -63,21 +64,24 @@ internal class HttpConductor(private val server: HttpServer) { return@runCatching response } - server.routeController.findRoute(context.path, method)?.let { route -> - logger.debug("Found route {}", route) - return@runCatching route.handler(requestObject) - } + val (node, params, remaining) = server.routeController.processPath(context.path, method) ?: + return@runCatching httpNotFound(context.path, "Route not found") - if (method == HttpMethod.GET) { - server.routeController.findFileServant(context.path)?.let { (fileServant, path) -> - logger.debug("Found file servant {}", fileServant) - return@runCatching fileServant.handleFileRequest(path) - } - } + logger.debug("Found destination {}", node) + val requestObject = RequestObject( + uri = context.uri, + path = context.path, + remainingPath = remaining, + method = method, + body = content, + params = params, + queryParams = context.params, + headers = context.headers + ) - httpNotFound(context.path, "Route not found") + return@runCatching node.handleRequest(requestObject) }.getOrElse { - logger.error("Error while processing request object: $requestObject", it) + logger.error("Error while processing request object: $context", it) httpInternalServerError(it.message ?: "Unknown error") } diff --git a/src/main/kotlin/net/ccbluex/netty/http/HttpServerHandler.kt b/src/main/kotlin/net/ccbluex/netty/http/HttpServerHandler.kt index 8c76c48..a785001 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/HttpServerHandler.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/HttpServerHandler.kt @@ -28,7 +28,6 @@ import io.netty.handler.codec.http.LastHttpContent import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory import net.ccbluex.netty.http.HttpServer.Companion.logger import net.ccbluex.netty.http.model.RequestContext -import net.ccbluex.netty.http.model.RequestObject import net.ccbluex.netty.http.websocket.WebSocketHandler import java.net.URLDecoder @@ -109,11 +108,10 @@ internal class HttpServerHandler(private val server: HttpServer) : ChannelInboun // If this is the last content, process the request if (msg is LastHttpContent) { - val requestObject = RequestObject(requestContext) localRequestContext.remove() val httpConductor = HttpConductor(server) - val response = httpConductor.processRequestObject(requestObject) + val response = httpConductor.processRequestContext(requestContext) ctx.writeAndFlush(response) } } 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 09a7232..cf5bb6e 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/model/RequestContext.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/model/RequestContext.kt @@ -51,5 +51,6 @@ private fun getUriParams(uri: String): Map { { v1: String?, v2: String -> v2 }) ) } - return HashMap() + + return emptyMap() } 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 801d3bd..cd73ad0 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/model/RequestObject.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/model/RequestObject.kt @@ -20,13 +20,38 @@ package net.ccbluex.netty.http.model import com.google.gson.Gson +import io.netty.handler.codec.http.HttpMethod -data class RequestObject(var context: RequestContext) { - val content = context.contentBuffer.toString() - val params = context.params +/** + * Represents an HTTP request object. + * + * @property uri The full URI of the request. + * @property path The path of the request. + * @property remainingPath The ending part of the path which was not matched by the route. + * @property method The HTTP method of the request. + * @property body The body of the request. + * @property params The inline URI parameters of the request. + * @property queryParams The query parameters of the request. + * @property headers The headers of the request. + */ +data class RequestObject( + val uri: String, + val path: String, + val remainingPath: String, + val method: HttpMethod, + val body: String, + val params: Map, + val queryParams: Map, + val headers: Map +) { + /** + * Converts the body of the request to a JSON object of the specified type. + * + * @return The JSON object of the specified type. + */ inline fun asJson(): T { - return Gson().fromJson(content, T::class.java) + return Gson().fromJson(body, T::class.java) } -} +} \ 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 7bc4475..84b5bc5 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/rest/RouteControl.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/rest/RouteControl.kt @@ -21,144 +21,242 @@ package net.ccbluex.netty.http.rest import io.netty.handler.codec.http.FullHttpResponse import io.netty.handler.codec.http.HttpMethod -import net.ccbluex.netty.http.HttpServer.Companion.logger import net.ccbluex.netty.http.util.httpFile import net.ccbluex.netty.http.util.httpForbidden import net.ccbluex.netty.http.util.httpNotFound import net.ccbluex.netty.http.model.RequestObject import java.io.File -class RouteController : RestNode("") +/** + * Controller for handling routing of HTTP requests. + */ +class RouteController : Node("") { + + /** + * Data class representing a destination node in the routing tree. + * + * @property length The length of the path to the destination. + * @property destination The destination node. + * @property params The parameters extracted from the path. + * @property remainingPath The remaining part of the path after reaching the destination. + */ + internal data class Destination( + val destination: Node, + val params: Map, + val remainingPath: String + ) + + /** + * Finds the destination node for a given path and HTTP method. + * + * @param path The path to find the destination for. + * @param method The HTTP method of the request. + * @return The destination node and associated information, or null if no destination is found. + */ + internal fun processPath(path: String, method: HttpMethod): Destination? { + val pathArray = path.asPathArray() + .also { if (it.isEmpty()) throw IllegalArgumentException("Path cannot be empty") } + + return travelNode(this, pathArray, method, 0, mutableMapOf()) + } + + /** + * Recursively find the destination node by traversing through deeper nodes first. + * + * @param currentNode The current node in the traversal. + * @param pathArray The array of path segments. + * @param method The HTTP method of the request. + * @param index The current index in the path array. + * @param params The map of parameters extracted from the path. + * @return The destination node and associated information, or null if no destination is found. + */ + private fun travelNode( + currentNode: Node, + pathArray: Array, + method: HttpMethod, + index: Int, + params: MutableMap + ): Destination? { + if (index < pathArray.size) { + val part = pathArray[index] + val matchingNodes = currentNode.nodes.filter { it.matches(index, part) } + + for (node in matchingNodes) { + val newParams = params.toMutableMap() + + if (node.isParam) { + newParams[node.part.substring(1)] = part + } + + val result = travelNode(node, pathArray, method, index + 1, newParams) + if (result != null) { + return result + } + } + } + return if (currentNode.matchesMethod(method)) { + Destination(currentNode, params, pathArray.joinToString("/")) + } else { + null + } + } + +} + +/** + * Represents a node in the routing tree. + * + * @property part The part of the path this node represents. + */ @Suppress("TooManyFunctions") -open class RestNode(val path: String) { +open class Node(val part: String) { + + open val isRoot = part.isEmpty() + open val isExecutable = false + val isParam = part.startsWith(":") - private val nodes = mutableListOf() + internal val nodes = mutableListOf() + + init { + if (part.contains("/")) { + error("Part cannot contain slashes") + } + } + + /** + * Adds a path to the routing tree and executes a block to configure the path. + * + * @param path The path of the route. + * @param block The block to execute for the path. + */ + fun withPath(path: String, block: Node.() -> Unit) { + chain({ Node(it).apply(block) }, *path.asPathArray()) + } + + /** + * Adds a route to the routing tree. + * + * @param path The path of the route. + * @param method The HTTP method of the route. + * @param handler The handler function for the route. + * @return The node representing the route. + */ + fun route(path: String, method: HttpMethod, handler: (RequestObject) -> FullHttpResponse) = + chain({ Route(it, method, handler) }, *path.asPathArray()) - fun new(path: String) = RestNode(path).also { nodes += it } + /** + * Adds a file servant to the routing tree. + * + * @param path The path of the file servant. + * @param baseFolder The base folder for serving files. + * @return The node representing the file servant. + */ + fun file(path: String, baseFolder: File) = + chain({ FileServant(it, baseFolder) }, *path.asPathArray()) fun get(path: String, handler: (RequestObject) -> FullHttpResponse) - = Route(path, HttpMethod.GET, handler).also { nodes += it } + = route(path, HttpMethod.GET, handler) fun post(path: String, handler: (RequestObject) -> FullHttpResponse) - = Route(path, HttpMethod.POST, handler).also { nodes += it } + = route(path, HttpMethod.POST, handler) fun put(path: String, handler: (RequestObject) -> FullHttpResponse) - = Route(path, HttpMethod.PUT, handler).also { nodes += it } + = route(path, HttpMethod.PUT, handler) fun delete(path: String, handler: (RequestObject) -> FullHttpResponse) - = Route(path, HttpMethod.DELETE, handler).also { nodes += it } + = route(path, HttpMethod.DELETE, handler) fun patch(path: String, handler: (RequestObject) -> FullHttpResponse) - = Route(path, HttpMethod.PATCH, handler).also { nodes += it } + = route(path, HttpMethod.PATCH, handler) fun head(path: String, handler: (RequestObject) -> FullHttpResponse) - = Route(path, HttpMethod.HEAD, handler).also { nodes += it } + = route(path, HttpMethod.HEAD, handler) fun options(path: String, handler: (RequestObject) -> FullHttpResponse) - = Route(path, HttpMethod.OPTIONS, handler).also { nodes += it } + = route(path, HttpMethod.OPTIONS, handler) fun trace(path: String, handler: (RequestObject) -> FullHttpResponse) - = Route(path, HttpMethod.TRACE, handler).also { nodes += it } - - fun file(path: String, baseFolder: File) = FileServant(path, baseFolder).also { nodes += it } + = route(path, HttpMethod.TRACE, handler) /** - * Find a route for the given URI path and http method + * Chains nodes together to form a path in the routing tree. * - * @param path URI path - * @param method HTTP method - * @return Route or null if no route was found - * - * @example findRoute("/api/v1/users", HttpMethod.GET) + * @param destination The function to create the destination node. + * @param parts The parts of the path. + * @return The final node in the chain. */ - fun findRoute(path: String, method: HttpMethod): Route? { - logger.debug("FR --------- ${if (this is Route) "ROUTE" else "NODE"} ${this.path} ---------") - - val takenOff = path.substring(this.path.length) - logger.debug("Search for route: {} {}", method, takenOff) - - // Nodes can now either include a route with the correct method or a node with a path that matches - // the given path - val nodes = nodes.filter { - logger.debug("Check node: $takenOff startsWith ${it.path} -> ${path.startsWith(it.path)}") - takenOff.startsWith(it.path) - } - - logger.debug("Found ${nodes.size} nodes") - - // Now we have to decide if the route matches the path exactly or if it is a node step - val exactMatch = nodes.filterIsInstance().find { - logger.debug("Check route: {} == {} && {} == {}", it.path, takenOff, it.method, method) - it.method == method && it.path == takenOff - } - if (exactMatch != null) { - return exactMatch + private fun chain(destination: (String) -> Node, vararg parts: String): Node { + return when (parts.size) { + 0 -> throw IllegalArgumentException("Parts cannot be empty") + 1 -> destination(parts[0]).also { nodes += it } + else -> { + val node = nodes.find { it.part == parts[0] } ?: Node(parts[0]).also { nodes += it } + node.chain(destination, *parts.copyOfRange(1, parts.size)) + } } - - logger.debug("No exact match found") - - // If we have no exact match we have to find the node that matches the path - val nodeMatch = nodes.firstOrNull() ?: return null - logger.debug("Found node match: ${nodeMatch.path}") - return nodeMatch.findRoute(takenOff, method) } /** - * Find a route for the given URI path and http method + * Handles an HTTP request. * - * @param path URI path - * @param method HTTP method - * @return Route or null if no route was found - * - * @example findRoute("/api/v1/users", HttpMethod.GET) + * @param requestObject The request object. + * @return The HTTP response. */ - fun findFileServant(path: String): Pair? { - logger.debug("FFS --------- ${if (this is Route) "ROUTE" else "NODE"} ${this.path} ---------") - - val takenOff = path.substring(this.path.length) - logger.debug("Search for file servant: {}", takenOff) - - // Nodes can now either include a route with the correct method or a node with a path that matches - // the given path - val nodes = nodes.filter { - logger.debug("Check node: $takenOff startsWith ${it.path} -> ${path.startsWith(it.path)}") - takenOff.startsWith(it.path) - } + open fun handleRequest(requestObject: RequestObject): FullHttpResponse { + error("Node does not implement handleRequest") + } - logger.debug("Found ${nodes.size} nodes") + /** + * Checks if the node matches a part of the path and HTTP method. + * + * @param part The part of the path. + * @param method The HTTP method. + * @return True if the node matches, false otherwise. + */ + open fun matches(index: Int, part: String) = + this.part.equals(part, true) || isParam - // Now we have to decide if the file servant matches the path exactly or if it is a node step - val exactMatch = nodes.filterIsInstance().firstOrNull() - if (exactMatch != null) { - return exactMatch to takenOff - } + /** + * Checks if the node matches a part of the path and HTTP method. + */ + open fun matchesMethod(method: HttpMethod) = isExecutable - logger.debug("No exact match found") +} - // If we have no exact match we have to find the node that matches the path - val nodeMatch = nodes.firstOrNull() ?: return null - logger.debug("Found node match: ${nodeMatch.path}") - return nodeMatch.findFileServant(takenOff) - } +/** + * Represents a route in the routing tree. + * + * @property part The part of the path this node represents. + * @property method The HTTP method of the route. + * @property handler The handler function for the route. + */ +open class Route(name: String, private val method: HttpMethod, val handler: (RequestObject) -> FullHttpResponse) + : Node(name) { + override val isExecutable = true + override fun handleRequest(requestObject: RequestObject) = handler(requestObject) + override fun matchesMethod(method: HttpMethod) = + this.method == method && super.matchesMethod(method) } -open class Route(name: String, val method: HttpMethod, val handler: (RequestObject) -> FullHttpResponse) - : RestNode(name) +/** + * Represents a file servant in the routing tree. + * + * @property part The part of the path this node represents. + * @property baseFolder The base folder for serving files. + */ +class FileServant(part: String, private val baseFolder: File) : Node(part) { -class FileServant(val name: String, private val baseFolder: File) : RestNode(name) { + override val isExecutable = true - internal fun handleFileRequest(path: String): FullHttpResponse { - val filePath = path.substring(name.length).let { - if (it.startsWith("/")) it.substring(1) else it - } - - val sanitizedPath = filePath.replace("..", "") + override fun handleRequest(requestObject: RequestObject): FullHttpResponse { + val path = requestObject.remainingPath + val sanitizedPath = path.replace("..", "") val file = baseFolder.resolve(sanitizedPath) return when { - !file.exists() -> httpNotFound(filePath, "File not found") + !file.exists() -> httpNotFound(path, "File not found") !file.isFile -> { val indexFile = file.resolve("index.html") @@ -172,4 +270,16 @@ class FileServant(val name: String, private val baseFolder: File) : RestNode(nam } } + override fun matches(index: Int, part: String) = super.matches(index, part) || index == 0 && isRoot + + override fun matchesMethod(method: HttpMethod) = + method == HttpMethod.GET && super.matchesMethod(method) + } + +/** + * Convert a string to an array of path parts and drop the first empty part. + * + * @return An array of path parts. + */ +private fun String.asPathArray() = split("/").drop(1).toTypedArray() \ No newline at end of file diff --git a/src/main/kotlin/net/ccbluex/netty/http/websocket/WebSocketController.kt b/src/main/kotlin/net/ccbluex/netty/http/websocket/WebSocketController.kt index c6fd6a0..12dcfb6 100644 --- a/src/main/kotlin/net/ccbluex/netty/http/websocket/WebSocketController.kt +++ b/src/main/kotlin/net/ccbluex/netty/http/websocket/WebSocketController.kt @@ -20,6 +20,7 @@ package net.ccbluex.netty.http.websocket import io.netty.channel.ChannelHandlerContext +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame import io.netty.handler.codec.http.websocketx.WebSocketFrame /** @@ -39,12 +40,12 @@ class WebSocketController { * @param message The message to broadcast. * @param failure The action to take if a failure occurs. */ - fun broadcast(message: WebSocketFrame, failure: (ChannelHandlerContext, Throwable) -> Unit = { _, _ -> }) { - activeContexts.forEach { + fun broadcast(text: String, failure: (ChannelHandlerContext, Throwable) -> Unit = { _, _ -> }) { + activeContexts.forEach { handlerContext -> try { - it.writeAndFlush(message) + handlerContext.channel().writeAndFlush(TextWebSocketFrame(text)) } catch (e: Throwable) { - failure(it, e) + failure(handlerContext, e) } } } @@ -53,8 +54,8 @@ class WebSocketController { * Closes all active contexts. */ fun closeAll() { - activeContexts.forEach { - it.close() + activeContexts.forEach { handlerContext -> + handlerContext.channel().close() } } diff --git a/src/test/kotlin/HttpServerTest.kt b/src/test/kotlin/HttpServerTest.kt index af75203..9fb5ee4 100644 --- a/src/test/kotlin/HttpServerTest.kt +++ b/src/test/kotlin/HttpServerTest.kt @@ -1,41 +1,361 @@ -import io.netty.channel.ChannelHandlerContext -import io.netty.handler.codec.http.websocketx.TextWebSocketFrame +import com.google.gson.JsonObject +import io.netty.handler.codec.http.FullHttpResponse import net.ccbluex.netty.http.HttpServer -import org.junit.jupiter.api.Test -import org.mockito.Mockito.* +import net.ccbluex.netty.http.model.RequestObject +import net.ccbluex.netty.http.util.httpOk +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.junit.jupiter.api.* +import java.io.File +import java.nio.file.Files +import kotlin.concurrent.thread +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +/** + * Test class for the HttpServer, focusing on verifying the routing capabilities + * and correctness of responses from different endpoints. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) class HttpServerTest { - @Test - fun broadcast_sendsMessageToAllActiveContexts() { + private lateinit var folder: File + private lateinit var serverThread: Thread + private val client = OkHttpClient() + + /** + * This method sets up the necessary environment before any tests are run. + * It creates a temporary directory with dummy files and starts the HTTP server + * in a separate thread. + */ + @BeforeAll + fun initialize() { + // Create a temporary folder with dummy data + folder = Files.createTempDirectory("netty-rest-test").toFile() + File(folder, "fa.txt").writeText("A") + File(folder, "fb.txt").writeText("B") + File(folder, "fc.txt").writeText("C") + + // sub-folder with index.html + val subFolder = File(folder, "sub") + subFolder.mkdir() + File(subFolder, "index.html").writeText("Hello, World!") + + // Start the HTTP server in a separate thread + serverThread = thread { + startHttpServer(folder) + } + + // Allow the server some time to start + Thread.sleep(1000) + } + + /** + * This method cleans up resources after all tests have been executed. + * It stops the server and deletes the temporary directory. + */ + @AfterAll + fun cleanup() { + serverThread.interrupt() + folder.deleteRecursively() // Clean up the temporary folder + } + + /** + * This function starts the HTTP server with routing configured for + * different difficulty levels. + */ + private fun startHttpServer(folder: File) { val server = HttpServer() - val ctx1 = mock(ChannelHandlerContext::class.java) - val ctx2 = mock(ChannelHandlerContext::class.java) - server.webSocketController.activeContexts.add(ctx1) - server.webSocketController.activeContexts.add(ctx2) - val message = TextWebSocketFrame("Test message") - server.webSocketController.broadcast(message) + server.routeController.apply { + // Routes with difficulty levels + get("/a", ::a) + get("/b", ::b) + get("/c", ::c) + get("/v/:name", ::param) + get("/r/:value1/:value2", ::params) + get("/o/:value1/in/:value2", ::params) + get("/m/a/b", ::b) + get("/m/a/c", ::c) + get("/m/b/a", ::a) + get("/m/b/c", ::c) + get("/m/c/a", ::a) + + get("/m/c/b", ::b) + + get("/h/a/b", ::b) + get("/h/a", ::a) - verify(ctx1).writeAndFlush(message) - verify(ctx2).writeAndFlush(message) + delete("/api/v1/s/s", ::static) + get("/api/v1/s/s", ::static) + get("/api/v1/s", ::static) + + get("/", ::static) + file("/", folder) + } + + server.start(8080) // Start the server on port 8080 + } + + @Suppress("UNUSED_PARAMETER") + fun static(requestObject: RequestObject): FullHttpResponse { + return httpOk(JsonObject().apply { + addProperty("message", "Hello, World!") + }) + } + + fun param(requestObject: RequestObject): FullHttpResponse { + return httpOk(JsonObject().apply { + addProperty("message", "Hello, ${requestObject.params["name"]}") + }) } + fun params(requestObject: RequestObject): FullHttpResponse { + return httpOk(JsonObject().apply { + addProperty("message", "Hello, ${requestObject.params["value1"]} and ${requestObject.params["value2"]}") + }) + } + + fun a(requestObject: RequestObject): FullHttpResponse { + return httpOk(JsonObject().apply { + addProperty("char", "A") + }) + } + + fun b(requestObject: RequestObject): FullHttpResponse { + return httpOk(JsonObject().apply { + addProperty("char", "B") + }) + } + + fun c(requestObject: RequestObject): FullHttpResponse { + return httpOk(JsonObject().apply { + addProperty("char", "C") + }) + } + + /** + * Utility function to make HTTP GET requests to the specified path. + * + * @param path The path for the request. + * @return The HTTP response. + */ + private fun makeRequest(path: String): Response { + val request = Request.Builder() + .url("http://localhost:8080$path") + .build() + return client.newCall(request).execute() + } + + /** + * Test the root endpoint ("/") and verify that it returns the correct number + * of files in the directory. + */ @Test - fun broadcast_handlesExceptions() { - val server = HttpServer() - val ctx1 = mock(ChannelHandlerContext::class.java) - val ctx2 = mock(ChannelHandlerContext::class.java) - server.webSocketController.activeContexts.add(ctx1) - server.webSocketController.activeContexts.add(ctx2) + fun testRootEndpoint() { + val response = makeRequest("/") + assertEquals(200, response.code(), "Expected status code 200") + + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null") + + assertTrue(responseBody.contains("Hello, World!"), "Response should contain 'Hello, World!'") + } + + /** + * Test the "/a" endpoint and verify that it returns the correct character. + */ + @Test + fun testAEndpoint() { + val response = makeRequest("/a") + assertEquals(200, response.code(), "Expected status code 200") + + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null") + + assertTrue(responseBody.contains("\"char\":\"A\""), "Response should contain char 'A'") + } + + /** + * Test the "/b" endpoint and verify that it returns the correct character. + */ + @Test + fun testBEndpoint() { + val response = makeRequest("/b") + assertEquals(200, response.code(), "Expected status code 200") + + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null") + + assertTrue(responseBody.contains("\"char\":\"B\""), "Response should contain char 'B'") + } + + /** + * Test the "/c" endpoint and verify that it returns the correct character. + */ + @Test + fun testCEndpoint() { + val response = makeRequest("/c") + assertEquals(200, response.code(), "Expected status code 200") - val message = TextWebSocketFrame("Test message") - doThrow(RuntimeException::class.java).`when`(ctx1).writeAndFlush(message) + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null") - server.webSocketController.broadcast(message) + assertTrue(responseBody.contains("\"char\":\"C\""), "Response should contain char 'C'") + } + + /** + * Test the "/v/:name" endpoint with a parameter and verify that it returns the correct message. + */ + @Test + fun testVParamEndpoint() { + val response = makeRequest("/v/Alice") + assertEquals(200, response.code(), "Expected status code 200") + + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null") + + assertTrue(responseBody.contains("Hello, Alice"), "Response should contain 'Hello, Alice'") + } + + /** + * Test the "/r/:value1/:value2" endpoint with multiple parameters and verify that it returns the correct message. + */ + @Test + fun testRParamsEndpoint() { + val response = makeRequest("/r/Alice/Bob") + assertEquals(200, response.code(), "Expected status code 200") + + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null") + + assertTrue(responseBody.contains("Hello, Alice and Bob"), "Response should contain 'Hello, Alice and Bob'") + } - verify(ctx1).writeAndFlush(message) - verify(ctx2).writeAndFlush(message) + /** + * Test the "/o/:value1/in/:value2" endpoint with multiple parameters and verify that it returns the correct message. + */ + @Test + fun testOParamsEndpoint() { + val response = makeRequest("/o/Alice/in/Bob") + assertEquals(200, response.code(), "Expected status code 200") + + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null") + + assertTrue(responseBody.contains("Hello, Alice and Bob"), "Response should contain 'Hello, Alice and Bob'") } -} \ No newline at end of file + /** + * Test various medium difficulty endpoints and verify correct responses. + */ + @Test + fun testMultipleEndpoints() { + val endpoints = listOf("/m/a/b", "/m/a/c", "/m/b/a", "/m/b/c", "/m/c/a", "/m/c/b") + + endpoints.forEach { endpoint -> + val response = makeRequest(endpoint) + assertEquals(200, response.code(), "Expected status code 200 for $endpoint") + + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null for $endpoint") + + // Example assertions; adjust according to the expected behavior of each endpoint + assertTrue( + responseBody.contains("\"char\":\"A\"") || + responseBody.contains("\"char\":\"B\"") || + responseBody.contains("\"char\":\"C\""), + "Unexpected response for $endpoint" + ) + } + } + + /** + * Test various hard difficulty endpoints and verify correct responses. + */ + @Test + fun testStackedEndpoints() { + val endpoints = listOf("/h/a/b", "/h/a") + + endpoints.forEach { endpoint -> + val response = makeRequest(endpoint) + assertEquals(200, response.code(), "Expected status code 200 for $endpoint") + + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null for $endpoint") + + // Example assertions; adjust according to the expected behavior of each endpoint + assertTrue( + responseBody.contains("\"char\":\"A\"") || responseBody.contains("\"char\":\"B\""), + "Unexpected response for $endpoint" + ) + } + } + + @Test + fun testNonExistentEndpoint() { + val response = makeRequest("/nonexistent") + assertEquals(404, response.code(), "Expected status code 404") + } + + @Test + fun testFileEndpoint() { + val files = folder.list() ?: emptyArray() + + files.forEach { file -> + val response = makeRequest("/$file") + assertEquals(200, response.code(), "Expected status code 200 for $file") + + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null for $file") + + val file = File(folder, file) + val expected = if (file.isDirectory) { + File(file, "index.html") + } else { + file + }.readText() + + assertEquals(expected, responseBody, "Response should match file content") + } + } + + @Test + fun testApiEndpoints() { + val endpoints = listOf("/api/v1/s", "/api/v1/s/s") + + endpoints.forEach { endpoint -> + val response = makeRequest(endpoint) + assertEquals(200, response.code(), "Expected status code 200 for $endpoint") + + val responseBody = response.body()?.string() + assertNotNull(responseBody, "Response body should not be null for $endpoint") + + assertTrue(responseBody.contains("Hello, World!"), "Response should contain 'Hello, World!'") + } + } + + /** + * Test the "/h/a" file servant endpoint and verify that it returns the correct file content. + * This test is expected to fail due to the conflict with the "/h/a" route. + * + * Since this matter is not worth fixing, the test is commented out. + */ +// @Test +// fun testConflictingFileEndpoint() { +// val files = folder.list() ?: emptyArray() +// +// files.forEach { file -> +// val response = makeRequest("/h/a/$file") +// assertEquals(200, response.code(), "Expected status code 200 for $file") +// +// val responseBody = response.body()?.string() +// assertNotNull(responseBody, "Response body should not be null for $file") +// +// assertEquals(File(folder, file).readText(), responseBody, "Response should match file content") +// } +// } + +}