Lexicon-driven ATProto AppView in Gleam — port of HappyView
1//// Default XRPC procedure handling (local index or OAuth PDS proxy).
2
3import gleam/dynamic.{type Dynamic}
4import gleam/json
5import gleam/option.{None, Some}
6import gleam/result
7import gleam/string
8import gleamview/db.{type Db}
9import gleamview/error.{type AppError}
10import gleamview/json_util
11import gleamview/lexicon.{type ParsedLexicon, Create, Delete, Update, Upsert}
12import gleamview/pds_write
13import gleamview/records
14import gleamview/util
15
16/// Claims for a write. `identity` is DID or handle used for OAuth session lookup.
17pub type WriteClaims {
18 WriteClaims(did: String)
19}
20
21pub fn handle(
22 db: Db,
23 method: String,
24 claims: WriteClaims,
25 input: Dynamic,
26 input_json: String,
27 lexicon: ParsedLexicon,
28 allow_local: Bool,
29) -> Result(String, AppError) {
30 use collection <- result.try(case lexicon.target_collection {
31 Some(c) -> Ok(c)
32 None ->
33 Error(error.BadRequest(
34 method <> " has no target_collection configured",
35 ))
36 })
37 let action = resolve_action(lexicon.action, input)
38 case allow_local {
39 True ->
40 case action {
41 Create -> create_record_local(db, claims.did, collection, input_json)
42 Update -> put_record_local(db, claims.did, collection, input, input_json)
43 Delete -> delete_record_local(db, claims.did, collection, input)
44 Upsert -> create_record_local(db, claims.did, collection, input_json)
45 }
46 False ->
47 case action {
48 Create -> create_record_pds(db, claims.did, collection, input_json)
49 Update -> put_record_pds(db, claims.did, collection, input, input_json)
50 Delete -> delete_record_pds(db, claims.did, collection, input)
51 Upsert -> create_record_pds(db, claims.did, collection, input_json)
52 }
53 }
54}
55
56fn resolve_action(
57 action: lexicon.ProcedureAction,
58 input: Dynamic,
59) -> lexicon.ProcedureAction {
60 case action {
61 Upsert ->
62 case json_util.optional_string(input, "uri") {
63 Some(_) -> Update
64 None -> Create
65 }
66 other -> other
67 }
68}
69
70// ---------------------------------------------------------------------------
71// Local index mode
72// ---------------------------------------------------------------------------
73
74fn create_record_local(
75 db: Db,
76 did: String,
77 collection: String,
78 input_json: String,
79) -> Result(String, AppError) {
80 let rkey = util.new_token("", 13) |> tidish
81 let body = ensure_type(input_json, collection)
82 use uri <- result.try(records.upsert(db, did, collection, rkey, body, ""))
83 Ok(
84 json.object([
85 #("uri", json.string(uri)),
86 #("cid", json.string("")),
87 #("commit", json.object([
88 #("cid", json.string("")),
89 #("rev", json.string("")),
90 ])),
91 ])
92 |> json.to_string,
93 )
94}
95
96fn put_record_local(
97 db: Db,
98 did: String,
99 collection: String,
100 input: Dynamic,
101 input_json: String,
102) -> Result(String, AppError) {
103 use client_uri <- result.try(
104 json_util.field_string(input, "uri")
105 |> result.map_error(error.BadRequest),
106 )
107 use rkey <- result.try(
108 util.rkey_from_uri(client_uri)
109 |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }),
110 )
111 let body = case extract_record_json(input_json) {
112 Ok(rec) -> ensure_type(rec, collection)
113 Error(_) -> ensure_type("{}", collection)
114 }
115 use uri <- result.try(records.upsert(db, did, collection, rkey, body, ""))
116 Ok(
117 json.object([
118 #("uri", json.string(uri)),
119 #("cid", json.string("")),
120 ])
121 |> json.to_string,
122 )
123}
124
125fn delete_record_local(
126 db: Db,
127 did: String,
128 collection: String,
129 input: Dynamic,
130) -> Result(String, AppError) {
131 use client_uri <- result.try(
132 json_util.field_string(input, "uri")
133 |> result.map_error(error.BadRequest),
134 )
135 use rkey <- result.try(
136 util.rkey_from_uri(client_uri)
137 |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }),
138 )
139 let uri = util.at_uri(did, collection, rkey)
140 use _ <- result.try(records.delete(db, uri))
141 Ok("{}")
142}
143
144// ---------------------------------------------------------------------------
145// OAuth PDS proxy mode (also indexes locally after successful write)
146// ---------------------------------------------------------------------------
147
148fn create_record_pds(
149 db: Db,
150 identity: String,
151 collection: String,
152 input_json: String,
153) -> Result(String, AppError) {
154 let rkey = util.new_token("", 13) |> tidish
155 let body = ensure_type(input_json, collection)
156 use wr <- result.try(pds_write.create_record(identity, collection, body, rkey))
157 // Index for AppView queries
158 let _ = records.upsert(db, identity_did(identity, wr.uri), collection, rkey, body, wr.cid)
159 Ok(
160 json.object([
161 #("uri", json.string(wr.uri)),
162 #("cid", json.string(wr.cid)),
163 #("commit", json.object([
164 #("cid", json.string(wr.cid)),
165 #("rev", json.string("")),
166 ])),
167 ])
168 |> json.to_string,
169 )
170}
171
172fn put_record_pds(
173 db: Db,
174 identity: String,
175 collection: String,
176 input: Dynamic,
177 input_json: String,
178) -> Result(String, AppError) {
179 use client_uri <- result.try(
180 json_util.field_string(input, "uri")
181 |> result.map_error(error.BadRequest),
182 )
183 use rkey <- result.try(
184 util.rkey_from_uri(client_uri)
185 |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }),
186 )
187 let body = case extract_record_json(input_json) {
188 Ok(rec) -> ensure_type(rec, collection)
189 Error(_) -> ensure_type(input_json, collection)
190 }
191 use wr <- result.try(pds_write.put_record(identity, collection, rkey, body))
192 let _ = records.upsert(db, identity_did(identity, wr.uri), collection, rkey, body, wr.cid)
193 Ok(
194 json.object([
195 #("uri", json.string(wr.uri)),
196 #("cid", json.string(wr.cid)),
197 ])
198 |> json.to_string,
199 )
200}
201
202fn delete_record_pds(
203 db: Db,
204 identity: String,
205 collection: String,
206 input: Dynamic,
207) -> Result(String, AppError) {
208 use client_uri <- result.try(
209 json_util.field_string(input, "uri")
210 |> result.map_error(error.BadRequest),
211 )
212 use rkey <- result.try(
213 util.rkey_from_uri(client_uri)
214 |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }),
215 )
216 use _ <- result.try(pds_write.delete_record(identity, collection, rkey))
217 let uri = util.at_uri(identity, collection, rkey)
218 let _ = records.delete(db, uri)
219 // also try uri from client
220 let _ = records.delete(db, client_uri)
221 Ok("{}")
222}
223
224/// Prefer DID from AT URI when identity was a handle.
225fn identity_did(identity: String, uri: String) -> String {
226 case string.starts_with(identity, "did:") {
227 True -> identity
228 False ->
229 case string.split(uri, "/") {
230 ["at:", "", did, ..] -> did
231 _ -> identity
232 }
233 }
234}
235
236fn ensure_type(input_json: String, collection: String) -> String {
237 case string.contains(input_json, "\"$type\"") {
238 True -> input_json
239 False ->
240 case input_json {
241 "{" <> rest ->
242 "{\"$type\":"
243 <> json.to_string(json.string(collection))
244 <> case rest {
245 "}" -> "}"
246 _ -> "," <> rest
247 }
248 _ -> input_json
249 }
250 }
251}
252
253/// Pull nested `"record":{...}` JSON object from a procedure body when present.
254fn extract_record_json(input_json: String) -> Result(String, Nil) {
255 let needle = "\"record\":"
256 case string.split_once(input_json, needle) {
257 Error(_) -> Error(Nil)
258 Ok(#(_, rest)) -> {
259 let rest = string.trim_start(rest)
260 case rest {
261 "{" <> _ -> extract_balanced_object(rest)
262 _ -> Error(Nil)
263 }
264 }
265 }
266}
267
268fn extract_balanced_object(s: String) -> Result(String, Nil) {
269 do_bal(s, 0, False, False, "")
270}
271
272fn do_bal(
273 rest: String,
274 depth: Int,
275 in_str: Bool,
276 escape: Bool,
277 acc: String,
278) -> Result(String, Nil) {
279 case string.pop_grapheme(rest) {
280 Error(_) -> Error(Nil)
281 Ok(#(ch, rest2)) -> {
282 let acc2 = acc <> ch
283 case in_str {
284 True ->
285 case escape {
286 True -> do_bal(rest2, depth, True, False, acc2)
287 False ->
288 case ch {
289 "\\" -> do_bal(rest2, depth, True, True, acc2)
290 "\"" -> do_bal(rest2, depth, False, False, acc2)
291 _ -> do_bal(rest2, depth, True, False, acc2)
292 }
293 }
294 False ->
295 case ch {
296 "\"" -> do_bal(rest2, depth, True, False, acc2)
297 "{" -> do_bal(rest2, depth + 1, False, False, acc2)
298 "}" -> {
299 let d2 = depth - 1
300 case d2 == 0 {
301 True -> Ok(acc2)
302 False -> do_bal(rest2, d2, False, False, acc2)
303 }
304 }
305 _ -> do_bal(rest2, depth, False, False, acc2)
306 }
307 }
308 }
309 }
310}
311
312fn tidish(s: String) -> String {
313 case string.starts_with(s, "_") {
314 True -> string.drop_start(s, 1)
315 False -> s
316 }
317}