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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,21 @@ of an `If-Match` or `If-None-Match` header.
(Result.Error e) (IO.errorln &e))
```

`If-Range` is not evaluated yet.
`If-Range` gates a `Range` header on the client still holding the version it is
asking to continue (§13.1.5). A non-match means ignoring the `Range` and sending
the whole representation with a `200`, rather than splicing bytes of a changed
representation into the client's copy.

```clojure
(if (Request.if-range-matches? &req &etag &modified)
(serve-range &req) ; no If-Range, or its validator still matches
(whole-representation))
```

The validator is either an entity-tag, compared strongly — so a weak one never
matches — or an HTTP-date, which must name the very instant the representation
was last modified. §13.2.1 has the §13.2.2 preconditions evaluated first, then
`If-Range`, then the `Range` itself.

### Status codes

Expand Down
42 changes: 39 additions & 3 deletions docs/Precondition.html
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,9 @@ <h1>
(Maybe.Just code) (Response.respond code {} @&quot;&quot;)
(Maybe.Nothing) (Response.ok {} @&quot;the body&quot;))
</code></pre>
<p><code>If-Range</code> is not evaluated: a range request whose condition fails is answered
with the whole representation, which is
<a href="ByteRange.html"><code>ByteRange</code></a>'s business rather than a status.</p>
<p><code>If-Range</code> demands no status of its own, so it is evaluated apart from the four,
by <a href="#if-range-matches?">if-range-matches?</a>: a range request whose condition fails
is answered with the whole representation rather than an error.</p>

</div>
<div class="binder">
Expand Down Expand Up @@ -207,6 +207,42 @@ <h3 id="evaluate">
<a href="ETagList.html#strong-match?"><code>ETagList.strong-match?</code></a> and
<a href="ETagList.html#weak-match?"><code>ETagList.weak-match?</code></a> are public for it.</p>

</p>
</div>
<div class="binder">
<a class="anchor" href="#if-range-matches?">
<h3 id="if-range-matches?">
if-range-matches?
</h3>
</a>
<div class="description">
defn
</div>
<p class="sig">
(Fn [(Ref (Map String (Array String)) a), (Ref (Maybe ETag) b), (Ref (Maybe Datetime) c)] Bool)
</p>
<pre class="args">
(if-range-matches? hdrs etag modified)
</pre>
<p class="doc">
<p>whether the <code>Range</code> header in <code>hdrs</code> is to be honored,
given <code>etag</code> and <code>modified</code>, the entity-tag and last modification date of the
representation the server selected — either may be <code>Nothing</code>.</p>
<p><code>true</code> when the request carries no <code>If-Range</code>, when it carries no <code>Range</code> for one
to gate, or when the <code>If-Range</code> validator matches (RFC 9110 §13.1.5). <code>false</code>
means the <code>Range</code> must be ignored and the whole representation sent with a <code>200</code>,
the client holding a version other than the one it asks to continue.</p>
<p>The two validator forms are told apart by the first characters of the field
value: a quote or a <code>W/</code> names an entity-tag, anything else an HTTP-date. An
entity-tag is compared strongly (§8.8.3.2), so a weak one — which §13.1.5 forbids
a client to send and obliges a recipient to ignore — never matches. A date
matches the modification date only when it names the very same instant, as
§8.8.2.2's strong-validator rule demands. A field value that reads as neither
form, and a validator the selected representation has no counterpart to, are
non-matches.</p>
<p>§13.2.1 orders the three steps of a conditional range request:
<a href="#evaluate">evaluate</a> first, then this, then the <code>Range</code> itself.</p>

</p>
</div>
</div>
Expand Down
27 changes: 26 additions & 1 deletion docs/Request.html
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,30 @@ <h3 id="headers">

</p>
</div>
<div class="binder">
<a class="anchor" href="#if-range-matches?">
<h3 id="if-range-matches?">
if-range-matches?
</h3>
</a>
<div class="description">
defn
</div>
<p class="sig">
(Fn [(Ref Request a), (Ref (Maybe ETag) b), (Ref (Maybe Datetime) c)] Bool)
</p>
<pre class="args">
(if-range-matches? r etag modified)
</pre>
<p class="doc">
<p>whether this request's <code>Range</code> header is to be honored
for a representation whose entity-tag is <code>etag</code> and whose last modification date
is <code>modified</code>. <code>false</code> means the <code>Range</code> is to be ignored and the whole
representation sent. See
<a href="Precondition.html#if-range-matches?"><code>Precondition.if-range-matches?</code></a>.</p>

</p>
</div>
<div class="binder">
<a class="anchor" href="#ignore-body?">
<h3 id="ignore-body?">
Expand Down Expand Up @@ -637,7 +661,8 @@ <h3 id="range">
<code>(Maybe (Result (Array ByteRangeSpec) String))</code>. <code>Nothing</code> when it carries no
<code>Range</code> header. See <a href="ByteRange.html#parse"><code>ByteRange.parse</code></a>, whose <code>Error</code>
means the header is one RFC 9110 §14.2 has the server ignore — serving the whole
representation rather than a <code>416</code>.</p>
representation rather than a <code>416</code>. A request that also carries an <code>If-Range</code>
wants <a href="#if-range-matches?">if-range-matches?</a> consulted first.</p>

</p>
</div>
Expand Down
73 changes: 67 additions & 6 deletions http.carp
Original file line number Diff line number Diff line change
Expand Up @@ -2330,9 +2330,9 @@ the status they demand.
(Maybe.Nothing) (Response.ok {} @\"the body\"))
```

`If-Range` is not evaluated: a range request whose condition fails is answered
with the whole representation, which is
[`ByteRange`](ByteRange.html)'s business rather than a status.")
`If-Range` demands no status of its own, so it is evaluated apart from the four,
by [if-range-matches?](#if-range-matches?): a range request whose condition fails
is answered with the whole representation rather than an error.")
(defmodule Precondition
(hidden list-header)
(private list-header)
Expand Down Expand Up @@ -2445,7 +2445,59 @@ conditional creation an `If-None-Match` of `*` guards is the caller's too;
(Maybe.Nothing)
(if (read? method)
(modified-since-status hdrs modified)
(Maybe.Nothing)))))))
(Maybe.Nothing))))))

(hidden if-range-etag-match?)
(private if-range-etag-match?)
(defn if-range-etag-match? [v etag]
(match (ETag.parse v)
(Result.Error _) false
(Result.Success e)
(match-ref etag
(Maybe.Nothing) false
(Maybe.Just cur) (ETag.strong-match? &e cur))))

(hidden if-range-date-match?)
(private if-range-date-match?)
(defn if-range-date-match? [v modified]
(match (HttpDate.parse v)
(Result.Error _) false
(Result.Success d)
(match-ref modified
(Maybe.Nothing) false
(Maybe.Just m) (Datetime.equal-instant? m &d))))

(doc if-range-matches? "whether the `Range` header in `hdrs` is to be honored,
given `etag` and `modified`, the entity-tag and last modification date of the
representation the server selected — either may be `Nothing`.

`true` when the request carries no `If-Range`, when it carries no `Range` for one
to gate, or when the `If-Range` validator matches (RFC 9110 §13.1.5). `false`
means the `Range` must be ignored and the whole representation sent with a `200`,
the client holding a version other than the one it asks to continue.

The two validator forms are told apart by the first characters of the field
value: a quote or a `W/` names an entity-tag, anything else an HTTP-date. An
entity-tag is compared strongly (§8.8.3.2), so a weak one — which §13.1.5 forbids
a client to send and obliges a recipient to ignore — never matches. A date
matches the modification date only when it names the very same instant, as
§8.8.2.2's strong-validator rule demands. A field value that reads as neither
form, and a validator the selected representation has no counterpart to, are
non-matches.

§13.2.1 orders the three steps of a conditional range request:
[evaluate](#evaluate) first, then this, then the `Range` itself.")
(defn if-range-matches? [hdrs etag modified]
(match (header-lookup hdrs "If-Range")
(Maybe.Nothing) true
(Maybe.Just v)
(if (Maybe.nothing? &(header-lookup hdrs "Range"))
true
(let [t (String.trim &v)]
(if (or (String.byte-starts-with? &t "\"")
(String.byte-starts-with? &t "W/"))
(if-range-etag-match? &t etag)
(if-range-date-match? &t modified)))))))

(defmodule Request
(doc form-data "parses the request body as URL-encoded form data.
Expand Down Expand Up @@ -2510,7 +2562,8 @@ from the server's `offers`, honoring the request's `Accept-Language` header (RFC
`(Maybe (Result (Array ByteRangeSpec) String))`. `Nothing` when it carries no
`Range` header. See [`ByteRange.parse`](ByteRange.html#parse), whose `Error`
means the header is one RFC 9110 §14.2 has the server ignore — serving the whole
representation rather than a `416`.")
representation rather than a `416`. A request that also carries an `If-Range`
wants [if-range-matches?](#if-range-matches?) consulted first.")
(defn range [r]
(match (Request.header r "Range")
(Maybe.Nothing) (Maybe.Nothing)
Expand All @@ -2522,4 +2575,12 @@ modification date is `modified`, as `(Maybe Int)`. `Nothing` means every
precondition held and the request should be answered normally. See
[`Precondition.evaluate`](Precondition.html#evaluate).")
(defn preconditions [r etag modified]
(Precondition.evaluate (headers r) (verb r) etag modified)))
(Precondition.evaluate (headers r) (verb r) etag modified))

(doc if-range-matches? "whether this request's `Range` header is to be honored
for a representation whose entity-tag is `etag` and whose last modification date
is `modified`. `false` means the `Range` is to be ignored and the whole
representation sent. See
[`Precondition.if-range-matches?`](Precondition.html#if-range-matches?).")
(defn if-range-matches? [r etag modified]
(Precondition.if-range-matches? (headers r) etag modified)))
74 changes: 74 additions & 0 deletions test/http.carp
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,13 @@
(Maybe.Nothing) @"OK"
(Maybe.Just code) (Int.str code))))

(defn if-range? [v etag modified]
(Precondition.if-range-matches?
&{@"Range" [@"bytes=0-499"]
@"If-Range" [@v]}
etag
modified))

; ---- hostile-byte test helpers ----
; a run of UTF-8 continuation bytes: `n` bytes long, zero characters long
(defn continuation [n] (String.from-bytes &(Array.replicate n &128b)))
Expand Down Expand Up @@ -2031,6 +2038,73 @@
&(no-etag)
&(at "Sun, 06 Nov 1994 08:49:37 GMT"))
"Request.preconditions gates If-Modified-Since on the request's own method")
(assert-true test
(if-range? "\"a\"" &(some-etag "a" false) &(no-date))
"an If-Range entity-tag matching the representation honors the Range")
(assert-false test
(if-range? "\"b\"" &(some-etag "a" false) &(no-date))
"an If-Range entity-tag naming another version ignores the Range")
(assert-false test
(if-range? "W/\"a\"" &(some-etag "a" false) &(no-date))
"an If-Range compares strongly, so a weak entity-tag never matches")
(assert-false test
(if-range? "\"a\"" &(some-etag "a" true) &(no-date))
"an If-Range against a weakly validated representation never matches")
(assert-false test
(if-range? "\"a\"" &(no-etag) &(no-date))
"an If-Range entity-tag against a representation with none is a non-match")
(assert-false test
(if-range? "junk" &(some-etag "a" false) &(no-date))
"an If-Range that reads as neither validator form is a non-match")
(assert-true test
(if-range? "Sun, 06 Nov 1994 08:49:37 GMT"
&(no-etag)
&(at "Sun, 06 Nov 1994 08:49:37 GMT"))
"an If-Range date equal to the modification date honors the Range")
(assert-false test
(if-range? "Sun, 06 Nov 1994 08:49:36 GMT"
&(no-etag)
&(at "Sun, 06 Nov 1994 08:49:37 GMT"))
"an If-Range date a second before the modification date is a non-match")
(assert-false test
(if-range? "Sun, 06 Nov 1994 08:49:38 GMT"
&(no-etag)
&(at "Sun, 06 Nov 1994 08:49:37 GMT"))
"an If-Range date a second after the modification date is a non-match")
(assert-true test
(if-range? "Sunday, 06-Nov-94 08:49:37 GMT"
&(no-etag)
&(at "Sun, 06 Nov 1994 08:49:37 GMT"))
"an If-Range date names an instant, whichever HTTP-date format spells it")
(assert-false test
(if-range? "not a date" &(no-etag) &(at "Sun, 06 Nov 1994 08:49:37 GMT"))
"an If-Range date the server cannot read is a non-match")
(assert-false test
(if-range? "Sun, 06 Nov 1994 08:49:37 GMT" &(some-etag "a" false) &(no-date))
"an If-Range date against a representation with none is a non-match")
(assert-true test
(Precondition.if-range-matches? &{@"Range" [@"bytes=0-499"]}
&(some-etag "a" false)
&(no-date))
"a Range with no If-Range is honored")
(assert-true test
(Precondition.if-range-matches? &{@"If-Range" [@"\"b\""]}
&(some-etag "a" false)
&(no-date))
"an If-Range without a Range to gate is ignored")
(assert-true test
(Precondition.if-range-matches? &(no-headers) &(no-etag) &(no-date))
"a request with neither header is honored")
(assert-false test
(Request.if-range-matches?
&(Request.get (URI.zero)
[]
{@"Range" [@"bytes=0-499"]
@"If-Range" [@"\"b\""]}
@"")
&(some-etag "a" false)
&(no-date))
"Request.if-range-matches? evaluates the request's own headers")
(assert-equal test
412
Status.precondition-failed
Expand Down