Fix conditional headers processing

This commit is contained in:
Sergey Mashkov
2019-10-03 15:00:10 +03:00
parent f8b38d00eb
commit 0bcdf147e5
5 changed files with 596 additions and 122 deletions

View File

@@ -1091,18 +1091,31 @@ public final class io/ktor/http/content/CachingOptionsKt {
}
public final class io/ktor/http/content/EntityTagVersion : io/ktor/http/content/Version {
public fun <init> (Ljava/lang/String;)V
public static final field Companion Lio/ktor/http/content/EntityTagVersion$Companion;
public synthetic fun <init> (Ljava/lang/String;)V
public fun <init> (Ljava/lang/String;Z)V
public fun appendHeadersTo (Lio/ktor/http/HeadersBuilder;)V
public fun check (Lio/ktor/http/Headers;)Lio/ktor/http/content/VersionCheckResult;
public final fun component1 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;)Lio/ktor/http/content/EntityTagVersion;
public static synthetic fun copy$default (Lio/ktor/http/content/EntityTagVersion;Ljava/lang/String;ILjava/lang/Object;)Lio/ktor/http/content/EntityTagVersion;
public final fun component2 ()Z
public final fun copy (Ljava/lang/String;Z)Lio/ktor/http/content/EntityTagVersion;
public static synthetic fun copy$default (Lio/ktor/http/content/EntityTagVersion;Ljava/lang/String;ZILjava/lang/Object;)Lio/ktor/http/content/EntityTagVersion;
public fun equals (Ljava/lang/Object;)Z
public final fun getEtag ()Ljava/lang/String;
public final fun getWeak ()Z
public fun hashCode ()I
public final fun match (Lio/ktor/http/content/EntityTagVersion;)Z
public final fun match (Ljava/util/List;)Lio/ktor/http/content/VersionCheckResult;
public final fun noneMatch (Ljava/util/List;)Lio/ktor/http/content/VersionCheckResult;
public fun toString ()Ljava/lang/String;
}
public final class io/ktor/http/content/EntityTagVersion$Companion {
public final fun getSTAR ()Lio/ktor/http/content/EntityTagVersion;
public final fun parse (Ljava/lang/String;)Ljava/util/List;
public final fun parseSingle (Ljava/lang/String;)Lio/ktor/http/content/EntityTagVersion;
}
public final class io/ktor/http/content/LastModifiedVersion : io/ktor/http/content/Version {
public fun <init> (Lio/ktor/util/date/GMTDate;)V
public fun <init> (Ljava/util/Date;)V
@@ -1114,6 +1127,8 @@ public final class io/ktor/http/content/LastModifiedVersion : io/ktor/http/conte
public fun equals (Ljava/lang/Object;)Z
public final fun getLastModified ()Lio/ktor/util/date/GMTDate;
public fun hashCode ()I
public final fun ifModifiedSince (Ljava/util/List;)Z
public final fun ifUnmodifiedSince (Ljava/util/List;)Z
public fun toString ()Ljava/lang/String;
}
@@ -1241,6 +1256,7 @@ public final class io/ktor/http/content/VersionCheckResult : java/lang/Enum {
}
public final class io/ktor/http/content/VersionsKt {
public static final fun EntityTagVersion (Ljava/lang/String;)Lio/ktor/http/content/EntityTagVersion;
public static final fun getVersionListProperty ()Lio/ktor/util/AttributeKey;
public static final fun getVersions (Lio/ktor/http/content/OutgoingContent;)Ljava/util/List;
public static final fun setVersions (Lio/ktor/http/content/OutgoingContent;Ljava/util/List;)V

View File

@@ -74,23 +74,24 @@ enum class VersionCheckResult(val statusCode: HttpStatusCode) {
* @param lastModified of the current content, for example file's last modified date
*/
data class LastModifiedVersion(val lastModified: GMTDate) : Version {
constructor(lastModified: Date) : this(GMTDate(lastModified.time))
private val truncatedModificationDate: GMTDate = lastModified.truncateToSeconds()
/**
* @return [VersionCheckResult.OK] if all header pass or there was no headers in the request,
* [VersionCheckResult.NOT_MODIFIED] for If-Modified-Since,
* [VersionCheckResult.PRECONDITION_FAILED] for If-Unmodified*Since
*/
override fun check(requestHeaders: Headers): VersionCheckResult {
val normalized = lastModified.truncateToSeconds()
val ifModifiedSince = requestHeaders[HttpHeaders.IfModifiedSince]?.fromHttpToGmtDate()
val ifUnmodifiedSince = requestHeaders[HttpHeaders.IfUnmodifiedSince]?.fromHttpToGmtDate()
if (ifModifiedSince != null) {
if (normalized <= ifModifiedSince) {
requestHeaders.getAll(HttpHeaders.IfModifiedSince)?.parseDates()?.let { dates ->
if (!ifModifiedSince(dates)) {
return VersionCheckResult.NOT_MODIFIED
}
}
if (ifUnmodifiedSince != null) {
if (normalized > ifUnmodifiedSince) {
requestHeaders.getAll(HttpHeaders.IfUnmodifiedSince)?.parseDates()?.let { dates ->
if (!ifUnmodifiedSince(dates)) {
return VersionCheckResult.PRECONDITION_FAILED
}
}
@@ -98,11 +99,42 @@ data class LastModifiedVersion(val lastModified: GMTDate) : Version {
return VersionCheckResult.OK
}
constructor(lastModified: Date) : this(GMTDate(lastModified.time))
/**
* If-Modified-Since logic: all [dates] should be _before_ this date (truncated to seconds).
*/
@KtorExperimentalAPI
fun ifModifiedSince(dates: List<GMTDate>): Boolean {
return dates.any { truncatedModificationDate > it }
}
/**
* If-Unmodified-Since logic: all [dates] should not be before this date (truncated to seconds).
*/
@KtorExperimentalAPI
fun ifUnmodifiedSince(dates: List<GMTDate>): Boolean {
return dates.all { truncatedModificationDate <= it }
}
override fun appendHeadersTo(builder: HeadersBuilder) {
builder[HttpHeaders.LastModified] = lastModified.toHttpDate()
}
private fun List<String>.parseDates(): List<GMTDate>? = filter { it.isNotBlank() }.
mapNotNull { try {
it.fromHttpToGmtDate()
} catch (_: Throwable) {
// according to RFC7232 sec 3.3 illegal dates should be ignored
null
}
}.takeIf { it.isNotEmpty() }
}
/**
* Creates an instance of [EntityTagVersion] parsing the [spec] via [EntityTagVersion.parseSingle].
*/
@Suppress("FunctionName", "CONFLICTING_OVERLOADS")
fun EntityTagVersion(spec: String): EntityTagVersion {
return EntityTagVersion.parseSingle(spec)
}
/**
@@ -113,30 +145,136 @@ data class LastModifiedVersion(val lastModified: GMTDate) : Version {
* See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26 for more details
*
* @param etag - entity tag, for example file's content hash
* @param weak - whether strong or weak validation should be applied
* @return [VersionCheckResult.OK] if all headers pass or there was no related headers,
* [VersionCheckResult.NOT_MODIFIED] for successful If-None-Match,
* [VersionCheckResult.PRECONDITION_FAILED] for failed If-Match
*/
data class EntityTagVersion(val etag: String) : Version {
data class EntityTagVersion(val etag: String, val weak: Boolean) : Version {
@Suppress("unused", "CONFLICTING_OVERLOADS")
@Deprecated("Binary compatibility.", level = DeprecationLevel.HIDDEN)
constructor(etag: String) : this(etag.removePrefix("W/"), etag.startsWith("W/"))
private val normalized: String = when {
etag == "*" -> etag
etag.startsWith("\"") -> etag
else -> etag.quote()
}
init {
for (index in etag.indices) {
val ch = etag[index]
if (ch <= ' ' || ch == '\"') {
require(index == 0 || index == etag.lastIndex) { "Character '$ch' is not allowed in entity-tag." }
}
}
}
override fun check(requestHeaders: Headers): VersionCheckResult {
val givenNoneMatchEtags = requestHeaders[HttpHeaders.IfNoneMatch]?.parseMatchTag()
val givenMatchEtags = requestHeaders[HttpHeaders.IfMatch]?.parseMatchTag()
if (givenNoneMatchEtags != null && etag in givenNoneMatchEtags && "*" !in givenNoneMatchEtags) {
return VersionCheckResult.NOT_MODIFIED
requestHeaders[HttpHeaders.IfNoneMatch]?.let { parse(it) }?.let { givenNoneMatchEtags ->
noneMatch(givenNoneMatchEtags).let { result ->
if (result != VersionCheckResult.OK) return result
}
}
if (givenMatchEtags != null && givenMatchEtags.isNotEmpty() && etag !in givenMatchEtags && "*" !in givenMatchEtags) {
return VersionCheckResult.PRECONDITION_FAILED
requestHeaders[HttpHeaders.IfMatch]?.let { parse(it) }?.let { givenMatchEtags ->
match(givenMatchEtags).let { result ->
if (result != VersionCheckResult.OK) return result
}
}
return VersionCheckResult.OK
}
private fun String.parseMatchTag() = split("\\s*,\\s*".toRegex()).map { it.removePrefix("W/") }.filter { it.isNotEmpty() }.toSet()
/**
* Examine two entity-tags for match (strong).
*/
@KtorExperimentalAPI
fun match(other: EntityTagVersion): Boolean {
if (this == STAR || other == STAR) return true
return normalized == other.normalized
}
/**
* `If-None-Match` logic using [match] function.
*/
@KtorExperimentalAPI
fun noneMatch(givenNoneMatchEtags: List<EntityTagVersion>): VersionCheckResult {
if (STAR in givenNoneMatchEtags) return VersionCheckResult.OK
if (givenNoneMatchEtags.any { match(it) }) {
return VersionCheckResult.NOT_MODIFIED
}
return VersionCheckResult.OK
}
/**
* `If-Match` logic using [match] function.
*/
@KtorExperimentalAPI
fun match(givenMatchEtags: List<EntityTagVersion>): VersionCheckResult {
if (givenMatchEtags.isEmpty()) return VersionCheckResult.OK
if (STAR in givenMatchEtags) return VersionCheckResult.OK
for (given in givenMatchEtags) {
if (match(given)) {
return VersionCheckResult.OK
}
}
return VersionCheckResult.PRECONDITION_FAILED
}
override fun appendHeadersTo(builder: HeadersBuilder) {
builder.etag(etag)
builder.etag(normalized)
}
companion object {
/**
* Instance for `*` entity-tag pattern.
*/
@KtorExperimentalAPI
val STAR: EntityTagVersion = EntityTagVersion("*", false)
/**
* Parse headers with a list of entity-tags. Useful for headers such as `If-Match`/`If-None-Match`.
*/
@KtorExperimentalAPI
fun parse(headerValue: String): List<EntityTagVersion> {
val rawEntries = parseHeaderValue(headerValue)
return rawEntries.map { entry ->
check (entry.quality == 1.0) { "entity-tag quality parameter is not allowed: ${entry.quality}."}
check (entry.params.isEmpty()) { "entity-tag parameters are not allowed: ${entry.params}." }
parseSingle(entry.value)
}
}
/**
* Parse single entity-tag or pattern specification.
*/
@KtorExperimentalAPI
fun parseSingle(value: String): EntityTagVersion {
if (value == "*") return STAR
val weak: Boolean
val rawEtag: String
if (value.startsWith("W/")) {
weak = true
rawEtag = value.drop(2)
} else {
weak = false
rawEtag = value
}
val etag = when {
rawEtag.startsWith("\"") -> rawEtag
else -> rawEtag.quote()
}
return EntityTagVersion(etag, weak)
}
}
}

View File

@@ -11,6 +11,7 @@ import io.ktor.util.pipeline.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.util.*
import io.ktor.util.date.*
import kotlinx.coroutines.*
import io.ktor.utils.io.*
import kotlin.coroutines.*
@@ -107,43 +108,43 @@ class PartialContent(private val maxRangeCount: Int) {
call: ApplicationCall
): Boolean {
val conditionalHeadersFeature = call.application.featureOrNull(ConditionalHeaders)
val versions = conditionalHeadersFeature?.versionsFor(content) ?: content.defaultVersions
val ifRange = call.request.header(HttpHeaders.IfRange)?.trim() ?: return true
if (ifRange.endsWith(" GMT")) { // E-Tag If-Range spec can't have GMT suffix
return checkIfRangeDateHeader(ifRange, versions)
val ifRange = try {
call.request.headers.getAll(HttpHeaders.IfRange)
?.map { parseIfRangeHeader(it) }
?.takeIf { it.isNotEmpty() }
?.reduce { acc, list -> acc + list }
?.parseVersions()
?: return true
} catch (_: Throwable) {
return false
}
return checkIfRangeETagHeader(versions, ifRange)
}
private fun checkIfRangeETagHeader(
versions: List<Version>,
ifRange: String
): Boolean {
val parsed = ifRange.parseMatchTag()
val versions = conditionalHeadersFeature?.versionsFor(content) ?: content.defaultVersions
return versions.all { version ->
when (version) {
is EntityTagVersion -> version.etag in parsed
is LastModifiedVersion -> checkLastModified(version, ifRange)
is EntityTagVersion -> checkEntityTags(version, ifRange)
else -> true
}
}
}
private fun checkIfRangeDateHeader(
ifRange: String,
versions: List<Version>
): Boolean {
val ifRangeDate = try {
ifRange.fromHttpToGmtDate()
} catch (_: Throwable) {
return false
}
private fun checkLastModified(actual: LastModifiedVersion, ifRange: List<Version>): Boolean {
val actualDate = actual.lastModified.truncateToSeconds()
return versions.all { version ->
when (version) {
is LastModifiedVersion -> version.lastModified <= ifRangeDate
return ifRange.all { condition ->
when (condition) {
is LastModifiedVersion -> actualDate <= condition.lastModified
else -> true
}
}
}
private fun checkEntityTags(actual: EntityTagVersion, ifRange: List<Version>): Boolean {
return ifRange.all { condition ->
when (condition) {
is EntityTagVersion -> actual.etag == condition.etag
else -> true
}
}
@@ -221,7 +222,7 @@ class PartialContent(private val maxRangeCount: Int) {
class Single(
val get: Boolean,
original: OutgoingContent.ReadChannelContent,
original: ReadChannelContent,
val range: LongRange,
val fullLength: Long
) : PartialOutgoingContent(original) {
@@ -243,7 +244,7 @@ class PartialContent(private val maxRangeCount: Int) {
class Multiple(
override val coroutineContext: CoroutineContext,
val get: Boolean,
original: OutgoingContent.ReadChannelContent,
original: ReadChannelContent,
val ranges: List<LongRange>,
val length: Long,
val boundary: String
@@ -280,9 +281,33 @@ class PartialContent(private val maxRangeCount: Int) {
private fun ApplicationCall.isGet() = request.local.method == HttpMethod.Get
private fun ApplicationCall.isGetOrHead() = isGet() || request.local.method == HttpMethod.Head
private fun String.parseMatchTag() =
split("\\s*,\\s*".toRegex()).map { it.removePrefix("W/") }.filter { it.isNotEmpty() }.toSet()
}
private fun List<LongRange>.isAscending(): Boolean =
fold(true to 0L) { acc, e -> (acc.first && acc.second <= e.start) to e.start }.first
private fun parseIfRangeHeader(header: String): List<HeaderValue> {
if (header.endsWith(" GMT")) {
return listOf(HeaderValue(header))
}
return parseHeaderValue(header)
}
private fun List<HeaderValue>.parseVersions(): List<Version> = mapNotNull { field ->
check(field.quality == 1.0) { "If-Range doesn't support quality" }
check(field.params.isEmpty()) { "If-Range doesn't support parameters" }
parseVersion(field.value)
}
private fun parseVersion(value: String): Version? {
if (value.isBlank()) return null
check(!value.startsWith("W/"))
if (value.startsWith("\"")) {
return EntityTagVersion.parseSingle(value)
}
return LastModifiedVersion(value.fromHttpToGmtDate())
}

View File

@@ -38,28 +38,28 @@ class ETagsTest {
}
@Test
fun testNoConditions() = withConditionalApplication {
fun testNoConditions(): Unit = withConditionalApplication {
val result = handleRequest {}
assertTrue(result.requestHandled)
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
assertEquals("tag1", result.response.headers[HttpHeaders.ETag])
assertEquals("\"tag1\"", result.response.headers[HttpHeaders.ETag])
}
@Test
fun testIfMatchConditionAccepted() = withConditionalApplication {
fun testIfMatchConditionAccepted(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfMatch, "tag1")
}
assertTrue(result.requestHandled)
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
assertEquals("tag1", result.response.headers[HttpHeaders.ETag])
assertEquals("\"tag1\"", result.response.headers[HttpHeaders.ETag])
}
@Test
fun testIfMatchConditionFailed() = withConditionalApplication {
fun testIfMatchConditionFailed(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfMatch, "tag2")
}
@@ -68,7 +68,7 @@ class ETagsTest {
}
@Test
fun testIfNoneMatchConditionAccepted() = withConditionalApplication {
fun testIfNoneMatchConditionAccepted(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfNoneMatch, "tag1")
}
@@ -77,7 +77,7 @@ class ETagsTest {
}
@Test
fun testIfNoneMatchWeakConditionAccepted() = withConditionalApplication {
fun testIfNoneMatchWeakConditionAccepted(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfNoneMatch, "W/tag1")
}
@@ -86,29 +86,29 @@ class ETagsTest {
}
@Test
fun testIfNoneMatchConditionFailed() = withConditionalApplication {
fun testIfNoneMatchConditionFailed(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfNoneMatch, "tag2")
}
assertTrue(result.requestHandled)
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
assertEquals("tag1", result.response.headers[HttpHeaders.ETag])
assertEquals("\"tag1\"", result.response.headers[HttpHeaders.ETag])
}
@Test
fun testIfMatchStar() = withConditionalApplication {
fun testIfMatchStar(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfMatch, "*")
}
assertTrue(result.requestHandled)
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
assertEquals("tag1", result.response.headers[HttpHeaders.ETag])
assertEquals("\"tag1\"", result.response.headers[HttpHeaders.ETag])
}
@Test
fun testIfNoneMatchStar() = withConditionalApplication {
fun testIfNoneMatchStar(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfNoneMatch, "*")
}
@@ -120,7 +120,7 @@ class ETagsTest {
}
@Test
fun testIfNoneMatchListConditionFailed() = withConditionalApplication {
fun testIfNoneMatchListConditionFailed(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfNoneMatch, "tag0,tag1,tag3")
}
@@ -129,29 +129,29 @@ class ETagsTest {
}
@Test
fun testIfNoneMatchListConditionSuccess() = withConditionalApplication {
fun testIfNoneMatchListConditionSuccess(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfNoneMatch, "tag2")
}
assertTrue(result.requestHandled)
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
assertEquals("tag1", result.response.headers[HttpHeaders.ETag])
assertEquals("\"tag1\"", result.response.headers[HttpHeaders.ETag])
}
@Test
fun testIfMatchListConditionAccepted() = withConditionalApplication {
fun testIfMatchListConditionAccepted(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfMatch, "tag0,tag1,tag3")
}
assertTrue(result.requestHandled)
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
assertEquals("tag1", result.response.headers[HttpHeaders.ETag])
assertEquals("\"tag1\"", result.response.headers[HttpHeaders.ETag])
}
@Test
fun testIfMatchListConditionFailed() = withConditionalApplication {
fun testIfMatchListConditionFailed(): Unit = withConditionalApplication {
val result = handleRequest {
addHeader(HttpHeaders.IfMatch, "tag0,tag2,tag3")
}
@@ -166,7 +166,8 @@ class LastModifiedTest(@Suppress("UNUSED_PARAMETER") name: String, zone: ZoneId)
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun zones(): List<Array<Any>> = listOf(arrayOf<Any>("GMT", ZoneId.of("GMT")), arrayOf<Any>("SomeLocal", ZoneId.of("GMT+1")))
fun zones(): List<Array<Any>> =
listOf(arrayOf<Any>("GMT", ZoneId.of("GMT")), arrayOf<Any>("SomeLocal", ZoneId.of("GMT+1")))
}
private val date = ZonedDateTime.now(zone)!!
@@ -185,7 +186,7 @@ class LastModifiedTest(@Suppress("UNUSED_PARAMETER") name: String, zone: ZoneId)
}
@Test
fun testNoHeaders() = withConditionalApplication {
fun testNoHeaders(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/").let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
@@ -193,8 +194,13 @@ class LastModifiedTest(@Suppress("UNUSED_PARAMETER") name: String, zone: ZoneId)
}
@Test
fun testIfModifiedSinceEq() = withConditionalApplication {
handleRequest(HttpMethod.Get, "/", { addHeader(HttpHeaders.IfModifiedSince, date.toHttpDateString()) }).let { result ->
fun testIfModifiedSinceEq(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
date.toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.NotModified, result.response.status())
assertNull(result.response.content)
}
@@ -212,7 +218,12 @@ class LastModifiedTest(@Suppress("UNUSED_PARAMETER") name: String, zone: ZoneId)
}
}
handleRequest(HttpMethod.Get, "/", { addHeader(HttpHeaders.IfModifiedSince, date.toHttpDateString()) }).let { result ->
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
date.toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.NotModified, result.response.status())
assertNull(result.response.content)
}
@@ -220,72 +231,287 @@ class LastModifiedTest(@Suppress("UNUSED_PARAMETER") name: String, zone: ZoneId)
}
@Test
fun testIfModifiedSinceLess() = withConditionalApplication {
handleRequest(HttpMethod.Get, "/", { addHeader(HttpHeaders.IfModifiedSince, date.minusDays(1).toHttpDateString()) }).let { result ->
fun testIfModifiedSinceLess(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
date.minusDays(1).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
}
@Test
fun testIfModifiedSinceGt() = withConditionalApplication {
handleRequest(HttpMethod.Get, "/", { addHeader(HttpHeaders.IfModifiedSince, date.plusDays(1).toHttpDateString()) }).let { result ->
fun testIfModifiedSinceGt(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
date.plusDays(1).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.NotModified, result.response.status())
assertNull(result.response.content)
}
}
// this test is disabled since non-GMT timezones are strictly prohibited by the specification
fun testIfModifiedSinceTimeZoned() = withConditionalApplication {
val expectedDate = date.toHttpDateString()
val customFormat = httpDateFormat.withZone(ZoneId.of("Europe/Moscow"))!!
handleRequest(HttpMethod.Get, "/", { addHeader(HttpHeaders.IfModifiedSince, customFormat.format(date).replace("MT", "MSK")) }).let { result ->
assertEquals(HttpStatusCode.NotModified, result.response.status())
assertNull(result.response.content)
}
handleRequest(HttpMethod.Get, "/", { addHeader(HttpHeaders.IfModifiedSince, customFormat.format(date.plusDays(1)).replace("MT", "MSK")) }).let { result ->
assertEquals(HttpStatusCode.NotModified, result.response.status())
assertNull(result.response.content)
}
handleRequest(HttpMethod.Get, "/", { addHeader(HttpHeaders.IfModifiedSince, customFormat.format(date.minusDays(1)).replace("MT", "MSK")) }).let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
assertEquals(expectedDate, result.response.headers[HttpHeaders.LastModified])
}
}
@Test
fun testIfUnModifiedSinceEq() = withConditionalApplication {
handleRequest(HttpMethod.Get, "/", { addHeader(HttpHeaders.IfUnmodifiedSince, date.toHttpDateString()) }).let { result ->
fun testIfUnModifiedSinceEq(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
}
@Test
fun testIfUnModifiedSinceLess() = withConditionalApplication {
handleRequest(HttpMethod.Get, "/", { addHeader(HttpHeaders.IfUnmodifiedSince, date.minusDays(1).toHttpDateString()) }).let { result ->
fun testIfUnModifiedSinceLess(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.minusDays(1).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.PreconditionFailed, result.response.status())
assertNull(result.response.content)
}
}
@Test
fun testIfUnModifiedSinceGt() = withConditionalApplication {
handleRequest(HttpMethod.Get, "/", { addHeader(HttpHeaders.IfUnmodifiedSince, date.plusDays(1).toHttpDateString()) }).let { result ->
fun testIfUnModifiedSinceGt(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(1).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
}
@Test
fun testIfUnmodifiedSinceIllegal(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfUnmodifiedSince,
"zzz"
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfUnmodifiedSince,
"zzz"
)
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfUnmodifiedSince,
"zzz"
)
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(1).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfUnmodifiedSince,
"zzz"
)
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(-1).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.PreconditionFailed, result.response.status())
assertEquals(null, result.response.content)
}
}
@Test
fun testIfUnmodifiedSinceMultiple(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfUnmodifiedSince,
""
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(1).toHttpDateString()
)
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(2).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(-1).toHttpDateString()
)
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(2).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.PreconditionFailed, result.response.status())
assertNull(result.response.content)
}
}
@Test
fun testIfModifiedSinceMultiple(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
""
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
date.plusDays(-1).toHttpDateString()
)
addHeader(
HttpHeaders.IfModifiedSince,
date.plusDays(2).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
date.plusDays(1).toHttpDateString()
)
addHeader(
HttpHeaders.IfModifiedSince,
date.plusDays(2).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.NotModified, result.response.status())
assertNull(result.response.content)
}
}
@Test
fun testBoth(): Unit = withConditionalApplication {
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
""
)
addHeader(
HttpHeaders.IfUnmodifiedSince,
""
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
date.plusDays(-1).toHttpDateString()
)
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(1).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals("response", result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
date.plusDays(-1).toHttpDateString()
)
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(-1).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.PreconditionFailed, result.response.status())
assertEquals(null, result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
date.plusDays(1).toHttpDateString()
)
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(1).toHttpDateString()
)
}.let { result ->
assertEquals(HttpStatusCode.NotModified, result.response.status())
assertEquals(null, result.response.content)
}
handleRequest(HttpMethod.Get, "/") {
addHeader(
HttpHeaders.IfModifiedSince,
date.plusDays(1).toHttpDateString()
)
addHeader(
HttpHeaders.IfUnmodifiedSince,
date.plusDays(-1).toHttpDateString()
)
}.let { result ->
// both conditions are not met but actually it is not clear which one should win
// so we declare the order
assertEquals(HttpStatusCode.NotModified, result.response.status())
assertEquals(null, result.response.content)
}
}
}
class LastModifiedVersionTest {
private fun temporaryDefaultTimezone(timeZone : TimeZone, block : () -> Unit) {
val originalTimeZone : TimeZone = TimeZone.getDefault()
private fun temporaryDefaultTimezone(timeZone: TimeZone, block: () -> Unit) {
val originalTimeZone: TimeZone = TimeZone.getDefault()
TimeZone.setDefault(timeZone)
try {
block()
@@ -294,15 +520,18 @@ class LastModifiedVersionTest {
}
}
private fun checkLastModifiedHeaderIsIndependentOfLocalTimezone(constructLastModifiedVersion : (Date) -> LastModifiedVersion) {
private fun checkLastModifiedHeaderIsIndependentOfLocalTimezone(constructLastModifiedVersion: (Date) -> LastModifiedVersion) {
// setup: any non-zero-offset-Timezone will do
temporaryDefaultTimezone(TimeZone.getTimeZone("GMT+08:00")) {
// guard: local default timezone needs to be different from GMT for the problem to manifest
assertTrue(TimeZone.getDefault().rawOffset != 0, "invalid test setup - local timezone is GMT: ${TimeZone.getDefault()}")
assertTrue(
TimeZone.getDefault().rawOffset != 0,
"invalid test setup - local timezone is GMT: ${TimeZone.getDefault()}"
)
// setup: last modified for file
val expectedLastModified : Date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z").parse("2018-03-04 15:12:23 GMT")
val expectedLastModified: Date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z").parse("2018-03-04 15:12:23 GMT")
// setup: object to test
val lastModifiedVersion = constructLastModifiedVersion(expectedLastModified)
@@ -320,26 +549,26 @@ class LastModifiedVersionTest {
@Test
fun lastModifiedHeaderFromDateIsIndependentOfLocalTimezone() {
checkLastModifiedHeaderIsIndependentOfLocalTimezone { input : Date ->
checkLastModifiedHeaderIsIndependentOfLocalTimezone { input: Date ->
LastModifiedVersion(input)
}
}
@Test
fun lastModifiedHeaderFromLocalDateTimeIsIndependentOfLocalTimezone() {
checkLastModifiedHeaderIsIndependentOfLocalTimezone { input : Date ->
checkLastModifiedHeaderIsIndependentOfLocalTimezone { input: Date ->
LastModifiedVersion(ZonedDateTime.ofInstant(input.toInstant(), ZoneId.systemDefault()))
}
}
@get:Rule
val temporaryFolder = TemporaryFolder()
val temporaryFolder: TemporaryFolder = TemporaryFolder()
@Test
fun lastModifiedHeaderFromFileTimeIsIndependentOfLocalTimezone() {
checkLastModifiedHeaderIsIndependentOfLocalTimezone { input : Date ->
checkLastModifiedHeaderIsIndependentOfLocalTimezone { input: Date ->
// setup: create file
val file : File = temporaryFolder.newFile("foo.txt").apply {
val file: File = temporaryFolder.newFile("foo.txt").apply {
setLastModified(input.time)
}

View File

@@ -11,6 +11,7 @@ import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.testing.*
import io.ktor.util.date.*
import org.junit.Test
import java.io.*
import java.util.*
@@ -22,8 +23,9 @@ class PartialContentTest {
.first(File::exists)
private val localPath = "features/StaticContentTest.kt"
private val fileEtag = "etag-99"
private fun withRangeApplication(maxRangeCount: Int? = null, test: TestApplicationEngine.(File) -> Unit) =
private fun withRangeApplication(maxRangeCount: Int? = null, test: TestApplicationEngine.(File) -> Unit): Unit =
withTestApplication {
application.install(ConditionalHeaders)
application.install(CachingHeaders)
@@ -36,7 +38,9 @@ class PartialContentTest {
handle {
val file = basedir.resolve(localPath)
if (file.isFile) {
call.respond(LocalFileContent(file))
call.respond(LocalFileContent(file).apply {
versions += EntityTagVersion(fileEtag)
})
}
}
}
@@ -240,10 +244,72 @@ class PartialContentTest {
@Test
fun testDontCrashWithEmptyIfRange(): Unit = withRangeApplication { file ->
handleRequest(HttpMethod.Get, localPath) {
addHeader("Range", "bytes=573-")
addHeader("If-Range", "")
addHeader(HttpHeaders.Range, "bytes=1-2")
addHeader(HttpHeaders.IfRange, "")
}.let { result ->
assertNull(result.response.headers[HttpHeaders.ContentLength])
assertEquals(HttpStatusCode.PartialContent, result.response.status())
assertEquals("bytes 1-2/${file.length()}", result.response.headers[HttpHeaders.ContentRange])
}
}
@Test
fun testIfRangeETag(): Unit = withRangeApplication { file ->
handleRequest(HttpMethod.Get, localPath) {
addHeader(HttpHeaders.Range, "bytes=1-2")
addHeader(HttpHeaders.IfRange, "\"$fileEtag\"")
}.let { result ->
assertEquals(HttpStatusCode.PartialContent, result.response.status())
assertEquals("bytes 1-2/${file.length()}", result.response.headers[HttpHeaders.ContentRange])
}
handleRequest(HttpMethod.Get, localPath) {
addHeader(HttpHeaders.Range, "bytes=1-2")
addHeader(HttpHeaders.IfRange, "\"wrong-$fileEtag\"")
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals(null, result.response.headers[HttpHeaders.ContentRange])
}
}
@Test
fun testIfRangeDate(): Unit = withRangeApplication { file ->
val fileDate = GMTDate(file.lastModified())
handleRequest(HttpMethod.Get, localPath) {
addHeader(HttpHeaders.Range, "bytes=1-2")
addHeader(HttpHeaders.IfRange, fileDate.toHttpDate())
}.let { result ->
assertEquals(HttpStatusCode.PartialContent, result.response.status())
assertEquals("bytes 1-2/${file.length()}", result.response.headers[HttpHeaders.ContentRange])
}
handleRequest(HttpMethod.Get, localPath) {
addHeader(HttpHeaders.Range, "bytes=1-2")
addHeader(HttpHeaders.IfRange, fileDate.plus(10000).toHttpDate())
}.let { result ->
assertEquals(HttpStatusCode.PartialContent, result.response.status())
assertEquals("bytes 1-2/${file.length()}", result.response.headers[HttpHeaders.ContentRange])
}
handleRequest(HttpMethod.Get, localPath) {
addHeader(HttpHeaders.Range, "bytes=1-2")
addHeader(HttpHeaders.IfRange, fileDate.minus(100000).toHttpDate())
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals(null, result.response.headers[HttpHeaders.ContentRange])
}
}
@Test
fun testIfRangeWrongDate(): Unit = withRangeApplication { file ->
val fileDate = GMTDate(file.lastModified())
handleRequest(HttpMethod.Get, localPath) {
addHeader(HttpHeaders.Range, "bytes=1-2")
addHeader(HttpHeaders.IfRange, fileDate.toHttpDate().drop(15))
}.let { result ->
assertEquals(HttpStatusCode.OK, result.response.status())
assertEquals(null, result.response.headers[HttpHeaders.ContentRange])
}
}