Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.codegreen.api.infrastructure
import org.codegreen.api.content.ConcernBuilder
import org.codegreen.api.content.Handler
import org.codegreen.api.content.HandlerBuilder
import java.io.File
import java.net.InetAddress
import java.time.Duration

Expand All @@ -12,9 +13,6 @@ import java.time.Duration
* Mirrors GenHTTP's self-typed `IServerBuilder<T>` using covariant return overrides:
* each configuration method returns the builder so calls can be chained, and
* [ServerHost] narrows them to return itself.
*
* (Secure/TLS binding overloads — `Bind(..., certificate, ...)` — are a deferred part
* of the internal engine port and will be added with TLS support.)
*/
interface ServerBuilder : Builder<Server> {

Expand All @@ -26,6 +24,9 @@ interface ServerBuilder : Builder<Server> {
/** Registers an endpoint the server will bind to on startup. */
fun bind(address: InetAddress?, port: Int, dualStack: Boolean = true): ServerBuilder

/** Registers a TLS endpoint serving HTTPS with the given PEM certificate chain and private key. */
fun bind(address: InetAddress?, port: Int, certificate: File, key: File, dualStack: Boolean = true): ServerBuilder

// ---- Infrastructure ----

/** Registers a companion that logs all handled requests and errors to the console. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.codegreen.api.infrastructure
import org.codegreen.api.content.ConcernBuilder
import org.codegreen.api.content.Handler
import org.codegreen.api.content.HandlerBuilder
import java.io.File
import java.net.InetAddress
import java.time.Duration

Expand All @@ -19,6 +20,7 @@ interface ServerHost : ServerBuilder {

override fun port(port: Int): ServerHost
override fun bind(address: InetAddress?, port: Int, dualStack: Boolean): ServerHost
override fun bind(address: InetAddress?, port: Int, certificate: File, key: File, dualStack: Boolean): ServerHost
override fun console(): ServerHost
override fun companion(companion: ServerCompanion): ServerHost
override fun development(developmentMode: Boolean): ServerHost
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import io.netty.channel.ChannelOption
import io.netty.channel.EventLoopGroup
import io.netty.channel.socket.SocketChannel
import io.netty.channel.socket.nio.NioServerSocketChannel
import io.netty.handler.ssl.SslContext
import io.netty.handler.ssl.SslContextBuilder
import org.codegreen.api.infrastructure.EndPoint
import org.codegreen.api.infrastructure.EndPointCollection
import org.codegreen.engine.internal.infrastructure.ThreadedServer
Expand All @@ -21,8 +23,9 @@ class EndPointCollectionImpl(endpoints: List<EndPoint>) :
EndPointCollection, List<EndPoint> by endpoints

/**
* An insecure (plain HTTP) endpoint backed by a Netty `ServerBootstrap`. Each accepted
* channel gets a pipeline of [Glyph11RequestDecoder] → [HttpDispatchHandler].
* An endpoint backed by a Netty `ServerBootstrap`. Each accepted channel gets a pipeline of
* (optional TLS) → [Glyph11RequestDecoder] → [HttpDispatchHandler]; TLS is enabled when the
* endpoint configuration carries a [org.codegreen.engine.shared.infrastructure.SecurityConfiguration].
*/
class InternalEndPoint(
private val server: ThreadedServer,
Expand All @@ -32,7 +35,12 @@ class InternalEndPoint(
override val address: InetAddress? get() = config.address
override val port: Int get() = config.port
override val dualStack: Boolean get() = config.dualStack
override val secure: Boolean = false
override val secure: Boolean = config.security != null

// Built once per endpoint from the configured PEM certificate chain + PKCS#8 private key.
private val sslContext: SslContext? = config.security?.let { security ->
SslContextBuilder.forServer(security.certificate, security.key).build()
}

private var channel: Channel? = null

Expand All @@ -45,6 +53,8 @@ class InternalEndPoint(
.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
.childHandler(object : ChannelInitializer<SocketChannel>() {
override fun initChannel(ch: SocketChannel) {
// TLS terminates first so the rest of the pipeline sees plaintext HTTP/1.1.
sslContext?.let { ch.pipeline().addLast(it.newHandler(ch.alloc())) }
ch.pipeline().addLast(
Glyph11RequestDecoder(server.network),
HttpDispatchHandler(server, this@InternalEndPoint),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import org.codegreen.api.infrastructure.Server
import org.codegreen.api.infrastructure.ServerBuilder
import org.codegreen.api.infrastructure.ServerCompanion
import org.codegreen.api.infrastructure.ServerHost
import java.io.File
import java.net.InetAddress
import java.time.Duration

Expand All @@ -20,6 +21,7 @@ class ServerHostImpl(private val builder: ServerBuilder) : ServerHost {

override fun port(port: Int): ServerHost = apply { builder.port(port) }
override fun bind(address: InetAddress?, port: Int, dualStack: Boolean): ServerHost = apply { builder.bind(address, port, dualStack) }
override fun bind(address: InetAddress?, port: Int, certificate: File, key: File, dualStack: Boolean): ServerHost = apply { builder.bind(address, port, certificate, key, dualStack) }
override fun console(): ServerHost = apply { builder.console() }
override fun companion(companion: ServerCompanion): ServerHost = apply { builder.companion(companion) }
override fun development(developmentMode: Boolean): ServerHost = apply { builder.development(developmentMode) }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package org.codegreen.engine.shared.infrastructure

import java.io.File
import java.net.InetAddress
import java.time.Duration

/** Engine-agnostic configuration records, shared by any underlying engine. */

/** TLS material for a secure endpoint: a PEM certificate chain and its PEM (PKCS#8) private key. */
data class SecurityConfiguration(val certificate: File, val key: File)

data class EndPointConfiguration(
val address: InetAddress?,
val port: Int,
val dualStack: Boolean = true,
val secure: Boolean = false,
val security: SecurityConfiguration? = null,
)

data class NetworkConfiguration(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import org.codegreen.api.infrastructure.BuilderMissingPropertyException
import org.codegreen.api.infrastructure.Server
import org.codegreen.api.infrastructure.ServerBuilder
import org.codegreen.api.infrastructure.ServerCompanion
import java.io.File
import java.net.InetAddress
import java.time.Duration

Expand All @@ -32,6 +33,9 @@ abstract class AbstractServerBuilder : ServerBuilder {
override fun bind(address: InetAddress?, port: Int, dualStack: Boolean): ServerBuilder =
apply { binds.add(EndPointConfiguration(address, port, dualStack)) }

override fun bind(address: InetAddress?, port: Int, certificate: File, key: File, dualStack: Boolean): ServerBuilder =
apply { binds.add(EndPointConfiguration(address, port, dualStack, secure = true, security = SecurityConfiguration(certificate, key))) }

override fun console(): ServerBuilder = apply { companion = ConsoleCompanion() }

override fun companion(companion: ServerCompanion): ServerBuilder = apply { this.companion = companion }
Expand Down
24 changes: 24 additions & 0 deletions Modules/IO/src/main/kotlin/org/codegreen/modules/io/Compression.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.codegreen.modules.io

import org.codegreen.api.content.TypedHandlerBuilder
import org.codegreen.modules.io.compression.CompressionConcernBuilder
import org.codegreen.modules.io.compression.GzipAlgorithm

/**
* Entry point for response compression, mirroring GenHTTP's `Compression` module. Add it as a
* concern to a server host or layout, e.g. `Host.create().handler(app).add(Compression.default())`.
*/
object Compression {

/** A compression concern with no algorithms configured; add them via the builder. */
fun create(): CompressionConcernBuilder = CompressionConcernBuilder()

/** A compression concern preconfigured with the built-in gzip algorithm. */
fun default(): CompressionConcernBuilder = CompressionConcernBuilder().add(GzipAlgorithm())
}

/** Adds gzip response compression as a concern to the given builder. */
fun <T : TypedHandlerBuilder<T>> T.addCompression(): T {
add(Compression.default())
return this
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.codegreen.modules.io.compression

import org.codegreen.api.content.io.AlgorithmName

/**
* Parses an `Accept-Encoding` header value into the set of algorithm names the client accepts.
* Quality values (`;q=…`) and surrounding whitespace are ignored. Mirrors GenHTTP's
* `AcceptEncodingHeader.ParseSupported`.
*/
internal object AcceptEncoding {

fun parseSupported(header: String): Set<AlgorithmName> {
val result = HashSet<AlgorithmName>()
for (token in header.split(',')) {
val name = token.substringBefore(';').trim()
if (name.isNotEmpty()) result.add(AlgorithmName(name))
}
return result
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package org.codegreen.modules.io.compression

import org.codegreen.api.MemoryView
import org.codegreen.api.content.io.AlgorithmName
import org.codegreen.api.content.io.CompressionLevel
import org.codegreen.api.protocol.ContentType
import org.codegreen.api.protocol.ResponseContent
import org.codegreen.api.protocol.ResponseSink
import java.io.FilterOutputStream
import java.io.OutputStream
import java.util.zip.Deflater
import java.util.zip.GZIPOutputStream

/**
* Wraps a [ResponseContent], gzip-compressing it as it streams to the sink. The compressed length
* is not known up front, so [length] is null and the engine falls back to chunked transfer.
*/
class CompressedContent(
private val origin: ResponseContent,
algorithm: AlgorithmName,
private val level: CompressionLevel,
) : ResponseContent {

override val length: Long? = null

override val type: ContentType? = origin.type

override val encoding: MemoryView = algorithm.value

override suspend fun calculateChecksum(): Long? = null

override suspend fun write(sink: ResponseSink) {
// GZIPOutputStream.close() would close the underlying connection stream, whose lifecycle
// the engine owns; wrap it so close() only writes the gzip trailer and flushes.
val gzip = object : GZIPOutputStream(NonClosing(sink.stream)) {
init { def.setLevel(deflateLevel(level)) }
}
gzip.use { out -> origin.write(SinkOver(out)) }
}

/** Forwards writes/flush to [delegate] but never closes it. */
private class NonClosing(private val delegate: OutputStream) : FilterOutputStream(delegate) {
override fun write(b: ByteArray, off: Int, len: Int) = delegate.write(b, off, len)
override fun close() = delegate.flush()
}

private class SinkOver(override val stream: OutputStream) : ResponseSink

private companion object {
fun deflateLevel(level: CompressionLevel): Int = when (level) {
CompressionLevel.Fastest -> Deflater.BEST_SPEED
CompressionLevel.SmallestSize -> Deflater.BEST_COMPRESSION
CompressionLevel.NoCompression -> Deflater.NO_COMPRESSION
CompressionLevel.Optimal -> Deflater.DEFAULT_COMPRESSION
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package org.codegreen.modules.io.compression

import org.codegreen.api.content.Concern
import org.codegreen.api.content.Handler
import org.codegreen.api.content.io.CompressionAlgorithm
import org.codegreen.api.content.io.CompressionLevel
import org.codegreen.api.protocol.ContentType
import org.codegreen.api.protocol.Request
import org.codegreen.api.protocol.Response
import org.codegreen.api.protocol.getEntry

/**
* Negotiates response compression: when the request carries `Accept-Encoding` and the response
* body is a compressible type, large enough, and not already encoded, the body is replaced by a
* compressed variant and `Vary: Accept-Encoding` is set. Mirrors GenHTTP's `CompressionConcern`.
*/
class CompressionConcern(
override val content: Handler,
algorithms: List<CompressionAlgorithm>,
private val level: CompressionLevel,
private val minimumSize: Long?,
) : Concern {

// Highest priority first, so the best algorithm the client accepts wins.
private val algorithms: List<CompressionAlgorithm> = algorithms.sortedByDescending { it.priority.level }

override suspend fun prepare() = content.prepare()

override suspend fun handle(request: Request): Response? {
val acceptEncoding = request.header.headers.getEntry("Accept-Encoding")
val response = content.handle(request) ?: return null

val body = response.content
if (acceptEncoding != null && body != null && body.encoding == null &&
compressibleType(body.type) && compressibleSize(body.length)
) {
val supported = AcceptEncoding.parseSupported(acceptEncoding)
for (algorithm in algorithms) {
if (algorithm.name in supported) {
response.rebuild()
.content(algorithm.compress(body, level))
.header("Vary", "Accept-Encoding")
return response
}
}
}

return response
}

private fun compressibleType(type: ContentType?): Boolean =
type != null && type in COMPRESSIBLE

private fun compressibleSize(length: Long?): Boolean =
minimumSize == null || length == null || length >= minimumSize

private companion object {
val COMPRESSIBLE: Set<ContentType> = setOf(
ContentType.ApplicationJavaScript,
ContentType.ApplicationJson,
ContentType.ApplicationYaml,
ContentType.TextCss,
ContentType.TextHtml,
ContentType.TextPlain,
ContentType.TextYaml,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.codegreen.modules.io.compression

import org.codegreen.api.content.Concern
import org.codegreen.api.content.ConcernBuilder
import org.codegreen.api.content.Handler
import org.codegreen.api.content.io.CompressionAlgorithm
import org.codegreen.api.content.io.CompressionLevel

/**
* Builds a [CompressionConcern]. Defaults to the `Fastest` level (best throughput for a benchmark)
* and a 256-byte minimum so tiny responses are not compressed.
*/
class CompressionConcernBuilder : ConcernBuilder {

private val algorithms = mutableListOf<CompressionAlgorithm>()
private var level: CompressionLevel = CompressionLevel.Fastest
private var minimumSize: Long? = 256

fun add(algorithm: CompressionAlgorithm): CompressionConcernBuilder = apply { algorithms.add(algorithm) }

fun level(level: CompressionLevel): CompressionConcernBuilder = apply { this.level = level }

fun minimumSize(bytes: Long?): CompressionConcernBuilder = apply { this.minimumSize = bytes }

override fun build(content: Handler): Concern {
val algos = if (algorithms.isEmpty()) listOf(GzipAlgorithm()) else algorithms.toList()
return CompressionConcern(content, algos, level, minimumSize)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.codegreen.modules.io.compression

import org.codegreen.api.content.io.AlgorithmName
import org.codegreen.api.content.io.CompressionAlgorithm
import org.codegreen.api.content.io.CompressionLevel
import org.codegreen.api.infrastructure.Priority
import org.codegreen.api.protocol.ResponseContent
import java.io.InputStream
import java.util.zip.GZIPInputStream

/**
* Gzip compression backed by the JDK's `java.util.zip` (the analogue of GenHTTP's GzipAlgorithm).
* No third-party dependency is required.
*/
class GzipAlgorithm : CompressionAlgorithm {

override val name: AlgorithmName = AlgorithmName("gzip")

override val priority: Priority = Priority.Low

override fun compress(content: ResponseContent, level: CompressionLevel): ResponseContent =
CompressedContent(content, name, level)

override fun decompress(content: InputStream): InputStream = GZIPInputStream(content)
}