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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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

16 changes: 16 additions & 0 deletions ktor/src/main/kotlin/Annotations.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*
Expand Down
17 changes: 17 additions & 0 deletions ktor/src/main/kotlin/ControllerArgumentResolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@ internal object ControllerArgumentResolver {
}
}

if (kParameter.hasAnnotation<HttpHeader>()) {
val headerAnnotation = kParameter.findAnnotation<HttpHeader>()!!
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<RequestBody>()) {
args[kParameter] = call.receive(kParameter.type.jvmErasure)
}
Expand Down
11 changes: 10 additions & 1 deletion ktor/src/main/kotlin/OpenApiOperationDescriber.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -42,6 +42,15 @@ internal fun OpenApiOperation.Builder.describeOperation(kFunction: KFunction<*>)
schema = buildSchema(kParameter.type.withNullability(!pathParam.required))
}
}

kParameter.hasAnnotation<HttpHeader>() -> {
val header = kParameter.findAnnotation<HttpHeader>()!!

header(header.name) {
required = header.required
schema = buildSchema(kParameter.type.withNullability(!header.required))
}
}
}
}
}
Expand Down
46 changes: 46 additions & 0 deletions ktor/src/main/kotlin/ResponseEntity.kt
Original file line number Diff line number Diff line change
@@ -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].
Expand Down Expand Up @@ -34,5 +38,47 @@ data class ResponseEntity<T>(

/** Creates an HTTP 500 response containing [body]. */
fun <T> 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<OutgoingContent> =
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<OutgoingContent> =
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<OutgoingContent> =
ResponseEntity(NotModifiedContent(etag), HttpStatusCode.NotModified)
}
}
35 changes: 35 additions & 0 deletions ktor/src/main/kotlin/content/BinaryContent.kt
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions ktor/src/main/kotlin/content/LocationContent.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 22 additions & 0 deletions ktor/src/main/kotlin/content/NotModifiedContent.kt
Original file line number Diff line number Diff line change
@@ -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\"") }
}
}
71 changes: 70 additions & 1 deletion ktor/src/test/kotlin/ControllerArgumentResolverTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ 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
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test

Expand Down Expand Up @@ -35,6 +37,12 @@ class ControllerArgumentResolverTest {
@QueryParam("id", required = false) id: Uuid?
): ResponseEntity<String> =
ResponseEntity.ok(id?.toString() ?: "none")

fun headerEndpoint(
@HttpHeader("If-None-Match") ifNoneMatch: String,
@HttpHeader("If-Modified-Since", required = false) lastModified: String?
): ResponseEntity<String> =
ResponseEntity.ok("$ifNoneMatch / ${lastModified ?: "none"}")
}

@Nested
Expand Down Expand Up @@ -99,7 +107,7 @@ class ControllerArgumentResolverTest {
val function = SampleController::typeEndpoint

// When
val exception = org.junit.jupiter.api.assertThrows<InvalidParameterException> {
val exception = assertThrows<InvalidParameterException> {
ControllerArgumentResolver.resolve(call, function, controller)
}

Expand All @@ -123,4 +131,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<ApplicationCall>(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<ApplicationCall>(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<ApplicationCall>(relaxed = true)
every { call.request.headers["If-None-Match"] } returns null

// When / Then
assertThrows<InvalidParameterException> {
runBlocking {
ControllerArgumentResolver.resolve(
call,
SampleController::headerEndpoint,
SampleController()
)
}
}
}
}
}
42 changes: 42 additions & 0 deletions ktor/src/test/kotlin/ResponseEntityTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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])
}
}
}
Loading