src/cache.bend
emerging-ezhttp@0.17.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.17.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)