Skip to content

Commit af40145

Browse files
committed
release: 2.1.0
- Introduces support for HTTP Response middleware - Generic CORS middleware - Adds various useful response utils
1 parent 2818977 commit af40145

File tree

8 files changed

+291
-12
lines changed

8 files changed

+291
-12
lines changed

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ val authorName = "ccbluex"
1111
val projectUrl = "https://github.com/ccbluex/netty-httpserver"
1212

1313
group = "net.ccbluex"
14-
version = "2.0.0"
14+
version = "2.1.0"
1515

1616
repositories {
1717
mavenCentral()

src/main/kotlin/net/ccbluex/netty/http/HttpConductor.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,6 @@ internal class HttpConductor(private val server: HttpServer) {
5858
val httpHeaders = response.headers()
5959
httpHeaders[HttpHeaderNames.CONTENT_TYPE] = "text/plain"
6060
httpHeaders[HttpHeaderNames.CONTENT_LENGTH] = response.content().readableBytes()
61-
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN] = "*"
62-
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS] = "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"
63-
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS] = "Content-Type, Content-Length, Authorization, Accept, X-Requested-With"
6461
return@runCatching response
6562
}
6663

src/main/kotlin/net/ccbluex/netty/http/HttpServer.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,17 @@ import io.netty.channel.epoll.EpollEventLoopGroup
2626
import io.netty.channel.epoll.EpollServerSocketChannel
2727
import io.netty.channel.nio.NioEventLoopGroup
2828
import io.netty.channel.socket.nio.NioServerSocketChannel
29+
import io.netty.handler.codec.http.FullHttpResponse
2930
import io.netty.handler.logging.LogLevel
3031
import io.netty.handler.logging.LoggingHandler
32+
import net.ccbluex.netty.http.middleware.Middleware
33+
import net.ccbluex.netty.http.middleware.MiddlewareFunction
34+
import net.ccbluex.netty.http.model.RequestContext
3135
import net.ccbluex.netty.http.rest.RouteController
3236
import net.ccbluex.netty.http.websocket.WebSocketController
3337
import org.apache.logging.log4j.LogManager
3438

39+
3540
/**
3641
* NettyRest - A Web Rest-API server with support for WebSocket and File Serving using Netty.
3742
*
@@ -42,10 +47,20 @@ class HttpServer {
4247
val routeController = RouteController()
4348
val webSocketController = WebSocketController()
4449

50+
val middlewares = mutableListOf<MiddlewareFunction>()
51+
4552
companion object {
4653
internal val logger = LogManager.getLogger("HttpServer")
4754
}
4855

56+
fun middleware(middlewareFunction: MiddlewareFunction) {
57+
middlewares += middlewareFunction
58+
}
59+
60+
fun middleware(middleware: Middleware) {
61+
middlewares += middleware::middleware
62+
}
63+
4964
/**
5065
* Starts the Netty server on the specified port.
5166
*/

src/main/kotlin/net/ccbluex/netty/http/HttpServerHandler.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,11 @@ internal class HttpServerHandler(private val server: HttpServer) : ChannelInboun
109109
// If this is the last content, process the request
110110
if (msg is LastHttpContent) {
111111
localRequestContext.remove()
112-
112+
113113
val httpConductor = HttpConductor(server)
114114
val response = httpConductor.processRequestContext(requestContext)
115-
ctx.writeAndFlush(response)
115+
val httpResponse = server.middlewares.fold(response) { acc, f -> f(requestContext, acc) }
116+
ctx.writeAndFlush(httpResponse)
116117
}
117118
}
118119

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package net.ccbluex.netty.http.middleware
2+
3+
import io.netty.handler.codec.http.FullHttpResponse
4+
import io.netty.handler.codec.http.HttpHeaderNames
5+
import net.ccbluex.netty.http.HttpServer.Companion.logger
6+
import net.ccbluex.netty.http.model.RequestContext
7+
import java.net.URI
8+
import java.net.URISyntaxException
9+
10+
/**
11+
* Middleware to handle Cross-Origin Resource Sharing (CORS) requests.
12+
*
13+
* @param allowedOrigins List of allowed (host) origins (default: localhost, 127.0.0.1)
14+
* - If we want to specify a protocol and port, we should use the full origin (e.g., http://localhost:8080).
15+
* @param allowedMethods List of allowed HTTP methods (default: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)
16+
* @param allowedHeaders List of allowed HTTP headers (default: Content-Type, Content-Length, Authorization, Accept, X-Requested-With)
17+
*
18+
* @see RequestContext
19+
*/
20+
class CorsMiddleware(
21+
private val allowedOrigins: List<String> =
22+
listOf("localhost", "127.0.0.1"),
23+
private val allowedMethods: List<String> =
24+
listOf("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"),
25+
private val allowedHeaders: List<String> =
26+
listOf("Content-Type", "Content-Length", "Authorization", "Accept", "X-Requested-With")
27+
): Middleware {
28+
29+
/**
30+
* Middleware to handle CORS requests.
31+
* Pass to server.middleware() to apply the CORS policy to all requests.
32+
*/
33+
override fun middleware(context: RequestContext, response: FullHttpResponse): FullHttpResponse {
34+
val httpHeaders = response.headers()
35+
val requestOrigin = context.headers["origin"] ?: context.headers["Origin"]
36+
37+
if (requestOrigin != null) {
38+
try {
39+
// Parse the origin to extract the hostname (ignoring the port)
40+
val uri = URI(requestOrigin)
41+
val host = uri.host
42+
43+
// Allow requests from localhost or 127.0.0.1 regardless of the port
44+
if (allowedOrigins.contains(host) || allowedOrigins.contains(requestOrigin)) {
45+
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN] = requestOrigin
46+
} else {
47+
// Block cross-origin requests by not allowing other origins
48+
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN] = "null"
49+
}
50+
} catch (e: URISyntaxException) {
51+
// Handle bad URIs by setting a default CORS policy or logging the error
52+
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN] = "null"
53+
logger.error("Invalid Origin header: $requestOrigin", e)
54+
}
55+
56+
// Allow specific methods and headers for cross-origin requests
57+
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS] = allowedMethods.joinToString(", ")
58+
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS] = allowedHeaders.joinToString(", ")
59+
}
60+
61+
return response
62+
}
63+
64+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package net.ccbluex.netty.http.middleware
2+
3+
import io.netty.handler.codec.http.FullHttpResponse
4+
import net.ccbluex.netty.http.model.RequestContext
5+
6+
typealias MiddlewareFunction = (RequestContext, FullHttpResponse) -> FullHttpResponse
7+
8+
interface Middleware {
9+
fun middleware(context: RequestContext, response: FullHttpResponse): FullHttpResponse
10+
}

src/main/kotlin/net/ccbluex/netty/http/util/HttpResponse.kt

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,7 @@ fun httpResponse(status: HttpResponseStatus, contentType: String = "text/plain",
4747
val httpHeaders = response.headers()
4848
httpHeaders[HttpHeaderNames.CONTENT_TYPE] = contentType
4949
httpHeaders[HttpHeaderNames.CONTENT_LENGTH] = response.content().readableBytes()
50-
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN] = "*"
51-
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS] = "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"
52-
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS] = "Content-Type, Content-Length, Authorization, Accept, X-Requested-With"
50+
5351
return response
5452
}
5553

@@ -140,7 +138,6 @@ fun httpFile(file: File): FullHttpResponse {
140138
val httpHeaders = response.headers()
141139
httpHeaders[HttpHeaderNames.CONTENT_TYPE] = tika.detect(file)
142140
httpHeaders[HttpHeaderNames.CONTENT_LENGTH] = response.content().readableBytes()
143-
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN] = "*"
144141
return response
145142
}
146143

@@ -162,7 +159,70 @@ fun httpFileStream(stream: InputStream): FullHttpResponse {
162159
val httpHeaders = response.headers()
163160
httpHeaders[HttpHeaderNames.CONTENT_TYPE] = tika.detect(bytes)
164161
httpHeaders[HttpHeaderNames.CONTENT_LENGTH] = response.content().readableBytes()
165-
httpHeaders[HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN] = "*"
166162

167163
return response
168-
}
164+
}
165+
166+
/**
167+
* Creates an HTTP 204 No Content response.
168+
*
169+
* @return A FullHttpResponse object.
170+
*/
171+
fun httpNoContent(): FullHttpResponse {
172+
val response = DefaultFullHttpResponse(
173+
HttpVersion.HTTP_1_1,
174+
HttpResponseStatus.NO_CONTENT
175+
)
176+
177+
val httpHeaders = response.headers()
178+
httpHeaders[HttpHeaderNames.CONTENT_LENGTH] = 0
179+
return response
180+
}
181+
182+
/**
183+
* Creates an HTTP 405 Method Not Allowed response with the given method.
184+
*
185+
* @param method The method that is not allowed.
186+
* @return A FullHttpResponse object.
187+
*/
188+
fun httpMethodNotAllowed(method: String): FullHttpResponse {
189+
val jsonObject = JsonObject()
190+
jsonObject.addProperty("method", method)
191+
return httpResponse(HttpResponseStatus.METHOD_NOT_ALLOWED, jsonObject)
192+
}
193+
194+
/**
195+
* Creates an HTTP 401 Unauthorized response with the given reason.
196+
*
197+
* @param reason The reason for the 401 error.
198+
* @return A FullHttpResponse object.
199+
*/
200+
fun httpUnauthorized(reason: String): FullHttpResponse {
201+
val jsonObject = JsonObject()
202+
jsonObject.addProperty("reason", reason)
203+
return httpResponse(HttpResponseStatus.UNAUTHORIZED, jsonObject)
204+
}
205+
206+
/**
207+
* Creates an HTTP 429 Too Many Requests response with the given reason.
208+
*
209+
* @param reason The reason for the 429 error.
210+
* @return A FullHttpResponse object.
211+
*/
212+
fun httpTooManyRequests(reason: String): FullHttpResponse {
213+
val jsonObject = JsonObject()
214+
jsonObject.addProperty("reason", reason)
215+
return httpResponse(HttpResponseStatus.TOO_MANY_REQUESTS, jsonObject)
216+
}
217+
218+
/**
219+
* Creates an HTTP 503 Service Unavailable response with the given reason.
220+
*
221+
* @param reason The reason for the 503 error.
222+
* @return A FullHttpResponse object.
223+
*/
224+
fun httpServiceUnavailable(reason: String): FullHttpResponse {
225+
val jsonObject = JsonObject()
226+
jsonObject.addProperty("reason", reason)
227+
return httpResponse(HttpResponseStatus.SERVICE_UNAVAILABLE, jsonObject)
228+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import com.google.gson.JsonObject
2+
import io.netty.handler.codec.http.FullHttpResponse
3+
import net.ccbluex.netty.http.HttpServer
4+
import net.ccbluex.netty.http.model.RequestObject
5+
import net.ccbluex.netty.http.util.httpOk
6+
import okhttp3.OkHttpClient
7+
import okhttp3.Request
8+
import okhttp3.Response
9+
import org.junit.jupiter.api.*
10+
import java.io.File
11+
import java.nio.file.Files
12+
import kotlin.concurrent.thread
13+
import kotlin.test.assertEquals
14+
import kotlin.test.assertNotNull
15+
import kotlin.test.assertTrue
16+
17+
/**
18+
* Test class for the HttpServer, focusing on verifying the routing capabilities
19+
* and correctness of responses from different endpoints.
20+
*/
21+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
22+
class HttpMiddlewareServerTest {
23+
24+
private lateinit var serverThread: Thread
25+
private val client = OkHttpClient()
26+
27+
/**
28+
* This method sets up the necessary environment before any tests are run.
29+
* It creates a temporary directory with dummy files and starts the HTTP server
30+
* in a separate thread.
31+
*/
32+
@BeforeAll
33+
fun initialize() {
34+
// Start the HTTP server in a separate thread
35+
serverThread = thread {
36+
startHttpServer()
37+
}
38+
39+
// Allow the server some time to start
40+
Thread.sleep(1000)
41+
}
42+
43+
/**
44+
* This method cleans up resources after all tests have been executed.
45+
* It stops the server and deletes the temporary directory.
46+
*/
47+
@AfterAll
48+
fun cleanup() {
49+
serverThread.interrupt()
50+
}
51+
52+
/**
53+
* This function starts the HTTP server with routing configured for
54+
* different difficulty levels.
55+
*/
56+
private fun startHttpServer() {
57+
val server = HttpServer()
58+
59+
server.routeController.apply {
60+
get("/", ::static)
61+
}
62+
63+
server.middleware { requestContext, fullHttpResponse ->
64+
// Add custom headers to the response
65+
fullHttpResponse.headers().add("X-Custom-Header", "Custom Value")
66+
67+
// Add a custom header if there is a query parameter
68+
if (requestContext.params.isNotEmpty()) {
69+
fullHttpResponse.headers().add("X-Query-Param",
70+
requestContext.params.entries.joinToString(","))
71+
}
72+
73+
fullHttpResponse
74+
}
75+
76+
server.start(8080) // Start the server on port 8080
77+
}
78+
79+
@Suppress("UNUSED_PARAMETER")
80+
fun static(requestObject: RequestObject): FullHttpResponse {
81+
return httpOk(JsonObject().apply {
82+
addProperty("message", "Hello, World!")
83+
})
84+
}
85+
86+
/**
87+
* Utility function to make HTTP GET requests to the specified path.
88+
*
89+
* @param path The path for the request.
90+
* @return The HTTP response.
91+
*/
92+
private fun makeRequest(path: String): Response {
93+
val request = Request.Builder()
94+
.url("http://localhost:8080$path")
95+
.build()
96+
return client.newCall(request).execute()
97+
}
98+
99+
/**
100+
* Test the root endpoint ("/") and verify that it returns the correct number
101+
* of files in the directory.
102+
*/
103+
@Test
104+
fun testRootEndpoint() {
105+
val response = makeRequest("/")
106+
assertEquals(200, response.code(), "Expected status code 200")
107+
108+
val responseBody = response.body()?.string()
109+
assertNotNull(responseBody, "Response body should not be null")
110+
111+
assertTrue(responseBody.contains("Hello, World!"), "Response should contain 'Hello, World!'")
112+
}
113+
114+
/**
115+
* Test the root endpoint ("/") with a query parameter and verify that the
116+
* custom header is added to the response.
117+
*/
118+
@Test
119+
fun testRootEndpointWithQueryParam() {
120+
val response = makeRequest("/?param1=value1&param2=value2")
121+
assertEquals(200, response.code(), "Expected status code 200")
122+
123+
val responseBody = response.body()?.string()
124+
assertNotNull(responseBody, "Response body should not be null")
125+
126+
assertTrue(responseBody.contains("Hello, World!"), "Response should contain 'Hello, World!'")
127+
assertTrue(response.headers("X-Custom-Header").contains("Custom Value"), "Custom header should be present")
128+
assertTrue(response.headers("X-Query-Param").contains("param1=value1,param2=value2"),
129+
"Query parameter should be present in the response")
130+
}
131+
132+
}

0 commit comments

Comments
 (0)