From 6406f02c1133904d9f09a101f70665838751386f Mon Sep 17 00:00:00 2001 From: Ziedelth <45169338+Ziedelth@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:14:30 +0000 Subject: [PATCH 1/3] feat(ktor): resolve `@HttpHeader` controller parameters A controller had no way to read a request header: a conditional request (`If-None-Match`, `If-Modified-Since`), an idempotency key or a correlation identifier could not reach one without dropping to a raw Ktor route, which the controller conventions forbid. `@HttpHeader(name, required)` resolves a parameter from the request headers, with the same conversions as `@QueryParam`. An absent optional header binds `null`; an absent required header is refused with `InvalidParameterException`, which the binder already answers as HTTP 400. The OpenAPI description emits the parameter as a header, so the generated contract stays exact. Covered by three resolver cases: present, absent optional, absent required. --- ktor/src/main/kotlin/Annotations.kt | 16 +++++ .../main/kotlin/ControllerArgumentResolver.kt | 17 +++++ .../main/kotlin/OpenApiOperationDescriber.kt | 11 ++- .../kotlin/ControllerArgumentResolverTest.kt | 68 +++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/ktor/src/main/kotlin/Annotations.kt b/ktor/src/main/kotlin/Annotations.kt index 2aed4e6..e0b734d 100644 --- a/ktor/src/main/kotlin/Annotations.kt +++ b/ktor/src/main/kotlin/Annotations.kt @@ -125,6 +125,22 @@ annotation class PathParam( val required: Boolean = true ) +/** + * Resolves a controller parameter from a request header. + * + * Textual values use the same conversions as [QueryParam]. At runtime, a missing value remains + * unresolved, and answers HTTP 400 when the parameter is not nullable: a controller deciding on + * `If-None-Match` must see the absence, not an empty string. + * + * @property name header name, as sent by the client. + * @property required whether the parameter is required in the generated OpenAPI schema. + */ +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class HttpHeader( + val name: String, + val required: Boolean = true +) + /** * Deserializes the request payload into the annotated controller parameter using Ktor `receive`. * diff --git a/ktor/src/main/kotlin/ControllerArgumentResolver.kt b/ktor/src/main/kotlin/ControllerArgumentResolver.kt index 9d06202..4bf0281 100644 --- a/ktor/src/main/kotlin/ControllerArgumentResolver.kt +++ b/ktor/src/main/kotlin/ControllerArgumentResolver.kt @@ -118,6 +118,23 @@ internal object ControllerArgumentResolver { } } + if (kParameter.hasAnnotation()) { + val headerAnnotation = kParameter.findAnnotation()!! + val stringValue = call.request.headers[headerAnnotation.name] + + if (stringValue != null) { + args[kParameter] = parseParameter( + kParameter, + headerAnnotation.name, + stringValue + ) + } else if (!headerAnnotation.required && kParameter.type.isMarkedNullable) { + args[kParameter] = null + } else if (headerAnnotation.required) { + throw InvalidParameterException("Header '${headerAnnotation.name}' is required") + } + } + if (kParameter.hasAnnotation()) { args[kParameter] = call.receive(kParameter.type.jvmErasure) } diff --git a/ktor/src/main/kotlin/OpenApiOperationDescriber.kt b/ktor/src/main/kotlin/OpenApiOperationDescriber.kt index 63abd93..168e3c9 100644 --- a/ktor/src/main/kotlin/OpenApiOperationDescriber.kt +++ b/ktor/src/main/kotlin/OpenApiOperationDescriber.kt @@ -10,7 +10,7 @@ import kotlin.reflect.full.* * Adds annotation-based OpenAPI metadata for a controller function to this operation. * * Metadata is emitted only when [kFunction] has [Operation]. Parameter schemas - * come from [QueryParam] and [PathParam], the body schema comes from [RequestBody], and response + * come from [QueryParam], [PathParam] and [HttpHeader], the body schema comes from [RequestBody], and response * schemas come from [ApiResponse] or [ApiResponses]. * * @param kFunction controller function whose annotations describe the operation. @@ -42,6 +42,15 @@ internal fun OpenApiOperation.Builder.describeOperation(kFunction: KFunction<*>) schema = buildSchema(kParameter.type.withNullability(!pathParam.required)) } } + + kParameter.hasAnnotation() -> { + val header = kParameter.findAnnotation()!! + + header(header.name) { + required = header.required + schema = buildSchema(kParameter.type.withNullability(!header.required)) + } + } } } } diff --git a/ktor/src/test/kotlin/ControllerArgumentResolverTest.kt b/ktor/src/test/kotlin/ControllerArgumentResolverTest.kt index 9d7c54d..bce239e 100644 --- a/ktor/src/test/kotlin/ControllerArgumentResolverTest.kt +++ b/ktor/src/test/kotlin/ControllerArgumentResolverTest.kt @@ -3,6 +3,7 @@ package fr.shikkanime.ktor import io.ktor.server.application.ApplicationCall import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.runBlocking import kotlin.uuid.Uuid import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue @@ -35,6 +36,12 @@ class ControllerArgumentResolverTest { @QueryParam("id", required = false) id: Uuid? ): ResponseEntity = ResponseEntity.ok(id?.toString() ?: "none") + + fun headerEndpoint( + @HttpHeader("If-None-Match") ifNoneMatch: String, + @HttpHeader("If-Modified-Since", required = false) lastModified: String? + ): ResponseEntity = + ResponseEntity.ok("$ifNoneMatch / ${lastModified ?: "none"}") } @Nested @@ -123,4 +130,65 @@ class ControllerArgumentResolverTest { assertTrue(args.values.contains(null)) } } + + @Nested + @DisplayName("tests for header parameter resolution") + inner class HeaderResolutionTests { + + @Test + suspend fun `should resolve a required header`() { + // Given + val call = mockk(relaxed = true) + every { call.request.headers["If-None-Match"] } returns "\"a1b2c3d4\"" + + // When + val args = ControllerArgumentResolver.resolve( + call, + SampleController::headerEndpoint, + SampleController() + ) + + // Then + val ifNoneMatch = SampleController::headerEndpoint.parameters.first { it.name == "ifNoneMatch" } + assertEquals("\"a1b2c3d4\"", args[ifNoneMatch]) + } + + @Test + suspend fun `should leave an absent optional header null`() { + // Given + val call = mockk(relaxed = true) + every { call.request.headers["If-None-Match"] } returns "\"a1b2c3d4\"" + every { call.request.headers["If-Modified-Since"] } returns null + + // When + val args = ControllerArgumentResolver.resolve( + call, + SampleController::headerEndpoint, + SampleController() + ) + + // Then + val lastModified = SampleController::headerEndpoint.parameters + .first { it.name == "lastModified" } + assertTrue(!args.containsKey(lastModified) || args[lastModified] == null) + } + + @Test + suspend fun `should refuse an absent required header`() { + // Given + val call = mockk(relaxed = true) + every { call.request.headers["If-None-Match"] } returns null + + // When / Then + org.junit.jupiter.api.assertThrows { + runBlocking { + ControllerArgumentResolver.resolve( + call, + SampleController::headerEndpoint, + SampleController() + ) + } + } + } + } } From 9adfd4b761d6354c251f269eed5182df07429949 Mon Sep 17 00:00:00 2001 From: Ziedelth <45169338+Ziedelth@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:31:59 +0000 Subject: [PATCH 2/3] feat(ktor): answer binary payloads, redirects and 304 from a controller A controller returning raw bytes had to build its own `OutgoingContent` to keep its media type and its cache headers, which every consumer project reimplements. `ResponseEntity.binary(payload, contentType, etag, cacheControl)`, `ResponseEntity.redirect(location)` and `ResponseEntity.notModified(etag)` wrap the new `BinaryContent`, `LocationContent` and `NotModifiedContent` bodies: the binder answers with them as-is, so the content type and the headers reach the client without leaving the controller pipeline. Covered by three factory cases: payload with its cache metadata, location, revalidated validator. --- ktor/src/main/kotlin/ResponseEntity.kt | 46 +++++++++++++++++++ ktor/src/main/kotlin/content/BinaryContent.kt | 35 ++++++++++++++ .../main/kotlin/content/LocationContent.kt | 19 ++++++++ .../main/kotlin/content/NotModifiedContent.kt | 22 +++++++++ ktor/src/test/kotlin/ResponseEntityTest.kt | 42 +++++++++++++++++ 5 files changed, 164 insertions(+) create mode 100644 ktor/src/main/kotlin/content/BinaryContent.kt create mode 100644 ktor/src/main/kotlin/content/LocationContent.kt create mode 100644 ktor/src/main/kotlin/content/NotModifiedContent.kt diff --git a/ktor/src/main/kotlin/ResponseEntity.kt b/ktor/src/main/kotlin/ResponseEntity.kt index ede4b43..ef09fd1 100644 --- a/ktor/src/main/kotlin/ResponseEntity.kt +++ b/ktor/src/main/kotlin/ResponseEntity.kt @@ -1,6 +1,10 @@ package fr.shikkanime.ktor +import fr.shikkanime.ktor.content.BinaryContent +import fr.shikkanime.ktor.content.LocationContent +import fr.shikkanime.ktor.content.NotModifiedContent import io.ktor.http.* +import io.ktor.http.content.OutgoingContent /** * Couples a response body with the HTTP status returned by [ControllerBinder]. @@ -34,5 +38,47 @@ data class ResponseEntity( /** Creates an HTTP 500 response containing [body]. */ fun internalServerError(body: T) = ResponseEntity(body, HttpStatusCode.InternalServerError) + + /** + * Creates an HTTP 200 response carrying raw [payload] bytes. + * + * The payload is served as-is, with its media type, and with the validators a cache needs: + * this is how a controller returns an image, a generated document or an export without + * leaving the controller pipeline. + * + * @param payload raw bytes to send. + * @param contentType media type of the payload. + * @param etag validator identifying this exact payload, when the caller publishes one. + * @param cacheControl `Cache-Control` directive of the response, when the caller publishes one. + * @return an HTTP 200 response holding the payload. + * @see [BinaryContent] + */ + fun binary( + payload: ByteArray, + contentType: ContentType, + etag: String? = null, + cacheControl: String? = null + ): ResponseEntity = + ResponseEntity(BinaryContent(payload, contentType, etag, cacheControl), HttpStatusCode.OK) + + /** + * Creates an HTTP 302 response pointing at [location]. + * + * @param location address the client is sent to. + * @return an HTTP 302 response carrying the location. + * @see [LocationContent] + */ + fun redirect(location: String): ResponseEntity = + ResponseEntity(LocationContent(location), HttpStatusCode.Found) + + /** + * Creates an HTTP 304 response, echoing the validator the client revalidated with. + * + * @param etag validator of the payload the client already holds, when the caller publishes one. + * @return an HTTP 304 response carrying the validator. + * @see [NotModifiedContent] + */ + fun notModified(etag: String? = null): ResponseEntity = + ResponseEntity(NotModifiedContent(etag), HttpStatusCode.NotModified) } } diff --git a/ktor/src/main/kotlin/content/BinaryContent.kt b/ktor/src/main/kotlin/content/BinaryContent.kt new file mode 100644 index 0000000..bf3ea88 --- /dev/null +++ b/ktor/src/main/kotlin/content/BinaryContent.kt @@ -0,0 +1,35 @@ +package fr.shikkanime.ktor.content + +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.content.OutgoingContent + +/** + * Binary response body, with the metadata a cache needs. + * + * The binder answers with this content as-is, so its content type and its headers reach the client: + * an artwork, a generated document or a CSV export is served without escaping the controller + * pipeline. + * + * @property bytes raw payload. + * @property contentType media type of the payload. + * @property etag validator identifying this exact payload, when the caller publishes one. + * @property cacheControl `Cache-Control` directive of the response, when the caller publishes one. + * @see fr.shikkanime.ktor.ResponseEntity.binary + */ +class BinaryContent( + private val bytes: ByteArray, + override val contentType: ContentType, + etag: String? = null, + cacheControl: String? = null +) : OutgoingContent.ByteArrayContent() { + override val contentLength: Long = bytes.size.toLong() + + override val headers: Headers = Headers.build { + etag?.let { append(HttpHeaders.ETag, "\"$it\"") } + cacheControl?.let { append(HttpHeaders.CacheControl, it) } + } + + override fun bytes(): ByteArray = bytes +} diff --git a/ktor/src/main/kotlin/content/LocationContent.kt b/ktor/src/main/kotlin/content/LocationContent.kt new file mode 100644 index 0000000..e7438d3 --- /dev/null +++ b/ktor/src/main/kotlin/content/LocationContent.kt @@ -0,0 +1,19 @@ +package fr.shikkanime.ktor.content + +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.content.OutgoingContent + +/** + * Empty body of a redirect, pointing the client at another address. + * + * @property location address the client is sent to. + * @see fr.shikkanime.ktor.ResponseEntity.redirect + */ +class LocationContent( + private val location: String +) : OutgoingContent.NoContent() { + override val headers: Headers = Headers.build { + append(HttpHeaders.Location, location) + } +} diff --git a/ktor/src/main/kotlin/content/NotModifiedContent.kt b/ktor/src/main/kotlin/content/NotModifiedContent.kt new file mode 100644 index 0000000..bbfc8d1 --- /dev/null +++ b/ktor/src/main/kotlin/content/NotModifiedContent.kt @@ -0,0 +1,22 @@ +package fr.shikkanime.ktor.content + +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.content.OutgoingContent + +/** + * Empty body of a `304` answer, echoing the validator the client revalidated with. + * + * A client holding the payload sends its entity tag back: answering `304` costs a header round trip + * instead of the payload. + * + * @property etag validator of the payload the client already holds, when the caller publishes one. + * @see fr.shikkanime.ktor.ResponseEntity.notModified + */ +class NotModifiedContent( + private val etag: String? = null +) : OutgoingContent.NoContent() { + override val headers: Headers = Headers.build { + etag?.let { append(HttpHeaders.ETag, "\"$it\"") } + } +} diff --git a/ktor/src/test/kotlin/ResponseEntityTest.kt b/ktor/src/test/kotlin/ResponseEntityTest.kt index 2653e7d..0498022 100644 --- a/ktor/src/test/kotlin/ResponseEntityTest.kt +++ b/ktor/src/test/kotlin/ResponseEntityTest.kt @@ -1,5 +1,7 @@ package fr.shikkanime.ktor +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName @@ -76,4 +78,44 @@ class ResponseEntityTest { assertEquals(HttpStatusCode.InternalServerError, response.status) } } + + @Nested + @DisplayName("tests for the binary, redirect and not-modified factories") + inner class ContentFactoryTests { + + @Test + fun `should serve a binary payload with its cache metadata`() { + // Given + val payload = byteArrayOf(1, 2, 3) + + // When + val response = ResponseEntity.binary(payload, ContentType.Image.PNG, "v1", "max-age=60") + + // Then + assertEquals(HttpStatusCode.OK, response.status) + assertEquals(payload.toList(), (response.body as io.ktor.http.content.OutgoingContent.ByteArrayContent).bytes().toList()) + assertEquals("\"v1\"", response.body.headers[HttpHeaders.ETag]) + assertEquals("max-age=60", response.body.headers[HttpHeaders.CacheControl]) + } + + @Test + fun `should redirect to the given location`() { + // When + val response = ResponseEntity.redirect("/v1/images/a/b/c.webp") + + // Then + assertEquals(HttpStatusCode.Found, response.status) + assertEquals("/v1/images/a/b/c.webp", response.body.headers[HttpHeaders.Location]) + } + + @Test + fun `should answer not modified with the revalidated validator`() { + // When + val response = ResponseEntity.notModified("v1") + + // Then + assertEquals(HttpStatusCode.NotModified, response.status) + assertEquals("\"v1\"", response.body.headers[HttpHeaders.ETag]) + } + } } From c16fa52760f95c526787b78d5cffceb8d450add8 Mon Sep 17 00:00:00 2001 From: Ziedelth <45169338+Ziedelth@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:43:16 +0000 Subject: [PATCH 3/3] chore: release 0.0.26-SNAPSHOT The annotation and the response factories land together: merging this branch now publishes a version that carries them, instead of overwriting a snapshot other builds already resolved. Tests also use `assertThrows` through an import rather than its qualified name. --- gradle.properties | 2 +- ktor/src/test/kotlin/ControllerArgumentResolverTest.kt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/gradle.properties b/gradle.properties index 8184180..3b12fcc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,5 +7,5 @@ org.gradle.caching=true org.gradle.configuration-cache=true # Framework version -version=0.0.25-SNAPSHOT +version=0.0.26-SNAPSHOT diff --git a/ktor/src/test/kotlin/ControllerArgumentResolverTest.kt b/ktor/src/test/kotlin/ControllerArgumentResolverTest.kt index bce239e..7f8d848 100644 --- a/ktor/src/test/kotlin/ControllerArgumentResolverTest.kt +++ b/ktor/src/test/kotlin/ControllerArgumentResolverTest.kt @@ -8,6 +8,7 @@ import kotlin.uuid.Uuid import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -106,7 +107,7 @@ class ControllerArgumentResolverTest { val function = SampleController::typeEndpoint // When - val exception = org.junit.jupiter.api.assertThrows { + val exception = assertThrows { ControllerArgumentResolver.resolve(call, function, controller) } @@ -180,7 +181,7 @@ class ControllerArgumentResolverTest { every { call.request.headers["If-None-Match"] } returns null // When / Then - org.junit.jupiter.api.assertThrows { + assertThrows { runBlocking { ControllerArgumentResolver.resolve( call,