src/cache.bend

emerging-ezhttp@0.14.0.0 · 174 lines · 0 definitions

1# ezhttp/src/cache: Cache-Control and a small freshness check (RFC 9111). This is
2# not a shared cache. Expires is recorded; max-age overrides it for freshness.
3# IMF-fixdate arithmetic is not evaluated.
4import Base
5import emerging-ezhttp@0.14.0.0/src/http.bend as Http
6
7# the directives this module records
8type Cc is Data:
9  Cc{
10    no_store: Bool
11    no_cache: Bool
12    public: Bool
13    private: Bool
14    max_age: String
15    expires: String
16  }
17
18# no directives
19def cache.empty() -> Cc:
20  Cc{False{}, False{}, False{}, False{}, "", ""}
21
22# set no-store
23def cache.store(c: Cc) -> Cc:
24  match c:
25    case Cc{no_store, no_cache, public, private, max_age, expires}:
26      Cc{True{}, no_cache, public, private, max_age, expires}
27
28# set no-cache
29def cache.revalidate(c: Cc) -> Cc:
30  match c:
31    case Cc{no_store, no_cache, public, private, max_age, expires}:
32      Cc{no_store, True{}, public, private, max_age, expires}
33
34# set public
35def cache.pub(c: Cc) -> Cc:
36  match c:
37    case Cc{no_store, no_cache, public, private, max_age, expires}:
38      Cc{no_store, no_cache, True{}, private, max_age, expires}
39
40# set private
41def cache.priv(c: Cc) -> Cc:
42  match c:
43    case Cc{no_store, no_cache, public, private, max_age, expires}:
44      Cc{no_store, no_cache, public, True{}, max_age, expires}
45
46# set max-age text
47def cache.age(c: Cc, age: String) -> Cc:
48  match c:
49    case Cc{no_store, no_cache, public, private, max_age, expires}:
50      Cc{no_store, no_cache, public, private, age, expires}
51
52# set the Expires header text
53def cache.expires(c: Cc, when: String) -> Cc:
54  match c:
55    case Cc{no_store, no_cache, public, private, max_age, expires}:
56      Cc{no_store, no_cache, public, private, max_age, when}
57
58# private, or leave the directive unrecognized
59def cache.one.private(c: Cc, _name: String, _value: String, hit: Bool) -> Cc:
60  match hit:
61    case True{}:
62      cache.priv(c)
63    case False{}:
64      c
65
66# public
67def cache.one.public(c: Cc, +name: String, value: String, hit: Bool) -> Cc:
68  match hit:
69    case True{}:
70      cache.pub(c)
71    case False{}:
72      cache.one.private(c, name, value, String.eq(name, "private"))
73
74# max-age, kept only when the argument is digits (delta-seconds)
75def cache.one.age_ok(c: Cc, +raw: String, m: Maybe<&2, Nat>) -> Cc:
76  match m:
77    case None{}:
78      c
79    case Some{n}:
80      cache.age(c, raw)
81
82# max-age
83def cache.one.age(c: Cc, +name: String, +value: String, hit: Bool) -> Cc:
84  match hit:
85    case True{}:
86      cache.one.age_ok(c, value, Http.digits.read(value))
87    case False{}:
88      cache.one.public(c, name, value, String.eq(name, "public"))
89
90# no-cache
91def cache.one.cache(c: Cc, +name: String, +value: String, hit: Bool) -> Cc:
92  match hit:
93    case True{}:
94      cache.revalidate(c)
95    case False{}:
96      cache.one.age(c, name, value, String.eq(name, "max-age"))
97
98# no-store
99def cache.one.store(c: Cc, +name: String, +value: String, hit: Bool) -> Cc:
100  match hit:
101    case True{}:
102      cache.store(c)
103    case False{}:
104      cache.one.cache(c, name, value, String.eq(name, "no-cache"))
105
106# a directive with no `=` argument
107def cache.one.flag(c: Cc, +name: String) -> Cc:
108  cache.one.store(c, name, "", String.eq(name, "no-store"))
109
110# flag or name=value
111def cache.one.dispatch(c: Cc, +piece: String, cut: Http.Cut) -> Cc:
112  match cut:
113    case Http.NoCut{}:
114      cache.one.flag(c, String.to_lower(String.trim(piece)))
115    case Http.Cut{+before, after}:
116      +name = String.to_lower(String.trim(before))
117      cache.one.store(c, name, String.trim(after), String.eq(name, "no-store"))
118
119# one directive text
120def cache.one(c: Cc, +piece: String) -> Cc:
121  cache.one.dispatch(c, piece, Http.divide(piece, "="))
122
123# walk directives. The list shrinks.
124def cache.fold(ps: List<&2, String>, c: Cc) -> Cc:
125  match ps:
126    case []:
127      c
128    case h <> t:
129      cache.fold(t, cache.one(c, String.trim(h)))
130
131# Cache-Control field value (RFC 9111 §5.2)
132def cache.parse(value: String) -> Cc:
133  cache.fold(String.split(value, ','), cache.empty())
134
135# record an Expires header beside Cache-Control (RFC 9111 §5.3)
136def cache.note(value: String, expires: String) -> Cc:
137  cache.expires(cache.parse(value), expires)
138
139# true when max-age is present, so it overrides Expires (RFC 9111 §4.2.1)
140def cache.max_age(c: Cc) -> Bool:
141  match c:
142    case Cc{no_store, no_cache, public, private, max_age, expires}:
143      Bool.not(String.eq(max_age, ""))
144
145# freshness from max-age. no-store and no-cache are not fresh.
146def cache.fresh.go(age: Nat, m: Maybe<&2, Nat>) -> Bool:
147  match m:
148    case None{}:
149      False{}
150    case Some{n}:
151      Nat.is_lt(age, n)
152
153# no-cache must revalidate
154def cache.fresh.cache(age: Nat, max_age: String, no_cache: Bool) -> Bool:
155  match no_cache:
156    case True{}:
157      False{}
158    case False{}:
159      cache.fresh.go(age, Http.digits.read(max_age))
160
161# no-store is not reused
162def cache.fresh.store(age: Nat, no_cache: Bool, max_age: String, no_store: Bool)
163  -> Bool:
164  match no_store:
165    case True{}:
166      False{}
167    case False{}:
168      cache.fresh.cache(age, max_age, no_cache)
169
170# age seconds against the directives. max-age=0 is stale (RFC 9111 §4.2.1).
171def cache.fresh(c: Cc, age: Nat) -> Bool:
172  match c:
173    case Cc{no_store, no_cache, public, private, max_age, expires}:
174      cache.fresh.store(age, no_cache, max_age, no_store)