src/web.bend
ezmcp@0.1.0.0 · 229 lines · 27 definitions
1
2
3
4
5
6import Base
7import emerging-ezjson@1.2.0.0/main.bend as Json
8import emerging-ezjson@1.2.0.0/src/value.bend as Value
9import emerging-ezhttp@0.21.0.0/src/http.bend as Http
10import ezmcp@0.1.0.0/src/rpc.bend as Rpc
11import ezmcp@0.1.0.0/src/server.bend as Server
12
13
14
15
16type Web is Data:
17 Now{status: U32, headers: List<&2, Http.Header>, body: String}
18 Later{rev: String, id: Value.Json, name: String, args: Value.Json}
19 Many{rev: String, acts: List<&2, Server.Act>}
20
21
22def json() -> List<&2, Http.Header>:
23 [Http.H{"Content-Type", "application/json"}]
24
25
26def json.reply(status: U32, val: Value.Json) -> Web:
27 Now{status, json(), Json.print(val)}
28
29
30
31
32def act(rev: String, lead: Server.Act) -> Web:
33 match lead:
34 case Server.Quiet{}:
35 Now{202, [], ""}
36 case Server.Send{reply}:
37 json.reply(200, reply)
38 case Server.Fault{reply}:
39 json.reply(400, reply)
40 case Server.Run{id, name, args}:
41 Later{rev, id, name, args}
42 case Server.Batch{acts}:
43 Many{rev, acts}
44
45
46def ran(rev: String, id: Value.Json, out: Rpc.Out) -> Web:
47 json.reply(200, Server.ran(rev, id, out))
48
49
50
51def many(replies: Maybe<&2, Value.Json>) -> Web:
52 match replies:
53 case None{}:
54 Now{202, [], ""}
55 case Some{batch}:
56 json.reply(200, batch)
57
58
59def path.of(target: String) -> String:
60 Http.first(String.split(target, '?'))
61
62
63def origin.or(hit: Bool, rest: Unit -> Bool) -> Bool:
64 match hit:
65 case True{}:
66 True{}
67 case False{}:
68 rest(Unit{})
69
70
71def origin.listed(origins: List<&2, String>, +origin: String) -> Bool:
72 match origins:
73 case []:
74 False{}
75 case listed <> rest:
76 origin.or(String.eq(listed, origin), _u => origin.listed(rest, origin))
77
78
79def loopback.colon(tail: String, colon: Bool) -> Bool:
80 match colon:
81 case True{}:
82 Rpc.digits(tail)
83 case False{}:
84 False{}
85
86
87def loopback.port(rest: String) -> Bool:
88 match rest:
89 case SNil{}:
90 True{}
91 case SCon{head, tail}:
92 loopback.colon(tail, Char.is_eq(head, ':'))
93
94
95def loopback.at(+after: String, num: Nat, hit: Bool) -> Bool:
96 match hit:
97 case True{}:
98 loopback.port(String.drop(after, num))
99 case False{}:
100 False{}
101
102
103def loopback.host(+after: String) -> Bool:
104 origin.or(loopback.at(after, 9n, String.starts_with(after, "localhost")),
105 _u => origin.or(loopback.at(after, 9n,
106 String.starts_with(after, "127.0.0.1")),
107 _v => loopback.at(after, 5n, String.starts_with(after, "[::1]"))))
108
109
110def loopback.scheme(+origin: String, num: Nat, hit: Bool) -> Bool:
111 match hit:
112 case True{}:
113 loopback.host(String.drop(origin, num))
114 case False{}:
115 False{}
116
117
118
119def loopback(+origin: String) -> Bool:
120 origin.or(loopback.scheme(origin, 7n, String.starts_with(origin, "http://")),
121 _u => loopback.scheme(origin, 8n, String.starts_with(origin, "https://")))
122
123
124
125def origin.ok(origins: List<&2, String>, +origin: String) -> Bool:
126 origin.or(String.is_empty(origin),
127 _u => origin.or(loopback(origin), _v => origin.listed(origins, origin)))
128
129
130
131def version.ok(+val: String) -> Bool:
132 Bool.or(String.is_empty(val), Server.supported(val))
133
134
135
136def revision(+val: String) -> String:
137 Bool.pick(String, String.is_empty(val), Server.oldest(), val)
138
139
140def text(status: U32, body: String) -> Web:
141 Now{status, [Http.H{"Content-Type", "text/plain"}], body}
142
143
144def post.as(srv: Server.Server, body: String, +rev: String) -> Web:
145 act(rev, Server.step(srv, rev, body))
146
147
148
149def post.ok(srv: Server.Server, body: String, header: String, ok: Bool) -> Web:
150 match ok:
151 case False{}:
152 text(400, "Bad Request: unsupported MCP-Protocol-Version")
153 case True{}:
154 post.as(srv, body, revision(header))
155
156
157def post(srv: Server.Server, body: String, +header: String) -> Web:
158 post.ok(srv, body, header, version.ok(header))
159
160
161def method(
162 srv: Server.Server,
163 body: String,
164 header: String,
165 is_post: Bool
166) -> Web:
167 match is_post:
168 case False{}:
169 Now{405, [Http.H{"Allow", "POST"}], ""}
170 case True{}:
171 post(srv, body, header)
172
173
174def origin(
175 srv: Server.Server,
176 body: String,
177 header: String,
178 is_post: Bool,
179 allowed: Bool
180) -> Web:
181 match allowed:
182 case False{}:
183 text(403, "Forbidden: Origin not allowed")
184 case True{}:
185 method(srv, body, header, is_post)
186
187
188
189
190def route(
191 srv: Server.Server,
192 here: Bool,
193 allowed: Bool,
194 is_post: Bool,
195 header: String,
196 body: String
197) -> Web:
198 match here:
199 case False{}:
200 text(404, "Not Found")
201 case True{}:
202 origin(srv, body, header, is_post, allowed)
203
204
205
206def decide(
207 srv: Server.Server,
208 path: String,
209 origins: List<&2, String>,
210 req: Http.Request
211) -> Web:
212 match req:
213 case Http.Bad{why}:
214 text(400, why)
215 case Http.Request{+m, +target, +headers, body}:
216 route(srv, String.eq(path.of(target), path),
217 origin.ok(origins, Http.headers.find(headers, "Origin")),
218 String.eq(m, "POST"),
219 Http.headers.find(headers, "MCP-Protocol-Version"), body)
220
221
222def reply(wid: Web) -> Http.Reply:
223 match wid:
224 case Now{status, headers, body}:
225 Http.Reply{status, headers, body}
226 case Later{rev, id, name, args}:
227 Http.Reply{500, [], "ezmcp: a tool call was not run"}
228 case Many{rev, acts}:
229 Http.Reply{500, [], "ezmcp: a batch was not run"}