+366
-27
api/Cargo.lock
+366
-27
api/Cargo.lock
···
3
3
version = 4
4
4
5
5
[[package]]
6
+
name = "Inflector"
7
+
version = "0.11.4"
8
+
source = "registry+https://github.com/rust-lang/crates.io-index"
9
+
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
10
+
dependencies = [
11
+
"lazy_static",
12
+
"regex",
13
+
]
14
+
15
+
[[package]]
6
16
name = "addr2line"
7
17
version = "0.25.1"
8
18
source = "registry+https://github.com/rust-lang/crates.io-index"
···
60
70
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
61
71
62
72
[[package]]
73
+
name = "ascii_utils"
74
+
version = "0.9.3"
75
+
source = "registry+https://github.com/rust-lang/crates.io-index"
76
+
checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a"
77
+
78
+
[[package]]
79
+
name = "async-graphql"
80
+
version = "7.0.17"
81
+
source = "registry+https://github.com/rust-lang/crates.io-index"
82
+
checksum = "036618f842229ba0b89652ffe425f96c7c16a49f7e3cb23b56fca7f61fd74980"
83
+
dependencies = [
84
+
"async-graphql-derive",
85
+
"async-graphql-parser",
86
+
"async-graphql-value",
87
+
"async-stream",
88
+
"async-trait",
89
+
"base64 0.22.1",
90
+
"bytes",
91
+
"fast_chemail",
92
+
"fnv",
93
+
"futures-channel",
94
+
"futures-timer",
95
+
"futures-util",
96
+
"handlebars",
97
+
"http",
98
+
"indexmap",
99
+
"lru",
100
+
"mime",
101
+
"multer",
102
+
"num-traits",
103
+
"pin-project-lite",
104
+
"regex",
105
+
"serde",
106
+
"serde_json",
107
+
"serde_urlencoded",
108
+
"static_assertions_next",
109
+
"tempfile",
110
+
"thiserror 1.0.69",
111
+
]
112
+
113
+
[[package]]
114
+
name = "async-graphql-axum"
115
+
version = "7.0.17"
116
+
source = "registry+https://github.com/rust-lang/crates.io-index"
117
+
checksum = "8725874ecfbf399e071150b8619c4071d7b2b7a2f117e173dddef53c6bdb6bb1"
118
+
dependencies = [
119
+
"async-graphql",
120
+
"axum",
121
+
"bytes",
122
+
"futures-util",
123
+
"serde_json",
124
+
"tokio",
125
+
"tokio-stream",
126
+
"tokio-util",
127
+
"tower-service",
128
+
]
129
+
130
+
[[package]]
131
+
name = "async-graphql-derive"
132
+
version = "7.0.17"
133
+
source = "registry+https://github.com/rust-lang/crates.io-index"
134
+
checksum = "fd45deb3dbe5da5cdb8d6a670a7736d735ba65b455328440f236dfb113727a3d"
135
+
dependencies = [
136
+
"Inflector",
137
+
"async-graphql-parser",
138
+
"darling",
139
+
"proc-macro-crate",
140
+
"proc-macro2",
141
+
"quote",
142
+
"strum",
143
+
"syn 2.0.106",
144
+
"thiserror 1.0.69",
145
+
]
146
+
147
+
[[package]]
148
+
name = "async-graphql-parser"
149
+
version = "7.0.17"
150
+
source = "registry+https://github.com/rust-lang/crates.io-index"
151
+
checksum = "60b7607e59424a35dadbc085b0d513aa54ec28160ee640cf79ec3b634eba66d3"
152
+
dependencies = [
153
+
"async-graphql-value",
154
+
"pest",
155
+
"serde",
156
+
"serde_json",
157
+
]
158
+
159
+
[[package]]
160
+
name = "async-graphql-value"
161
+
version = "7.0.17"
162
+
source = "registry+https://github.com/rust-lang/crates.io-index"
163
+
checksum = "34ecdaff7c9cffa3614a9f9999bf9ee4c3078fe3ce4d6a6e161736b56febf2de"
164
+
dependencies = [
165
+
"bytes",
166
+
"indexmap",
167
+
"serde",
168
+
"serde_json",
169
+
]
170
+
171
+
[[package]]
172
+
name = "async-stream"
173
+
version = "0.3.6"
174
+
source = "registry+https://github.com/rust-lang/crates.io-index"
175
+
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
176
+
dependencies = [
177
+
"async-stream-impl",
178
+
"futures-core",
179
+
"pin-project-lite",
180
+
]
181
+
182
+
[[package]]
183
+
name = "async-stream-impl"
184
+
version = "0.3.6"
185
+
source = "registry+https://github.com/rust-lang/crates.io-index"
186
+
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
187
+
dependencies = [
188
+
"proc-macro2",
189
+
"quote",
190
+
"syn 2.0.106",
191
+
]
192
+
193
+
[[package]]
63
194
name = "async-trait"
64
195
version = "0.1.89"
65
196
source = "registry+https://github.com/rust-lang/crates.io-index"
···
213
344
214
345
[[package]]
215
346
name = "axum"
216
-
version = "0.7.9"
347
+
version = "0.8.6"
217
348
source = "registry+https://github.com/rust-lang/crates.io-index"
218
-
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
349
+
checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871"
219
350
dependencies = [
220
-
"async-trait",
221
351
"axum-core",
222
352
"axum-macros",
223
353
"base64 0.22.1",
224
354
"bytes",
355
+
"form_urlencoded",
225
356
"futures-util",
226
357
"http",
227
358
"http-body",
···
234
365
"mime",
235
366
"percent-encoding",
236
367
"pin-project-lite",
237
-
"rustversion",
238
-
"serde",
368
+
"serde_core",
239
369
"serde_json",
240
370
"serde_path_to_error",
241
371
"serde_urlencoded",
···
251
381
252
382
[[package]]
253
383
name = "axum-core"
254
-
version = "0.4.5"
384
+
version = "0.5.5"
255
385
source = "registry+https://github.com/rust-lang/crates.io-index"
256
-
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
386
+
checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22"
257
387
dependencies = [
258
-
"async-trait",
259
388
"bytes",
260
-
"futures-util",
389
+
"futures-core",
261
390
"http",
262
391
"http-body",
263
392
"http-body-util",
264
393
"mime",
265
394
"pin-project-lite",
266
-
"rustversion",
267
395
"sync_wrapper",
268
396
"tower-layer",
269
397
"tower-service",
···
272
400
273
401
[[package]]
274
402
name = "axum-extra"
275
-
version = "0.9.6"
403
+
version = "0.10.3"
276
404
source = "registry+https://github.com/rust-lang/crates.io-index"
277
-
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
405
+
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
278
406
dependencies = [
279
407
"axum",
280
408
"axum-core",
281
409
"bytes",
282
-
"fastrand",
410
+
"form_urlencoded",
283
411
"futures-util",
284
412
"http",
285
413
"http-body",
286
414
"http-body-util",
287
415
"mime",
288
-
"multer",
289
416
"pin-project-lite",
290
-
"serde",
417
+
"rustversion",
418
+
"serde_core",
291
419
"serde_html_form",
292
-
"tower",
420
+
"serde_path_to_error",
293
421
"tower-layer",
294
422
"tower-service",
423
+
"tracing",
295
424
]
296
425
297
426
[[package]]
298
427
name = "axum-macros"
299
-
version = "0.4.2"
428
+
version = "0.5.0"
300
429
source = "registry+https://github.com/rust-lang/crates.io-index"
301
-
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
430
+
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
302
431
dependencies = [
303
432
"proc-macro2",
304
433
"quote",
···
394
523
version = "1.10.1"
395
524
source = "registry+https://github.com/rust-lang/crates.io-index"
396
525
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
526
+
dependencies = [
527
+
"serde",
528
+
]
397
529
398
530
[[package]]
399
531
name = "cbor4ii"
···
606
738
]
607
739
608
740
[[package]]
741
+
name = "darling"
742
+
version = "0.20.11"
743
+
source = "registry+https://github.com/rust-lang/crates.io-index"
744
+
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
745
+
dependencies = [
746
+
"darling_core",
747
+
"darling_macro",
748
+
]
749
+
750
+
[[package]]
751
+
name = "darling_core"
752
+
version = "0.20.11"
753
+
source = "registry+https://github.com/rust-lang/crates.io-index"
754
+
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
755
+
dependencies = [
756
+
"fnv",
757
+
"ident_case",
758
+
"proc-macro2",
759
+
"quote",
760
+
"strsim",
761
+
"syn 2.0.106",
762
+
]
763
+
764
+
[[package]]
765
+
name = "darling_macro"
766
+
version = "0.20.11"
767
+
source = "registry+https://github.com/rust-lang/crates.io-index"
768
+
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
769
+
dependencies = [
770
+
"darling_core",
771
+
"quote",
772
+
"syn 2.0.106",
773
+
]
774
+
775
+
[[package]]
609
776
name = "data-encoding"
610
777
version = "2.9.0"
611
778
source = "registry+https://github.com/rust-lang/crates.io-index"
···
779
946
]
780
947
781
948
[[package]]
949
+
name = "fast_chemail"
950
+
version = "0.9.6"
951
+
source = "registry+https://github.com/rust-lang/crates.io-index"
952
+
checksum = "495a39d30d624c2caabe6312bfead73e7717692b44e0b32df168c275a2e8e9e4"
953
+
dependencies = [
954
+
"ascii_utils",
955
+
]
956
+
957
+
[[package]]
782
958
name = "fastrand"
783
959
version = "2.3.0"
784
960
source = "registry+https://github.com/rust-lang/crates.io-index"
···
930
1106
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
931
1107
932
1108
[[package]]
1109
+
name = "futures-timer"
1110
+
version = "3.0.3"
1111
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1112
+
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
1113
+
1114
+
[[package]]
933
1115
name = "futures-util"
934
1116
version = "0.3.31"
935
1117
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1022
1204
]
1023
1205
1024
1206
[[package]]
1207
+
name = "handlebars"
1208
+
version = "5.1.2"
1209
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1210
+
checksum = "d08485b96a0e6393e9e4d1b8d48cf74ad6c063cd905eb33f42c1ce3f0377539b"
1211
+
dependencies = [
1212
+
"log",
1213
+
"pest",
1214
+
"pest_derive",
1215
+
"serde",
1216
+
"serde_json",
1217
+
"thiserror 1.0.69",
1218
+
]
1219
+
1220
+
[[package]]
1025
1221
name = "hashbrown"
1026
1222
version = "0.15.5"
1027
1223
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1371
1567
]
1372
1568
1373
1569
[[package]]
1570
+
name = "ident_case"
1571
+
version = "1.0.1"
1572
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1573
+
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1574
+
1575
+
[[package]]
1374
1576
name = "idna"
1375
1577
version = "1.1.0"
1376
1578
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1399
1601
dependencies = [
1400
1602
"equivalent",
1401
1603
"hashbrown 0.16.0",
1604
+
"serde",
1605
+
"serde_core",
1402
1606
]
1403
1607
1404
1608
[[package]]
···
1587
1791
1588
1792
[[package]]
1589
1793
name = "matchit"
1590
-
version = "0.7.3"
1794
+
version = "0.8.4"
1591
1795
source = "registry+https://github.com/rust-lang/crates.io-index"
1592
-
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
1796
+
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
1593
1797
1594
1798
[[package]]
1595
1799
name = "md-5"
···
1917
2121
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1918
2122
1919
2123
[[package]]
2124
+
name = "pest"
2125
+
version = "2.8.2"
2126
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2127
+
checksum = "21e0a3a33733faeaf8651dfee72dd0f388f0c8e5ad496a3478fa5a922f49cfa8"
2128
+
dependencies = [
2129
+
"memchr",
2130
+
"thiserror 2.0.16",
2131
+
"ucd-trie",
2132
+
]
2133
+
2134
+
[[package]]
2135
+
name = "pest_derive"
2136
+
version = "2.8.2"
2137
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2138
+
checksum = "bc58706f770acb1dbd0973e6530a3cff4746fb721207feb3a8a6064cd0b6c663"
2139
+
dependencies = [
2140
+
"pest",
2141
+
"pest_generator",
2142
+
]
2143
+
2144
+
[[package]]
2145
+
name = "pest_generator"
2146
+
version = "2.8.2"
2147
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2148
+
checksum = "6d4f36811dfe07f7b8573462465d5cb8965fffc2e71ae377a33aecf14c2c9a2f"
2149
+
dependencies = [
2150
+
"pest",
2151
+
"pest_meta",
2152
+
"proc-macro2",
2153
+
"quote",
2154
+
"syn 2.0.106",
2155
+
]
2156
+
2157
+
[[package]]
2158
+
name = "pest_meta"
2159
+
version = "2.8.2"
2160
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2161
+
checksum = "42919b05089acbd0a5dcd5405fb304d17d1053847b81163d09c4ad18ce8e8420"
2162
+
dependencies = [
2163
+
"pest",
2164
+
"sha2",
2165
+
]
2166
+
2167
+
[[package]]
1920
2168
name = "pin-project-lite"
1921
2169
version = "0.2.16"
1922
2170
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1990
2238
]
1991
2239
1992
2240
[[package]]
2241
+
name = "proc-macro-crate"
2242
+
version = "3.4.0"
2243
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2244
+
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
2245
+
dependencies = [
2246
+
"toml_edit",
2247
+
]
2248
+
2249
+
[[package]]
1993
2250
name = "proc-macro2"
1994
2251
version = "1.0.101"
1995
2252
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2670
2927
version = "0.1.0"
2671
2928
dependencies = [
2672
2929
"anyhow",
2930
+
"async-graphql",
2931
+
"async-graphql-axum",
2673
2932
"async-trait",
2674
2933
"atproto-client",
2675
2934
"atproto-identity",
···
2681
2940
"chrono",
2682
2941
"dotenvy",
2683
2942
"futures-util",
2943
+
"lazy_static",
2684
2944
"redis",
2685
2945
"regex",
2686
2946
"reqwest",
···
2997
3257
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
2998
3258
2999
3259
[[package]]
3260
+
name = "static_assertions_next"
3261
+
version = "1.1.2"
3262
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3263
+
checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766"
3264
+
3265
+
[[package]]
3000
3266
name = "stringprep"
3001
3267
version = "0.1.5"
3002
3268
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3008
3274
]
3009
3275
3010
3276
[[package]]
3277
+
name = "strsim"
3278
+
version = "0.11.1"
3279
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3280
+
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
3281
+
3282
+
[[package]]
3283
+
name = "strum"
3284
+
version = "0.26.3"
3285
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3286
+
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
3287
+
dependencies = [
3288
+
"strum_macros",
3289
+
]
3290
+
3291
+
[[package]]
3292
+
name = "strum_macros"
3293
+
version = "0.26.4"
3294
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3295
+
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
3296
+
dependencies = [
3297
+
"heck",
3298
+
"proc-macro2",
3299
+
"quote",
3300
+
"rustversion",
3301
+
"syn 2.0.106",
3302
+
]
3303
+
3304
+
[[package]]
3011
3305
name = "subtle"
3012
3306
version = "2.6.1"
3013
3307
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3233
3527
3234
3528
[[package]]
3235
3529
name = "tokio-tungstenite"
3236
-
version = "0.24.0"
3530
+
version = "0.28.0"
3237
3531
source = "registry+https://github.com/rust-lang/crates.io-index"
3238
-
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
3532
+
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
3239
3533
dependencies = [
3240
3534
"futures-util",
3241
3535
"log",
···
3251
3545
dependencies = [
3252
3546
"bytes",
3253
3547
"futures-core",
3548
+
"futures-io",
3254
3549
"futures-sink",
3255
3550
"pin-project-lite",
3256
3551
"tokio",
···
3279
3574
]
3280
3575
3281
3576
[[package]]
3577
+
name = "toml_datetime"
3578
+
version = "0.7.2"
3579
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3580
+
checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1"
3581
+
dependencies = [
3582
+
"serde_core",
3583
+
]
3584
+
3585
+
[[package]]
3586
+
name = "toml_edit"
3587
+
version = "0.23.6"
3588
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3589
+
checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b"
3590
+
dependencies = [
3591
+
"indexmap",
3592
+
"toml_datetime",
3593
+
"toml_parser",
3594
+
"winnow",
3595
+
]
3596
+
3597
+
[[package]]
3598
+
name = "toml_parser"
3599
+
version = "1.0.3"
3600
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3601
+
checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627"
3602
+
dependencies = [
3603
+
"winnow",
3604
+
]
3605
+
3606
+
[[package]]
3282
3607
name = "tower"
3283
3608
version = "0.5.2"
3284
3609
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3395
3720
3396
3721
[[package]]
3397
3722
name = "tungstenite"
3398
-
version = "0.24.0"
3723
+
version = "0.28.0"
3399
3724
source = "registry+https://github.com/rust-lang/crates.io-index"
3400
-
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
3725
+
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
3401
3726
dependencies = [
3402
-
"byteorder",
3403
3727
"bytes",
3404
3728
"data-encoding",
3405
3729
"http",
3406
3730
"httparse",
3407
3731
"log",
3408
-
"rand 0.8.5",
3732
+
"rand 0.9.2",
3409
3733
"sha1",
3410
-
"thiserror 1.0.69",
3734
+
"thiserror 2.0.16",
3411
3735
"utf-8",
3412
3736
]
3413
3737
···
3416
3740
version = "1.18.0"
3417
3741
source = "registry+https://github.com/rust-lang/crates.io-index"
3418
3742
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
3743
+
3744
+
[[package]]
3745
+
name = "ucd-trie"
3746
+
version = "0.1.7"
3747
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3748
+
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
3419
3749
3420
3750
[[package]]
3421
3751
name = "ulid"
···
4040
4370
version = "0.53.0"
4041
4371
source = "registry+https://github.com/rust-lang/crates.io-index"
4042
4372
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
4373
+
4374
+
[[package]]
4375
+
name = "winnow"
4376
+
version = "0.7.13"
4377
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4378
+
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
4379
+
dependencies = [
4380
+
"memchr",
4381
+
]
4043
4382
4044
4383
[[package]]
4045
4384
name = "winreg"
+7
-2
api/Cargo.toml
+7
-2
api/Cargo.toml
···
19
19
20
20
# HTTP client and server
21
21
reqwest = { version = "0.12", features = ["json", "stream"] }
22
-
axum = { version = "0.7", features = ["ws", "macros"] }
23
-
axum-extra = { version = "0.9", features = ["form"] }
22
+
axum = { version = "0.8", features = ["ws", "macros"] }
23
+
axum-extra = { version = "0.10", features = ["form"] }
24
24
tower = "0.5"
25
25
tower-http = { version = "0.6", features = ["cors", "trace"] }
26
26
···
65
65
66
66
# Redis for caching
67
67
redis = { version = "0.32", features = ["tokio-comp", "connection-manager"] }
68
+
69
+
# GraphQL server
70
+
async-graphql = { version = "7.0", features = ["dynamic-schema", "dataloader"] }
71
+
async-graphql-axum = "7.0"
72
+
lazy_static = "1.5"
+35
api/src/database/actors.rs
+35
api/src/database/actors.rs
···
251
251
.await?;
252
252
Ok(result.rows_affected())
253
253
}
254
+
255
+
/// Resolves actor handles to DIDs for a specific slice.
256
+
///
257
+
/// # Arguments
258
+
/// * `handles` - List of handles to resolve
259
+
/// * `slice_uri` - AT-URI of the slice
260
+
///
261
+
/// # Returns
262
+
/// Vec of DIDs corresponding to the handles
263
+
pub async fn resolve_handles_to_dids(
264
+
&self,
265
+
handles: &[String],
266
+
slice_uri: &str,
267
+
) -> Result<Vec<String>, DatabaseError> {
268
+
if handles.is_empty() {
269
+
return Ok(Vec::new());
270
+
}
271
+
272
+
let placeholders: Vec<String> = (1..=handles.len())
273
+
.map(|i| format!("${}", i))
274
+
.collect();
275
+
let query_sql = format!(
276
+
"SELECT DISTINCT did FROM actor WHERE handle = ANY(ARRAY[{}]) AND slice_uri = ${}",
277
+
placeholders.join(", "),
278
+
handles.len() + 1
279
+
);
280
+
281
+
let mut query = sqlx::query_scalar::<_, String>(&query_sql);
282
+
for handle in handles {
283
+
query = query.bind(handle);
284
+
}
285
+
query = query.bind(slice_uri);
286
+
287
+
Ok(query.fetch_all(&self.pool).await?)
288
+
}
254
289
}
255
290
256
291
/// Builds WHERE conditions specifically for actor queries.
+27
api/src/graphql/dataloaders.rs
+27
api/src/graphql/dataloaders.rs
···
1
+
//! DataLoader utilities for extracting references from records
2
+
3
+
use serde_json::Value;
4
+
5
+
/// Extract URI from a strongRef value
6
+
/// strongRef format: { "$type": "com.atproto.repo.strongRef", "uri": "at://...", "cid": "..." }
7
+
pub fn extract_uri_from_strong_ref(value: &Value) -> Option<String> {
8
+
if let Some(obj) = value.as_object() {
9
+
// Check if this is a strongRef
10
+
if let Some(type_val) = obj.get("$type") {
11
+
if type_val.as_str() == Some("com.atproto.repo.strongRef") {
12
+
return obj.get("uri").and_then(|u| u.as_str()).map(|s| s.to_string());
13
+
}
14
+
}
15
+
16
+
// Also support direct uri field (some lexicons might use this)
17
+
if let Some(uri) = obj.get("uri") {
18
+
if let Some(uri_str) = uri.as_str() {
19
+
if uri_str.starts_with("at://") {
20
+
return Some(uri_str.to_string());
21
+
}
22
+
}
23
+
}
24
+
}
25
+
26
+
None
27
+
}
+147
api/src/graphql/handler.rs
+147
api/src/graphql/handler.rs
···
1
+
//! GraphQL HTTP handler for Axum
2
+
3
+
use async_graphql::dynamic::Schema;
4
+
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
5
+
use axum::{
6
+
extract::{Query, State},
7
+
http::{HeaderMap, StatusCode},
8
+
response::Html,
9
+
};
10
+
use serde::Deserialize;
11
+
use std::sync::Arc;
12
+
use tokio::sync::RwLock;
13
+
14
+
use crate::errors::AppError;
15
+
use crate::AppState;
16
+
17
+
/// Global schema cache (one schema per slice)
18
+
/// This prevents rebuilding the schema on every request
19
+
type SchemaCache = Arc<RwLock<std::collections::HashMap<String, Schema>>>;
20
+
21
+
lazy_static::lazy_static! {
22
+
static ref SCHEMA_CACHE: SchemaCache = Arc::new(RwLock::new(std::collections::HashMap::new()));
23
+
}
24
+
25
+
#[derive(Deserialize, Default)]
26
+
pub struct GraphQLParams {
27
+
pub slice: Option<String>,
28
+
}
29
+
30
+
/// GraphQL query handler
31
+
/// Accepts slice URI from either query parameter (?slice=...) or HTTP header (X-Slice-Uri)
32
+
pub async fn graphql_handler(
33
+
State(state): State<AppState>,
34
+
Query(params): Query<GraphQLParams>,
35
+
headers: HeaderMap,
36
+
req: GraphQLRequest,
37
+
) -> Result<GraphQLResponse, (StatusCode, String)> {
38
+
// Get slice URI from query param or header
39
+
let slice_uri = params
40
+
.slice
41
+
.or_else(|| {
42
+
headers
43
+
.get("x-slice-uri")
44
+
.and_then(|h| h.to_str().ok())
45
+
.map(|s| s.to_string())
46
+
})
47
+
.ok_or_else(|| {
48
+
(
49
+
StatusCode::BAD_REQUEST,
50
+
"Missing slice parameter. Provide either ?slice=... query parameter or X-Slice-Uri header".to_string(),
51
+
)
52
+
})?;
53
+
54
+
let schema = match get_or_build_schema(&state, &slice_uri).await {
55
+
Ok(s) => s,
56
+
Err(e) => {
57
+
tracing::error!("Failed to get GraphQL schema: {:?}", e);
58
+
return Ok(async_graphql::Response::from_errors(vec![async_graphql::ServerError::new(
59
+
format!("Schema error: {:?}", e),
60
+
None,
61
+
)])
62
+
.into());
63
+
}
64
+
};
65
+
66
+
Ok(schema.execute(req.into_inner()).await.into())
67
+
}
68
+
69
+
/// GraphQL Playground UI handler
70
+
/// Configures the playground with the slice URI in headers
71
+
pub async fn graphql_playground(
72
+
Query(params): Query<GraphQLParams>,
73
+
) -> Result<Html<String>, (StatusCode, String)> {
74
+
let slice_uri = params.slice.ok_or_else(|| {
75
+
(
76
+
StatusCode::BAD_REQUEST,
77
+
"Missing slice parameter. Provide ?slice=... query parameter".to_string(),
78
+
)
79
+
})?;
80
+
81
+
// Create playground with pre-configured headers
82
+
let playground_html = format!(
83
+
r#"<!DOCTYPE html>
84
+
<html>
85
+
<head>
86
+
<meta charset=utf-8 />
87
+
<meta name="viewport" content="user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, minimal-ui">
88
+
<title>Slices GraphQL Playground</title>
89
+
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/graphql-playground-react/build/static/css/index.css" />
90
+
<link rel="shortcut icon" href="//cdn.jsdelivr.net/npm/graphql-playground-react/build/favicon.png" />
91
+
<script src="//cdn.jsdelivr.net/npm/graphql-playground-react/build/static/js/middleware.js"></script>
92
+
</head>
93
+
<body>
94
+
<div id="root"></div>
95
+
<script>
96
+
window.addEventListener('load', function (event) {{
97
+
GraphQLPlayground.init(document.getElementById('root'), {{
98
+
endpoint: '/graphql',
99
+
settings: {{
100
+
'request.credentials': 'omit',
101
+
}},
102
+
tabs: [{{
103
+
endpoint: '/graphql',
104
+
headers: {{
105
+
'X-Slice-Uri': '{}'
106
+
}}
107
+
}}]
108
+
}})
109
+
}})
110
+
</script>
111
+
</body>
112
+
</html>"#,
113
+
slice_uri.replace("'", "\\'").replace("\"", "\\\"")
114
+
);
115
+
116
+
Ok(Html(playground_html))
117
+
}
118
+
119
+
/// Gets schema from cache or builds it if not cached
120
+
async fn get_or_build_schema(
121
+
state: &AppState,
122
+
slice_uri: &str,
123
+
) -> Result<Schema, AppError> {
124
+
// Check cache first
125
+
{
126
+
let cache = SCHEMA_CACHE.read().await;
127
+
if let Some(schema) = cache.get(slice_uri) {
128
+
return Ok(schema.clone());
129
+
}
130
+
}
131
+
132
+
// Build schema
133
+
let schema = crate::graphql::build_graphql_schema(
134
+
state.database.clone(),
135
+
slice_uri.to_string(),
136
+
)
137
+
.await
138
+
.map_err(|e| AppError::Internal(format!("Failed to build GraphQL schema: {}", e)))?;
139
+
140
+
// Cache it
141
+
{
142
+
let mut cache = SCHEMA_CACHE.write().await;
143
+
cache.insert(slice_uri.to_string(), schema.clone());
144
+
}
145
+
146
+
Ok(schema)
147
+
}
+12
api/src/graphql/mod.rs
+12
api/src/graphql/mod.rs
···
1
+
//! GraphQL endpoint implementation for Slices
2
+
//!
3
+
//! This module provides a GraphQL interface to query slice records with support
4
+
//! for joining linked records through AT Protocol strongRef references.
5
+
6
+
mod schema_builder;
7
+
mod dataloaders;
8
+
mod types;
9
+
pub mod handler;
10
+
11
+
pub use schema_builder::build_graphql_schema;
12
+
pub use handler::{graphql_handler, graphql_playground};
+1431
api/src/graphql/schema_builder.rs
+1431
api/src/graphql/schema_builder.rs
···
1
+
//! Dynamic GraphQL schema builder from AT Protocol lexicons
2
+
//!
3
+
//! This module generates GraphQL schemas at runtime based on lexicon definitions
4
+
//! stored in the database, enabling flexible querying of slice records.
5
+
6
+
use async_graphql::dynamic::{Field, FieldFuture, FieldValue, Object, Schema, Scalar, TypeRef, InputObject, InputValue, Enum, EnumItem};
7
+
use async_graphql::{Error, Value as GraphQLValue};
8
+
use base64::engine::general_purpose;
9
+
use base64::Engine;
10
+
use serde_json;
11
+
use std::collections::HashMap;
12
+
use std::sync::Arc;
13
+
use tokio::sync::Mutex;
14
+
15
+
use crate::database::Database;
16
+
use crate::graphql::types::{extract_collection_fields, extract_record_key, GraphQLField, GraphQLType};
17
+
18
+
/// Metadata about a collection for cross-referencing
19
+
#[derive(Clone)]
20
+
struct CollectionMeta {
21
+
nsid: String,
22
+
key_type: String, // "tid", "literal:self", or "any"
23
+
type_name: String, // GraphQL type name for this collection
24
+
}
25
+
26
+
/// Builds a dynamic GraphQL schema from lexicons for a given slice
27
+
pub async fn build_graphql_schema(
28
+
database: Database,
29
+
slice_uri: String,
30
+
) -> Result<Schema, String> {
31
+
// Fetch all lexicons for this slice
32
+
let lexicons = database
33
+
.get_lexicons_by_slice(&slice_uri)
34
+
.await
35
+
.map_err(|e| format!("Failed to load lexicons: {}", e))?;
36
+
37
+
// Build Query root type and collect all object types
38
+
let mut query = Object::new("Query");
39
+
let mut objects_to_register = Vec::new();
40
+
41
+
// First pass: collect metadata about all collections for cross-referencing
42
+
let mut all_collections: Vec<CollectionMeta> = Vec::new();
43
+
for lexicon in &lexicons {
44
+
let nsid = lexicon
45
+
.get("id")
46
+
.and_then(|n| n.as_str())
47
+
.ok_or_else(|| "Lexicon missing id".to_string())?;
48
+
49
+
let defs = lexicon
50
+
.get("defs")
51
+
.ok_or_else(|| format!("Lexicon {} missing defs", nsid))?;
52
+
53
+
let fields = extract_collection_fields(defs);
54
+
if !fields.is_empty() {
55
+
if let Some(key_type) = extract_record_key(defs) {
56
+
all_collections.push(CollectionMeta {
57
+
nsid: nsid.to_string(),
58
+
key_type,
59
+
type_name: nsid_to_type_name(nsid),
60
+
});
61
+
}
62
+
}
63
+
}
64
+
65
+
// Second pass: create types and queries
66
+
for lexicon in &lexicons {
67
+
// get_lexicons_by_slice returns {lexicon: 1, id: "nsid", defs: {...}}
68
+
let nsid = lexicon
69
+
.get("id")
70
+
.and_then(|n| n.as_str())
71
+
.ok_or_else(|| "Lexicon missing id".to_string())?;
72
+
73
+
let defs = lexicon
74
+
.get("defs")
75
+
.ok_or_else(|| format!("Lexicon {} missing defs", nsid))?
76
+
.clone();
77
+
78
+
// Extract fields from lexicon
79
+
let fields = extract_collection_fields(&defs);
80
+
81
+
if !fields.is_empty() {
82
+
// Create a GraphQL type for this collection
83
+
let type_name = nsid_to_type_name(nsid);
84
+
let record_type = create_record_type(&type_name, &fields, database.clone(), slice_uri.clone(), &all_collections);
85
+
86
+
// Create edge and connection types for this collection (Relay standard)
87
+
let edge_type = create_edge_type(&type_name);
88
+
let connection_type = create_connection_type(&type_name);
89
+
90
+
// Collect the types to register with schema later
91
+
objects_to_register.push(record_type);
92
+
objects_to_register.push(edge_type);
93
+
objects_to_register.push(connection_type);
94
+
95
+
// Add query field for this collection
96
+
let collection_query_name = nsid_to_query_name(nsid);
97
+
let db_clone = database.clone();
98
+
let slice_clone = slice_uri.clone();
99
+
let nsid_clone = nsid.to_string();
100
+
101
+
let connection_type_name = format!("{}Connection", &type_name);
102
+
query = query.field(
103
+
Field::new(
104
+
&collection_query_name,
105
+
TypeRef::named_nn(&connection_type_name),
106
+
move |ctx| {
107
+
let db = db_clone.clone();
108
+
let slice = slice_clone.clone();
109
+
let collection = nsid_clone.clone();
110
+
111
+
FieldFuture::new(async move {
112
+
// Get Relay-standard pagination arguments
113
+
let first: i32 = match ctx.args.get("first") {
114
+
Some(val) => val.i64().unwrap_or(50) as i32,
115
+
None => 50,
116
+
};
117
+
118
+
let after: Option<&str> = match ctx.args.get("after") {
119
+
Some(val) => val.string().ok(),
120
+
None => None,
121
+
};
122
+
123
+
// Parse sortBy argument
124
+
let sort_by: Option<Vec<crate::models::SortField>> = match ctx.args.get("sortBy") {
125
+
Some(val) => {
126
+
if let Ok(list) = val.list() {
127
+
let mut sort_fields = Vec::new();
128
+
for item in list.iter() {
129
+
if let Ok(obj) = item.object() {
130
+
let field = obj.get("field")
131
+
.and_then(|v| v.string().ok())
132
+
.unwrap_or("indexedAt")
133
+
.to_string();
134
+
let direction = obj.get("direction")
135
+
.and_then(|v| v.string().ok())
136
+
.unwrap_or("desc")
137
+
.to_string();
138
+
sort_fields.push(crate::models::SortField { field, direction });
139
+
}
140
+
}
141
+
Some(sort_fields)
142
+
} else {
143
+
None
144
+
}
145
+
},
146
+
None => None,
147
+
};
148
+
149
+
// Build where clause for this collection
150
+
let mut where_clause = crate::models::WhereClause {
151
+
conditions: HashMap::new(),
152
+
or_conditions: None,
153
+
};
154
+
155
+
// Always filter by collection
156
+
where_clause.conditions.insert(
157
+
"collection".to_string(),
158
+
crate::models::WhereCondition {
159
+
eq: Some(serde_json::Value::String(collection.clone())),
160
+
in_values: None,
161
+
contains: None,
162
+
},
163
+
);
164
+
165
+
// Parse where argument if provided
166
+
if let Some(where_val) = ctx.args.get("where") {
167
+
// Try to parse as JSON object
168
+
if let Ok(where_obj) = where_val.object() {
169
+
for (field_name, condition_val) in where_obj.iter() {
170
+
if let Ok(condition_obj) = condition_val.object() {
171
+
let mut where_condition = crate::models::WhereCondition {
172
+
eq: None,
173
+
in_values: None,
174
+
contains: None,
175
+
};
176
+
177
+
// Parse eq condition
178
+
if let Some(eq_val) = condition_obj.get("eq") {
179
+
if let Ok(eq_str) = eq_val.string() {
180
+
where_condition.eq = Some(serde_json::Value::String(eq_str.to_string()));
181
+
} else if let Ok(eq_i64) = eq_val.i64() {
182
+
where_condition.eq = Some(serde_json::Value::Number(eq_i64.into()));
183
+
}
184
+
}
185
+
186
+
// Parse in condition
187
+
if let Some(in_val) = condition_obj.get("in") {
188
+
if let Ok(in_list) = in_val.list() {
189
+
let mut values = Vec::new();
190
+
for item in in_list.iter() {
191
+
if let Ok(s) = item.string() {
192
+
values.push(serde_json::Value::String(s.to_string()));
193
+
} else if let Ok(i) = item.i64() {
194
+
values.push(serde_json::Value::Number(i.into()));
195
+
}
196
+
}
197
+
where_condition.in_values = Some(values);
198
+
}
199
+
}
200
+
201
+
// Parse contains condition
202
+
if let Some(contains_val) = condition_obj.get("contains") {
203
+
if let Ok(contains_str) = contains_val.string() {
204
+
where_condition.contains = Some(contains_str.to_string());
205
+
}
206
+
}
207
+
208
+
where_clause.conditions.insert(field_name.to_string(), where_condition);
209
+
}
210
+
}
211
+
}
212
+
}
213
+
214
+
// Resolve actorHandle to did if present
215
+
if let Some(actor_handle_condition) = where_clause.conditions.remove("actorHandle") {
216
+
// Collect handles to resolve
217
+
let mut handles = Vec::new();
218
+
if let Some(eq_value) = &actor_handle_condition.eq {
219
+
if let Some(handle_str) = eq_value.as_str() {
220
+
handles.push(handle_str.to_string());
221
+
}
222
+
}
223
+
if let Some(in_values) = &actor_handle_condition.in_values {
224
+
for value in in_values {
225
+
if let Some(handle_str) = value.as_str() {
226
+
handles.push(handle_str.to_string());
227
+
}
228
+
}
229
+
}
230
+
231
+
// Resolve handles to DIDs from actor table
232
+
if !handles.is_empty() {
233
+
match db.resolve_handles_to_dids(&handles, &slice).await {
234
+
Ok(dids) => {
235
+
if !dids.is_empty() {
236
+
// Replace actorHandle condition with did condition
237
+
let did_condition = if dids.len() == 1 {
238
+
crate::models::WhereCondition {
239
+
eq: Some(serde_json::Value::String(dids[0].clone())),
240
+
in_values: None,
241
+
contains: None,
242
+
}
243
+
} else {
244
+
crate::models::WhereCondition {
245
+
eq: None,
246
+
in_values: Some(dids.into_iter().map(|d| serde_json::Value::String(d)).collect()),
247
+
contains: None,
248
+
}
249
+
};
250
+
where_clause.conditions.insert("did".to_string(), did_condition);
251
+
}
252
+
// If no DIDs found, the query will return 0 results naturally
253
+
}
254
+
Err(_) => {
255
+
// If resolution fails, skip the condition (will return 0 results)
256
+
}
257
+
}
258
+
}
259
+
}
260
+
261
+
// Query database for records
262
+
let (records, next_cursor) = db
263
+
.get_slice_collections_records(
264
+
&slice,
265
+
Some(first),
266
+
after,
267
+
sort_by.as_ref(),
268
+
Some(&where_clause),
269
+
)
270
+
.await
271
+
.map_err(|e| {
272
+
Error::new(format!("Database query failed: {}", e))
273
+
})?;
274
+
275
+
// Query database for total count
276
+
let total_count = db
277
+
.count_slice_collections_records(&slice, Some(&where_clause))
278
+
.await
279
+
.map_err(|e| {
280
+
Error::new(format!("Count query failed: {}", e))
281
+
})? as i32;
282
+
283
+
// Convert records to RecordContainers
284
+
let record_containers: Vec<RecordContainer> = records
285
+
.into_iter()
286
+
.map(|record| {
287
+
// Convert Record to IndexedRecord
288
+
let indexed_record = crate::models::IndexedRecord {
289
+
uri: record.uri,
290
+
cid: record.cid,
291
+
did: record.did,
292
+
collection: record.collection,
293
+
value: record.json,
294
+
indexed_at: record.indexed_at.to_rfc3339(),
295
+
};
296
+
RecordContainer {
297
+
record: indexed_record,
298
+
}
299
+
})
300
+
.collect();
301
+
302
+
// Build Connection data
303
+
let connection_data = ConnectionData {
304
+
total_count,
305
+
has_next_page: next_cursor.is_some(),
306
+
end_cursor: next_cursor,
307
+
nodes: record_containers,
308
+
};
309
+
310
+
Ok(Some(FieldValue::owned_any(connection_data)))
311
+
})
312
+
},
313
+
)
314
+
.argument(async_graphql::dynamic::InputValue::new(
315
+
"first",
316
+
TypeRef::named(TypeRef::INT),
317
+
))
318
+
.argument(async_graphql::dynamic::InputValue::new(
319
+
"after",
320
+
TypeRef::named(TypeRef::STRING),
321
+
))
322
+
.argument(async_graphql::dynamic::InputValue::new(
323
+
"last",
324
+
TypeRef::named(TypeRef::INT),
325
+
))
326
+
.argument(async_graphql::dynamic::InputValue::new(
327
+
"before",
328
+
TypeRef::named(TypeRef::STRING),
329
+
))
330
+
.argument(async_graphql::dynamic::InputValue::new(
331
+
"sortBy",
332
+
TypeRef::named_list("SortField"),
333
+
))
334
+
.argument(async_graphql::dynamic::InputValue::new(
335
+
"where",
336
+
TypeRef::named("JSON"),
337
+
))
338
+
.description(format!("Query {} records", nsid)),
339
+
);
340
+
}
341
+
}
342
+
343
+
// Build Mutation type
344
+
let mutation = create_mutation_type(database.clone(), slice_uri.clone());
345
+
346
+
// Build and return the schema
347
+
let mut schema_builder = Schema::build(query.type_name(), Some(mutation.type_name()), None)
348
+
.register(query)
349
+
.register(mutation);
350
+
351
+
// Register JSON scalar type for complex fields
352
+
let json_scalar = Scalar::new("JSON");
353
+
schema_builder = schema_builder.register(json_scalar);
354
+
355
+
// Register Blob type
356
+
let blob_type = create_blob_type();
357
+
schema_builder = schema_builder.register(blob_type);
358
+
359
+
// Register SyncResult type for mutations
360
+
let sync_result_type = create_sync_result_type();
361
+
schema_builder = schema_builder.register(sync_result_type);
362
+
363
+
// Register SortDirection enum
364
+
let sort_direction_enum = create_sort_direction_enum();
365
+
schema_builder = schema_builder.register(sort_direction_enum);
366
+
367
+
// Register SortField input type
368
+
let sort_field_input = create_sort_field_input();
369
+
schema_builder = schema_builder.register(sort_field_input);
370
+
371
+
// Register condition input types for where clauses
372
+
let string_condition_input = create_string_condition_input();
373
+
schema_builder = schema_builder.register(string_condition_input);
374
+
375
+
let int_condition_input = create_int_condition_input();
376
+
schema_builder = schema_builder.register(int_condition_input);
377
+
378
+
// Register PageInfo type
379
+
let page_info_type = create_page_info_type();
380
+
schema_builder = schema_builder.register(page_info_type);
381
+
382
+
// Register all object types
383
+
for obj in objects_to_register {
384
+
schema_builder = schema_builder.register(obj);
385
+
}
386
+
387
+
schema_builder
388
+
.finish()
389
+
.map_err(|e| format!("Schema build error: {:?}", e))
390
+
}
391
+
392
+
/// Container to hold record data for resolvers
393
+
#[derive(Clone)]
394
+
struct RecordContainer {
395
+
record: crate::models::IndexedRecord,
396
+
}
397
+
398
+
/// Container to hold blob data and DID for URL generation
399
+
#[derive(Clone)]
400
+
struct BlobContainer {
401
+
blob_ref: String, // CID reference
402
+
mime_type: String, // MIME type
403
+
size: i64, // Size in bytes
404
+
did: String, // DID for CDN URL generation
405
+
}
406
+
407
+
/// Creates a GraphQL Object type for a record collection
408
+
fn create_record_type(
409
+
type_name: &str,
410
+
fields: &[GraphQLField],
411
+
database: Database,
412
+
slice_uri: String,
413
+
all_collections: &[CollectionMeta],
414
+
) -> Object {
415
+
let mut object = Object::new(type_name);
416
+
417
+
// Check which field names exist in lexicon to avoid conflicts
418
+
let lexicon_field_names: std::collections::HashSet<&str> =
419
+
fields.iter().map(|f| f.name.as_str()).collect();
420
+
421
+
// Add standard AT Protocol fields only if they don't conflict with lexicon fields
422
+
if !lexicon_field_names.contains("uri") {
423
+
object = object.field(Field::new("uri", TypeRef::named_nn(TypeRef::STRING), |ctx| {
424
+
FieldFuture::new(async move {
425
+
let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?;
426
+
Ok(Some(GraphQLValue::from(container.record.uri.clone())))
427
+
})
428
+
}));
429
+
}
430
+
431
+
if !lexicon_field_names.contains("cid") {
432
+
object = object.field(Field::new("cid", TypeRef::named_nn(TypeRef::STRING), |ctx| {
433
+
FieldFuture::new(async move {
434
+
let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?;
435
+
Ok(Some(GraphQLValue::from(container.record.cid.clone())))
436
+
})
437
+
}));
438
+
}
439
+
440
+
if !lexicon_field_names.contains("did") {
441
+
object = object.field(Field::new("did", TypeRef::named_nn(TypeRef::STRING), |ctx| {
442
+
FieldFuture::new(async move {
443
+
let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?;
444
+
Ok(Some(GraphQLValue::from(container.record.did.clone())))
445
+
})
446
+
}));
447
+
}
448
+
449
+
if !lexicon_field_names.contains("indexedAt") {
450
+
object = object.field(Field::new(
451
+
"indexedAt",
452
+
TypeRef::named_nn(TypeRef::STRING),
453
+
|ctx| {
454
+
FieldFuture::new(async move {
455
+
let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?;
456
+
Ok(Some(GraphQLValue::from(
457
+
container.record.indexed_at.clone(),
458
+
)))
459
+
})
460
+
},
461
+
));
462
+
}
463
+
464
+
// Add actor metadata field (handle from actors table)
465
+
// Always add as "actorHandle" to avoid conflicts with lexicon fields
466
+
let db_for_actor = database.clone();
467
+
let slice_for_actor = slice_uri.clone();
468
+
object = object.field(Field::new(
469
+
"actorHandle",
470
+
TypeRef::named(TypeRef::STRING),
471
+
move |ctx| {
472
+
let db = db_for_actor.clone();
473
+
let slice = slice_for_actor.clone();
474
+
FieldFuture::new(async move {
475
+
let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?;
476
+
let did = &container.record.did;
477
+
478
+
// Build where clause to find actor by DID
479
+
let mut where_clause = crate::models::WhereClause {
480
+
conditions: std::collections::HashMap::new(),
481
+
or_conditions: None,
482
+
};
483
+
where_clause.conditions.insert(
484
+
"did".to_string(),
485
+
crate::models::WhereCondition {
486
+
eq: Some(serde_json::Value::String(did.clone())),
487
+
in_values: None,
488
+
contains: None,
489
+
},
490
+
);
491
+
492
+
match db.get_slice_actors(&slice, Some(1), None, Some(&where_clause)).await {
493
+
Ok((actors, _cursor)) => {
494
+
if let Some(actor) = actors.first() {
495
+
if let Some(handle) = &actor.handle {
496
+
Ok(Some(GraphQLValue::from(handle.clone())))
497
+
} else {
498
+
Ok(None)
499
+
}
500
+
} else {
501
+
Ok(None)
502
+
}
503
+
}
504
+
Err(e) => {
505
+
tracing::debug!("Actor not found for {}: {}", did, e);
506
+
Ok(None)
507
+
}
508
+
}
509
+
})
510
+
},
511
+
));
512
+
513
+
// Add fields from lexicon
514
+
for field in fields {
515
+
let field_name = field.name.clone();
516
+
let field_name_for_field = field_name.clone(); // Need separate clone for Field::new
517
+
let field_type = field.field_type.clone();
518
+
let db_clone = database.clone();
519
+
520
+
let type_ref = graphql_type_to_typeref(&field.field_type, field.is_required);
521
+
522
+
object = object.field(Field::new(&field_name_for_field, type_ref, move |ctx| {
523
+
let field_name = field_name.clone();
524
+
let field_type = field_type.clone();
525
+
let db = db_clone.clone();
526
+
527
+
FieldFuture::new(async move {
528
+
let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?;
529
+
let value = container.record.value.get(&field_name);
530
+
531
+
if let Some(val) = value {
532
+
// Check for explicit null value
533
+
if val.is_null() {
534
+
return Ok(Some(FieldValue::NULL));
535
+
}
536
+
537
+
// Check if this is a blob field
538
+
if matches!(field_type, GraphQLType::Blob) {
539
+
// Extract blob fields from JSON object
540
+
if let Some(obj) = val.as_object() {
541
+
let blob_ref = obj
542
+
.get("ref")
543
+
.and_then(|r| r.as_object())
544
+
.and_then(|r| r.get("$link"))
545
+
.and_then(|l| l.as_str())
546
+
.unwrap_or("")
547
+
.to_string();
548
+
549
+
let mime_type = obj
550
+
.get("mimeType")
551
+
.and_then(|m| m.as_str())
552
+
.unwrap_or("image/jpeg")
553
+
.to_string();
554
+
555
+
let size = obj
556
+
.get("size")
557
+
.and_then(|s| s.as_i64())
558
+
.unwrap_or(0);
559
+
560
+
let blob_container = BlobContainer {
561
+
blob_ref,
562
+
mime_type,
563
+
size,
564
+
did: container.record.did.clone(),
565
+
};
566
+
567
+
return Ok(Some(FieldValue::owned_any(blob_container)));
568
+
}
569
+
570
+
// If not a proper blob object, return NULL
571
+
return Ok(Some(FieldValue::NULL));
572
+
}
573
+
574
+
// Check if this is a reference field that needs joining
575
+
if matches!(field_type, GraphQLType::Ref) {
576
+
// Extract URI from strongRef and fetch the linked record
577
+
if let Some(uri) =
578
+
crate::graphql::dataloaders::extract_uri_from_strong_ref(val)
579
+
{
580
+
match db.get_record(&uri).await {
581
+
Ok(Some(linked_record)) => {
582
+
// Convert the linked record to a JSON value
583
+
let record_json = serde_json::to_value(linked_record)
584
+
.map_err(|e| {
585
+
Error::new(format!("Serialization error: {}", e))
586
+
})?;
587
+
588
+
// Convert serde_json::Value to async_graphql::Value
589
+
let graphql_val = json_to_graphql_value(&record_json);
590
+
return Ok(Some(FieldValue::value(graphql_val)));
591
+
}
592
+
Ok(None) => {
593
+
return Ok(Some(FieldValue::NULL));
594
+
}
595
+
Err(e) => {
596
+
tracing::error!("Error fetching linked record: {}", e);
597
+
return Ok(Some(FieldValue::NULL));
598
+
}
599
+
}
600
+
}
601
+
}
602
+
603
+
// For non-ref fields, return the raw JSON value
604
+
let graphql_val = json_to_graphql_value(val);
605
+
Ok(Some(FieldValue::value(graphql_val)))
606
+
} else {
607
+
Ok(Some(FieldValue::NULL))
608
+
}
609
+
})
610
+
}));
611
+
}
612
+
613
+
// Add join fields for cross-referencing other collections by DID
614
+
for collection in all_collections {
615
+
let field_name = nsid_to_join_field_name(&collection.nsid);
616
+
617
+
// Skip if this would conflict with existing field
618
+
if lexicon_field_names.contains(field_name.as_str()) {
619
+
continue;
620
+
}
621
+
622
+
let collection_nsid = collection.nsid.clone();
623
+
let key_type = collection.key_type.clone();
624
+
let db_for_join = database.clone();
625
+
let slice_for_join = slice_uri.clone();
626
+
627
+
// Determine type and resolver based on key_type
628
+
match key_type.as_str() {
629
+
"literal:self" => {
630
+
// Single record per DID - return nullable object of the collection's type
631
+
object = object.field(Field::new(
632
+
&field_name,
633
+
TypeRef::named(&collection.type_name),
634
+
move |ctx| {
635
+
let db = db_for_join.clone();
636
+
let nsid = collection_nsid.clone();
637
+
FieldFuture::new(async move {
638
+
let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?;
639
+
let uri = format!("at://{}/{}/self", container.record.did, nsid);
640
+
641
+
match db.get_record(&uri).await {
642
+
Ok(Some(record)) => {
643
+
let new_container = RecordContainer {
644
+
record,
645
+
};
646
+
Ok(Some(FieldValue::owned_any(new_container)))
647
+
}
648
+
Ok(None) => Ok(None),
649
+
Err(e) => {
650
+
tracing::debug!("Record not found for {}: {}", uri, e);
651
+
Ok(None)
652
+
}
653
+
}
654
+
})
655
+
},
656
+
));
657
+
}
658
+
"tid" | "any" => {
659
+
// Multiple records per DID - return array of the collection's type
660
+
object = object.field(
661
+
Field::new(
662
+
&field_name,
663
+
TypeRef::named_nn_list_nn(&collection.type_name),
664
+
move |ctx| {
665
+
let db = db_for_join.clone();
666
+
let nsid = collection_nsid.clone();
667
+
let slice = slice_for_join.clone();
668
+
FieldFuture::new(async move {
669
+
let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?;
670
+
let did = &container.record.did;
671
+
672
+
// Get limit from argument, default to 50
673
+
let limit = ctx.args.get("limit")
674
+
.and_then(|v| v.i64().ok())
675
+
.map(|i| i as i32)
676
+
.unwrap_or(50)
677
+
.min(100); // Cap at 100 to prevent abuse
678
+
679
+
// Build where clause to find all records of this collection for this DID
680
+
let mut where_clause = crate::models::WhereClause {
681
+
conditions: HashMap::new(),
682
+
or_conditions: None,
683
+
};
684
+
where_clause.conditions.insert(
685
+
"collection".to_string(),
686
+
crate::models::WhereCondition {
687
+
eq: Some(serde_json::Value::String(nsid.clone())),
688
+
in_values: None,
689
+
contains: None,
690
+
},
691
+
);
692
+
where_clause.conditions.insert(
693
+
"did".to_string(),
694
+
crate::models::WhereCondition {
695
+
eq: Some(serde_json::Value::String(did.clone())),
696
+
in_values: None,
697
+
contains: None,
698
+
},
699
+
);
700
+
701
+
match db.get_slice_collections_records(
702
+
&slice,
703
+
Some(limit),
704
+
None, // cursor
705
+
None, // sort
706
+
Some(&where_clause),
707
+
).await {
708
+
Ok((records, _cursor)) => {
709
+
let values: Vec<FieldValue> = records
710
+
.into_iter()
711
+
.map(|record| {
712
+
// Convert Record to IndexedRecord
713
+
let indexed_record = crate::models::IndexedRecord {
714
+
uri: record.uri,
715
+
cid: record.cid,
716
+
did: record.did,
717
+
collection: record.collection,
718
+
value: record.json,
719
+
indexed_at: record.indexed_at.to_rfc3339(),
720
+
};
721
+
let container = RecordContainer {
722
+
record: indexed_record,
723
+
};
724
+
FieldValue::owned_any(container)
725
+
})
726
+
.collect();
727
+
Ok(Some(FieldValue::list(values)))
728
+
}
729
+
Err(e) => {
730
+
tracing::debug!("Error querying {}: {}", nsid, e);
731
+
Ok(Some(FieldValue::list(Vec::<FieldValue>::new())))
732
+
}
733
+
}
734
+
})
735
+
},
736
+
)
737
+
.argument(async_graphql::dynamic::InputValue::new(
738
+
"limit",
739
+
TypeRef::named(TypeRef::INT),
740
+
))
741
+
);
742
+
}
743
+
_ => {
744
+
// Unknown key type, skip
745
+
continue;
746
+
}
747
+
}
748
+
}
749
+
750
+
// Add reverse joins: for every other collection, add a field to query records by DID
751
+
// This enables bidirectional traversal (e.g., profile.plays and play.profile)
752
+
for collection in all_collections {
753
+
let reverse_field_name = format!("{}s", nsid_to_join_field_name(&collection.nsid));
754
+
let db_for_reverse = database.clone();
755
+
let slice_for_reverse = slice_uri.clone();
756
+
let collection_nsid = collection.nsid.clone();
757
+
let collection_type = collection.type_name.clone();
758
+
759
+
object = object.field(
760
+
Field::new(
761
+
&reverse_field_name,
762
+
TypeRef::named_nn_list_nn(&collection_type),
763
+
move |ctx| {
764
+
let db = db_for_reverse.clone();
765
+
let slice = slice_for_reverse.clone();
766
+
let nsid = collection_nsid.clone();
767
+
FieldFuture::new(async move {
768
+
let container = ctx.parent_value.try_downcast_ref::<RecordContainer>()?;
769
+
let did = &container.record.did;
770
+
771
+
// Get limit from argument, default to 50
772
+
let limit = ctx.args.get("limit")
773
+
.and_then(|v| v.i64().ok())
774
+
.map(|i| i as i32)
775
+
.unwrap_or(50)
776
+
.min(100); // Cap at 100 to prevent abuse
777
+
778
+
// Build where clause to find all records of this collection for this DID
779
+
let mut where_clause = crate::models::WhereClause {
780
+
conditions: HashMap::new(),
781
+
or_conditions: None,
782
+
};
783
+
where_clause.conditions.insert(
784
+
"collection".to_string(),
785
+
crate::models::WhereCondition {
786
+
eq: Some(serde_json::Value::String(nsid.clone())),
787
+
in_values: None,
788
+
contains: None,
789
+
},
790
+
);
791
+
where_clause.conditions.insert(
792
+
"did".to_string(),
793
+
crate::models::WhereCondition {
794
+
eq: Some(serde_json::Value::String(did.clone())),
795
+
in_values: None,
796
+
contains: None,
797
+
},
798
+
);
799
+
800
+
match db.get_slice_collections_records(
801
+
&slice,
802
+
Some(limit),
803
+
None, // cursor
804
+
None, // sort
805
+
Some(&where_clause),
806
+
).await {
807
+
Ok((records, _cursor)) => {
808
+
let values: Vec<FieldValue> = records
809
+
.into_iter()
810
+
.map(|record| {
811
+
// Convert Record to IndexedRecord
812
+
let indexed_record = crate::models::IndexedRecord {
813
+
uri: record.uri,
814
+
cid: record.cid,
815
+
did: record.did,
816
+
collection: record.collection,
817
+
value: record.json,
818
+
indexed_at: record.indexed_at.to_rfc3339(),
819
+
};
820
+
let container = RecordContainer {
821
+
record: indexed_record,
822
+
};
823
+
FieldValue::owned_any(container)
824
+
})
825
+
.collect();
826
+
Ok(Some(FieldValue::list(values)))
827
+
}
828
+
Err(e) => {
829
+
tracing::debug!("Error querying {}: {}", nsid, e);
830
+
Ok(Some(FieldValue::list(Vec::<FieldValue>::new())))
831
+
}
832
+
}
833
+
})
834
+
},
835
+
)
836
+
.argument(async_graphql::dynamic::InputValue::new(
837
+
"limit",
838
+
TypeRef::named(TypeRef::INT),
839
+
))
840
+
);
841
+
}
842
+
843
+
object
844
+
}
845
+
846
+
/// Convert serde_json::Value to async_graphql::Value
847
+
fn json_to_graphql_value(val: &serde_json::Value) -> GraphQLValue {
848
+
match val {
849
+
serde_json::Value::Null => GraphQLValue::Null,
850
+
serde_json::Value::Bool(b) => GraphQLValue::Boolean(*b),
851
+
serde_json::Value::Number(n) => {
852
+
if let Some(i) = n.as_i64() {
853
+
GraphQLValue::Number((i as i32).into())
854
+
} else if let Some(f) = n.as_f64() {
855
+
GraphQLValue::Number(serde_json::Number::from_f64(f).unwrap().into())
856
+
} else {
857
+
GraphQLValue::Null
858
+
}
859
+
}
860
+
serde_json::Value::String(s) => GraphQLValue::String(s.clone()),
861
+
serde_json::Value::Array(arr) => {
862
+
GraphQLValue::List(arr.iter().map(json_to_graphql_value).collect())
863
+
}
864
+
serde_json::Value::Object(obj) => {
865
+
let mut map = async_graphql::indexmap::IndexMap::new();
866
+
for (k, v) in obj {
867
+
map.insert(
868
+
async_graphql::Name::new(k.as_str()),
869
+
json_to_graphql_value(v),
870
+
);
871
+
}
872
+
GraphQLValue::Object(map)
873
+
}
874
+
}
875
+
}
876
+
877
+
/// Converts GraphQL type to TypeRef for async-graphql
878
+
fn graphql_type_to_typeref(gql_type: &GraphQLType, is_required: bool) -> TypeRef {
879
+
match gql_type {
880
+
GraphQLType::String => {
881
+
if is_required {
882
+
TypeRef::named_nn(TypeRef::STRING)
883
+
} else {
884
+
TypeRef::named(TypeRef::STRING)
885
+
}
886
+
}
887
+
GraphQLType::Int => {
888
+
if is_required {
889
+
TypeRef::named_nn(TypeRef::INT)
890
+
} else {
891
+
TypeRef::named(TypeRef::INT)
892
+
}
893
+
}
894
+
GraphQLType::Boolean => {
895
+
if is_required {
896
+
TypeRef::named_nn(TypeRef::BOOLEAN)
897
+
} else {
898
+
TypeRef::named(TypeRef::BOOLEAN)
899
+
}
900
+
}
901
+
GraphQLType::Float => {
902
+
if is_required {
903
+
TypeRef::named_nn(TypeRef::FLOAT)
904
+
} else {
905
+
TypeRef::named(TypeRef::FLOAT)
906
+
}
907
+
}
908
+
GraphQLType::Blob => {
909
+
// Blob object type with url resolver
910
+
if is_required {
911
+
TypeRef::named_nn("Blob")
912
+
} else {
913
+
TypeRef::named("Blob")
914
+
}
915
+
}
916
+
GraphQLType::Json | GraphQLType::Ref | GraphQLType::Object(_) | GraphQLType::Union => {
917
+
// JSON scalar type - linked records and complex objects return as JSON
918
+
if is_required {
919
+
TypeRef::named_nn("JSON")
920
+
} else {
921
+
TypeRef::named("JSON")
922
+
}
923
+
}
924
+
GraphQLType::Array(inner) => {
925
+
// For arrays of primitives, use typed arrays
926
+
// For arrays of complex types, use JSON scalar
927
+
match inner.as_ref() {
928
+
GraphQLType::String | GraphQLType::Int | GraphQLType::Boolean | GraphQLType::Float => {
929
+
let inner_ref = match inner.as_ref() {
930
+
GraphQLType::String => TypeRef::STRING,
931
+
GraphQLType::Int => TypeRef::INT,
932
+
GraphQLType::Boolean => TypeRef::BOOLEAN,
933
+
GraphQLType::Float => TypeRef::FLOAT,
934
+
_ => unreachable!(),
935
+
};
936
+
937
+
if is_required {
938
+
TypeRef::named_nn_list_nn(inner_ref)
939
+
} else {
940
+
TypeRef::named_list(inner_ref)
941
+
}
942
+
}
943
+
_ => {
944
+
// Arrays of complex types (objects, etc.) are just JSON
945
+
if is_required {
946
+
TypeRef::named_nn("JSON")
947
+
} else {
948
+
TypeRef::named("JSON")
949
+
}
950
+
}
951
+
}
952
+
}
953
+
}
954
+
}
955
+
956
+
/// Creates the Blob GraphQL type with url resolver
957
+
fn create_blob_type() -> Object {
958
+
let mut blob = Object::new("Blob");
959
+
960
+
// ref field - CID reference
961
+
blob = blob.field(Field::new("ref", TypeRef::named_nn(TypeRef::STRING), |ctx| {
962
+
FieldFuture::new(async move {
963
+
let container = ctx.parent_value.try_downcast_ref::<BlobContainer>()?;
964
+
Ok(Some(GraphQLValue::from(container.blob_ref.clone())))
965
+
})
966
+
}));
967
+
968
+
// mimeType field
969
+
blob = blob.field(Field::new("mimeType", TypeRef::named_nn(TypeRef::STRING), |ctx| {
970
+
FieldFuture::new(async move {
971
+
let container = ctx.parent_value.try_downcast_ref::<BlobContainer>()?;
972
+
Ok(Some(GraphQLValue::from(container.mime_type.clone())))
973
+
})
974
+
}));
975
+
976
+
// size field
977
+
blob = blob.field(Field::new("size", TypeRef::named_nn(TypeRef::INT), |ctx| {
978
+
FieldFuture::new(async move {
979
+
let container = ctx.parent_value.try_downcast_ref::<BlobContainer>()?;
980
+
Ok(Some(GraphQLValue::from(container.size as i32)))
981
+
})
982
+
}));
983
+
984
+
// url(preset) field with argument
985
+
blob = blob.field(
986
+
Field::new("url", TypeRef::named_nn(TypeRef::STRING), |ctx| {
987
+
FieldFuture::new(async move {
988
+
let container = ctx.parent_value.try_downcast_ref::<BlobContainer>()?;
989
+
990
+
// Get preset argument, default to "feed_fullsize"
991
+
let preset: String = match ctx.args.get("preset") {
992
+
Some(val) => val.string().unwrap_or("feed_fullsize").to_string(),
993
+
None => "feed_fullsize".to_string(),
994
+
};
995
+
996
+
// Build CDN URL: https://cdn.bsky.app/img/{preset}/plain/{did}/{cid}@jpeg
997
+
let cdn_base_url = "https://cdn.bsky.app/img";
998
+
let url = format!(
999
+
"{}/{}/plain/{}/{}@jpeg",
1000
+
cdn_base_url,
1001
+
preset,
1002
+
container.did,
1003
+
container.blob_ref
1004
+
);
1005
+
1006
+
Ok(Some(GraphQLValue::from(url)))
1007
+
})
1008
+
})
1009
+
.argument(async_graphql::dynamic::InputValue::new(
1010
+
"preset",
1011
+
TypeRef::named(TypeRef::STRING),
1012
+
))
1013
+
.description("Generate CDN URL for the blob with the specified preset (avatar, banner, feed_thumbnail, feed_fullsize)"),
1014
+
);
1015
+
1016
+
blob
1017
+
}
1018
+
1019
+
/// Creates the SyncResult GraphQL type for mutation responses
1020
+
fn create_sync_result_type() -> Object {
1021
+
let mut sync_result = Object::new("SyncResult");
1022
+
1023
+
sync_result = sync_result.field(Field::new("success", TypeRef::named_nn(TypeRef::BOOLEAN), |ctx| {
1024
+
FieldFuture::new(async move {
1025
+
let value = ctx.parent_value.downcast_ref::<GraphQLValue>()
1026
+
.ok_or_else(|| Error::new("Failed to downcast sync result"))?;
1027
+
if let GraphQLValue::Object(obj) = value {
1028
+
if let Some(success) = obj.get("success") {
1029
+
return Ok(Some(success.clone()));
1030
+
}
1031
+
}
1032
+
Ok(None)
1033
+
})
1034
+
}));
1035
+
1036
+
sync_result = sync_result.field(Field::new("reposProcessed", TypeRef::named_nn(TypeRef::INT), |ctx| {
1037
+
FieldFuture::new(async move {
1038
+
let value = ctx.parent_value.downcast_ref::<GraphQLValue>()
1039
+
.ok_or_else(|| Error::new("Failed to downcast sync result"))?;
1040
+
if let GraphQLValue::Object(obj) = value {
1041
+
if let Some(repos) = obj.get("reposProcessed") {
1042
+
return Ok(Some(repos.clone()));
1043
+
}
1044
+
}
1045
+
Ok(None)
1046
+
})
1047
+
}));
1048
+
1049
+
sync_result = sync_result.field(Field::new("recordsSynced", TypeRef::named_nn(TypeRef::INT), |ctx| {
1050
+
FieldFuture::new(async move {
1051
+
let value = ctx.parent_value.downcast_ref::<GraphQLValue>()
1052
+
.ok_or_else(|| Error::new("Failed to downcast sync result"))?;
1053
+
if let GraphQLValue::Object(obj) = value {
1054
+
if let Some(records) = obj.get("recordsSynced") {
1055
+
return Ok(Some(records.clone()));
1056
+
}
1057
+
}
1058
+
Ok(None)
1059
+
})
1060
+
}));
1061
+
1062
+
sync_result = sync_result.field(Field::new("timedOut", TypeRef::named_nn(TypeRef::BOOLEAN), |ctx| {
1063
+
FieldFuture::new(async move {
1064
+
let value = ctx.parent_value.downcast_ref::<GraphQLValue>()
1065
+
.ok_or_else(|| Error::new("Failed to downcast sync result"))?;
1066
+
if let GraphQLValue::Object(obj) = value {
1067
+
if let Some(timed_out) = obj.get("timedOut") {
1068
+
return Ok(Some(timed_out.clone()));
1069
+
}
1070
+
}
1071
+
Ok(None)
1072
+
})
1073
+
}));
1074
+
1075
+
sync_result = sync_result.field(Field::new("message", TypeRef::named_nn(TypeRef::STRING), |ctx| {
1076
+
FieldFuture::new(async move {
1077
+
let value = ctx.parent_value.downcast_ref::<GraphQLValue>()
1078
+
.ok_or_else(|| Error::new("Failed to downcast sync result"))?;
1079
+
if let GraphQLValue::Object(obj) = value {
1080
+
if let Some(message) = obj.get("message") {
1081
+
return Ok(Some(message.clone()));
1082
+
}
1083
+
}
1084
+
Ok(None)
1085
+
})
1086
+
}));
1087
+
1088
+
sync_result
1089
+
}
1090
+
1091
+
/// Creates the SortDirection enum type
1092
+
fn create_sort_direction_enum() -> Enum {
1093
+
Enum::new("SortDirection")
1094
+
.item(EnumItem::new("asc"))
1095
+
.item(EnumItem::new("desc"))
1096
+
}
1097
+
1098
+
/// Creates the SortField input type
1099
+
fn create_sort_field_input() -> InputObject {
1100
+
InputObject::new("SortField")
1101
+
.field(InputValue::new("field", TypeRef::named_nn(TypeRef::STRING)))
1102
+
.field(InputValue::new(
1103
+
"direction",
1104
+
TypeRef::named_nn("SortDirection"),
1105
+
))
1106
+
}
1107
+
1108
+
/// Creates the StringCondition input type for string field filtering
1109
+
fn create_string_condition_input() -> InputObject {
1110
+
InputObject::new("StringCondition")
1111
+
.field(InputValue::new("eq", TypeRef::named(TypeRef::STRING)))
1112
+
.field(InputValue::new("in", TypeRef::named_list(TypeRef::STRING)))
1113
+
.field(InputValue::new("contains", TypeRef::named(TypeRef::STRING)))
1114
+
}
1115
+
1116
+
/// Creates the IntCondition input type for int field filtering
1117
+
fn create_int_condition_input() -> InputObject {
1118
+
InputObject::new("IntCondition")
1119
+
.field(InputValue::new("eq", TypeRef::named(TypeRef::INT)))
1120
+
.field(InputValue::new("in", TypeRef::named_list(TypeRef::INT)))
1121
+
}
1122
+
1123
+
/// Creates the PageInfo type for connection pagination
1124
+
fn create_page_info_type() -> Object {
1125
+
let mut page_info = Object::new("PageInfo");
1126
+
1127
+
page_info = page_info.field(Field::new("hasNextPage", TypeRef::named_nn(TypeRef::BOOLEAN), |ctx| {
1128
+
FieldFuture::new(async move {
1129
+
let value = ctx.parent_value.downcast_ref::<GraphQLValue>()
1130
+
.ok_or_else(|| Error::new("Failed to downcast PageInfo"))?;
1131
+
if let GraphQLValue::Object(obj) = value {
1132
+
if let Some(has_next) = obj.get("hasNextPage") {
1133
+
return Ok(Some(has_next.clone()));
1134
+
}
1135
+
}
1136
+
Ok(Some(GraphQLValue::from(false)))
1137
+
})
1138
+
}));
1139
+
1140
+
page_info = page_info.field(Field::new("hasPreviousPage", TypeRef::named_nn(TypeRef::BOOLEAN), |ctx| {
1141
+
FieldFuture::new(async move {
1142
+
let value = ctx.parent_value.downcast_ref::<GraphQLValue>()
1143
+
.ok_or_else(|| Error::new("Failed to downcast PageInfo"))?;
1144
+
if let GraphQLValue::Object(obj) = value {
1145
+
if let Some(has_prev) = obj.get("hasPreviousPage") {
1146
+
return Ok(Some(has_prev.clone()));
1147
+
}
1148
+
}
1149
+
Ok(Some(GraphQLValue::from(false)))
1150
+
})
1151
+
}));
1152
+
1153
+
page_info = page_info.field(Field::new("startCursor", TypeRef::named(TypeRef::STRING), |ctx| {
1154
+
FieldFuture::new(async move {
1155
+
let value = ctx.parent_value.downcast_ref::<GraphQLValue>()
1156
+
.ok_or_else(|| Error::new("Failed to downcast PageInfo"))?;
1157
+
if let GraphQLValue::Object(obj) = value {
1158
+
if let Some(cursor) = obj.get("startCursor") {
1159
+
return Ok(Some(cursor.clone()));
1160
+
}
1161
+
}
1162
+
Ok(None)
1163
+
})
1164
+
}));
1165
+
1166
+
page_info = page_info.field(Field::new("endCursor", TypeRef::named(TypeRef::STRING), |ctx| {
1167
+
FieldFuture::new(async move {
1168
+
let value = ctx.parent_value.downcast_ref::<GraphQLValue>()
1169
+
.ok_or_else(|| Error::new("Failed to downcast PageInfo"))?;
1170
+
if let GraphQLValue::Object(obj) = value {
1171
+
if let Some(cursor) = obj.get("endCursor") {
1172
+
return Ok(Some(cursor.clone()));
1173
+
}
1174
+
}
1175
+
Ok(None)
1176
+
})
1177
+
}));
1178
+
1179
+
page_info
1180
+
}
1181
+
1182
+
/// Connection data structure that holds all connection fields
1183
+
#[derive(Clone)]
1184
+
struct ConnectionData {
1185
+
total_count: i32,
1186
+
has_next_page: bool,
1187
+
end_cursor: Option<String>,
1188
+
nodes: Vec<RecordContainer>,
1189
+
}
1190
+
1191
+
/// Edge data structure for Relay connections
1192
+
#[derive(Clone)]
1193
+
struct EdgeData {
1194
+
node: RecordContainer,
1195
+
cursor: String,
1196
+
}
1197
+
1198
+
/// Creates an Edge type for a given record type
1199
+
/// Example: For "Post" creates "PostEdge" with node and cursor
1200
+
fn create_edge_type(record_type_name: &str) -> Object {
1201
+
let edge_name = format!("{}Edge", record_type_name);
1202
+
let mut edge = Object::new(&edge_name);
1203
+
1204
+
// Add node field
1205
+
let record_type = record_type_name.to_string();
1206
+
edge = edge.field(Field::new("node", TypeRef::named_nn(&record_type), |ctx| {
1207
+
FieldFuture::new(async move {
1208
+
let edge_data = ctx.parent_value.try_downcast_ref::<EdgeData>()?;
1209
+
Ok(Some(FieldValue::owned_any(edge_data.node.clone())))
1210
+
})
1211
+
}));
1212
+
1213
+
// Add cursor field
1214
+
edge = edge.field(Field::new("cursor", TypeRef::named_nn(TypeRef::STRING), |ctx| {
1215
+
FieldFuture::new(async move {
1216
+
let edge_data = ctx.parent_value.try_downcast_ref::<EdgeData>()?;
1217
+
Ok(Some(GraphQLValue::from(edge_data.cursor.clone())))
1218
+
})
1219
+
}));
1220
+
1221
+
edge
1222
+
}
1223
+
1224
+
/// Creates a Connection type for a given record type
1225
+
/// Example: For "Post" creates "PostConnection" with edges, pageInfo, and totalCount
1226
+
fn create_connection_type(record_type_name: &str) -> Object {
1227
+
let connection_name = format!("{}Connection", record_type_name);
1228
+
let mut connection = Object::new(&connection_name);
1229
+
1230
+
// Add totalCount field
1231
+
connection = connection.field(Field::new("totalCount", TypeRef::named_nn(TypeRef::INT), |ctx| {
1232
+
FieldFuture::new(async move {
1233
+
let data = ctx.parent_value.try_downcast_ref::<ConnectionData>()?;
1234
+
Ok(Some(GraphQLValue::from(data.total_count)))
1235
+
})
1236
+
}));
1237
+
1238
+
// Add pageInfo field
1239
+
connection = connection.field(Field::new("pageInfo", TypeRef::named_nn("PageInfo"), |ctx| {
1240
+
FieldFuture::new(async move {
1241
+
let data = ctx.parent_value.try_downcast_ref::<ConnectionData>()?;
1242
+
1243
+
let mut page_info = async_graphql::indexmap::IndexMap::new();
1244
+
page_info.insert(
1245
+
async_graphql::Name::new("hasNextPage"),
1246
+
GraphQLValue::from(data.has_next_page)
1247
+
);
1248
+
// For forward pagination only, hasPreviousPage is always false
1249
+
page_info.insert(
1250
+
async_graphql::Name::new("hasPreviousPage"),
1251
+
GraphQLValue::from(false)
1252
+
);
1253
+
1254
+
// Add startCursor (first node's cid if available)
1255
+
if !data.nodes.is_empty() {
1256
+
if let Some(first_record) = data.nodes.first() {
1257
+
let start_cursor = general_purpose::URL_SAFE_NO_PAD.encode(first_record.record.cid.clone());
1258
+
page_info.insert(
1259
+
async_graphql::Name::new("startCursor"),
1260
+
GraphQLValue::from(start_cursor)
1261
+
);
1262
+
}
1263
+
}
1264
+
1265
+
// Add endCursor
1266
+
if let Some(ref cursor) = data.end_cursor {
1267
+
page_info.insert(
1268
+
async_graphql::Name::new("endCursor"),
1269
+
GraphQLValue::from(cursor.clone())
1270
+
);
1271
+
}
1272
+
1273
+
Ok(Some(FieldValue::owned_any(GraphQLValue::Object(page_info))))
1274
+
})
1275
+
}));
1276
+
1277
+
// Add edges field (Relay standard)
1278
+
let edge_type = format!("{}Edge", record_type_name);
1279
+
connection = connection.field(Field::new("edges", TypeRef::named_nn_list_nn(&edge_type), |ctx| {
1280
+
FieldFuture::new(async move {
1281
+
let data = ctx.parent_value.try_downcast_ref::<ConnectionData>()?;
1282
+
1283
+
let field_values: Vec<FieldValue<'_>> = data.nodes.iter()
1284
+
.map(|node| {
1285
+
// Use base64-encoded CID as cursor
1286
+
let cursor = general_purpose::URL_SAFE_NO_PAD.encode(node.record.cid.clone());
1287
+
let edge = EdgeData {
1288
+
node: node.clone(),
1289
+
cursor,
1290
+
};
1291
+
FieldValue::owned_any(edge)
1292
+
})
1293
+
.collect();
1294
+
1295
+
Ok(Some(FieldValue::list(field_values)))
1296
+
})
1297
+
}));
1298
+
1299
+
// Add nodes field (convenience, direct access to records without edges wrapper)
1300
+
connection = connection.field(Field::new("nodes", TypeRef::named_nn_list_nn(record_type_name), |ctx| {
1301
+
FieldFuture::new(async move {
1302
+
let data = ctx.parent_value.try_downcast_ref::<ConnectionData>()?;
1303
+
1304
+
let field_values: Vec<FieldValue<'_>> = data.nodes.iter()
1305
+
.map(|node| FieldValue::owned_any(node.clone()))
1306
+
.collect();
1307
+
1308
+
Ok(Some(FieldValue::list(field_values)))
1309
+
})
1310
+
}));
1311
+
1312
+
connection
1313
+
}
1314
+
1315
+
/// Creates the Mutation root type with sync operations
1316
+
fn create_mutation_type(database: Database, slice_uri: String) -> Object {
1317
+
let mut mutation = Object::new("Mutation");
1318
+
1319
+
// Add syncUserCollections mutation
1320
+
let db_clone = database.clone();
1321
+
let slice_clone = slice_uri.clone();
1322
+
1323
+
mutation = mutation.field(
1324
+
Field::new(
1325
+
"syncUserCollections",
1326
+
TypeRef::named_nn("SyncResult"),
1327
+
move |ctx| {
1328
+
let db = db_clone.clone();
1329
+
let slice = slice_clone.clone();
1330
+
1331
+
FieldFuture::new(async move {
1332
+
let did = ctx.args.get("did")
1333
+
.and_then(|v| v.string().ok())
1334
+
.ok_or_else(|| Error::new("did argument is required"))?;
1335
+
1336
+
// Create sync service and call sync_user_collections
1337
+
let cache_backend = crate::cache::CacheFactory::create_cache(
1338
+
crate::cache::CacheBackend::InMemory { ttl_seconds: None }
1339
+
).await.map_err(|e| Error::new(format!("Failed to create cache: {}", e)))?;
1340
+
let cache = Arc::new(Mutex::new(crate::cache::SliceCache::new(cache_backend)));
1341
+
let sync_service = crate::sync::SyncService::with_cache(
1342
+
db.clone(),
1343
+
std::env::var("RELAY_ENDPOINT")
1344
+
.unwrap_or_else(|_| "https://relay1.us-west.bsky.network".to_string()),
1345
+
cache,
1346
+
);
1347
+
1348
+
let result = sync_service
1349
+
.sync_user_collections(did, &slice, 30) // 30 second timeout
1350
+
.await
1351
+
.map_err(|e| Error::new(format!("Sync failed: {}", e)))?;
1352
+
1353
+
// Convert result to GraphQL object
1354
+
let mut obj = async_graphql::indexmap::IndexMap::new();
1355
+
obj.insert(async_graphql::Name::new("success"), GraphQLValue::from(result.success));
1356
+
obj.insert(async_graphql::Name::new("reposProcessed"), GraphQLValue::from(result.repos_processed));
1357
+
obj.insert(async_graphql::Name::new("recordsSynced"), GraphQLValue::from(result.records_synced));
1358
+
obj.insert(async_graphql::Name::new("timedOut"), GraphQLValue::from(result.timed_out));
1359
+
obj.insert(async_graphql::Name::new("message"), GraphQLValue::from(result.message));
1360
+
1361
+
Ok(Some(FieldValue::owned_any(GraphQLValue::Object(obj))))
1362
+
})
1363
+
},
1364
+
)
1365
+
.argument(async_graphql::dynamic::InputValue::new(
1366
+
"did",
1367
+
TypeRef::named_nn(TypeRef::STRING),
1368
+
))
1369
+
.description("Sync user collections for a given DID")
1370
+
);
1371
+
1372
+
mutation
1373
+
}
1374
+
1375
+
/// Converts NSID to GraphQL type name
1376
+
/// Example: app.bsky.feed.post -> AppBskyFeedPost
1377
+
fn nsid_to_type_name(nsid: &str) -> String {
1378
+
nsid.split('.')
1379
+
.map(|part| {
1380
+
let mut chars = part.chars();
1381
+
match chars.next() {
1382
+
None => String::new(),
1383
+
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1384
+
}
1385
+
})
1386
+
.collect::<Vec<_>>()
1387
+
.join("")
1388
+
}
1389
+
1390
+
/// Converts NSID to GraphQL query name in camelCase and pluralized
1391
+
/// Example: app.bsky.feed.post -> appBskyFeedPosts
1392
+
/// Example: fm.teal.alpha.feed.play -> fmTealAlphaFeedPlays
1393
+
fn nsid_to_query_name(nsid: &str) -> String {
1394
+
// First convert to camelCase like join fields
1395
+
let camel_case = nsid_to_join_field_name(nsid);
1396
+
1397
+
// Then pluralize the end
1398
+
if camel_case.ends_with("s") || camel_case.ends_with("x") || camel_case.ends_with("ch") || camel_case.ends_with("sh") {
1399
+
format!("{}es", camel_case) // status -> statuses, box -> boxes
1400
+
} else if camel_case.ends_with("y") && camel_case.len() > 1 {
1401
+
let chars: Vec<char> = camel_case.chars().collect();
1402
+
if chars.len() > 1 && !['a', 'e', 'i', 'o', 'u'].contains(&chars[chars.len() - 2]) {
1403
+
format!("{}ies", &camel_case[..camel_case.len() - 1]) // party -> parties
1404
+
} else {
1405
+
format!("{}s", camel_case) // day -> days
1406
+
}
1407
+
} else {
1408
+
format!("{}s", camel_case) // post -> posts
1409
+
}
1410
+
}
1411
+
1412
+
/// Converts NSID to GraphQL join field name in camelCase
1413
+
/// Example: app.bsky.actor.profile -> appBskyActorProfile
1414
+
fn nsid_to_join_field_name(nsid: &str) -> String {
1415
+
let parts: Vec<&str> = nsid.split('.').collect();
1416
+
if parts.is_empty() {
1417
+
return nsid.to_string();
1418
+
}
1419
+
1420
+
// First part is lowercase, rest are capitalized
1421
+
let mut result = parts[0].to_string();
1422
+
for part in &parts[1..] {
1423
+
let mut chars = part.chars();
1424
+
if let Some(first) = chars.next() {
1425
+
result.push_str(&first.to_uppercase().collect::<String>());
1426
+
result.push_str(chars.as_str());
1427
+
}
1428
+
}
1429
+
1430
+
result
1431
+
}
+166
api/src/graphql/types.rs
+166
api/src/graphql/types.rs
···
1
+
//! GraphQL type definitions and mappings from AT Protocol lexicons
2
+
3
+
use serde_json::Value;
4
+
5
+
/// Represents a mapped GraphQL field from a lexicon property
6
+
#[derive(Debug, Clone)]
7
+
pub struct GraphQLField {
8
+
pub name: String,
9
+
pub field_type: GraphQLType,
10
+
pub is_required: bool,
11
+
}
12
+
13
+
/// GraphQL type representation mapped from lexicon types
14
+
#[derive(Debug, Clone)]
15
+
pub enum GraphQLType {
16
+
String,
17
+
Int,
18
+
Boolean,
19
+
Float,
20
+
/// Reference to another record (for strongRef)
21
+
Ref,
22
+
/// Array of a type
23
+
Array(Box<GraphQLType>),
24
+
/// Object with nested fields
25
+
Object(Vec<GraphQLField>),
26
+
/// Union of multiple types
27
+
Union,
28
+
/// Blob reference with CDN URL support
29
+
Blob,
30
+
/// Any JSON value
31
+
Json,
32
+
}
33
+
34
+
/// Maps AT Protocol lexicon type to GraphQL type
35
+
pub fn map_lexicon_type_to_graphql(
36
+
type_name: &str,
37
+
lexicon_def: &Value,
38
+
) -> GraphQLType {
39
+
match type_name {
40
+
"string" => GraphQLType::String,
41
+
"integer" => GraphQLType::Int,
42
+
"boolean" => GraphQLType::Boolean,
43
+
"number" => GraphQLType::Float,
44
+
"bytes" => GraphQLType::String, // Base64 encoded
45
+
"cid-link" => GraphQLType::String,
46
+
"blob" => GraphQLType::Blob,
47
+
"unknown" => GraphQLType::Json,
48
+
"null" => GraphQLType::Json,
49
+
"ref" => {
50
+
// Check if this is a strongRef (link to another record)
51
+
let ref_name = lexicon_def
52
+
.get("ref")
53
+
.and_then(|r| r.as_str())
54
+
.unwrap_or("");
55
+
56
+
if ref_name == "com.atproto.repo.strongRef" {
57
+
GraphQLType::Ref
58
+
} else {
59
+
GraphQLType::Json
60
+
}
61
+
}
62
+
"array" => {
63
+
let items = lexicon_def.get("items");
64
+
let item_type = if let Some(items_obj) = items {
65
+
let item_type_name = items_obj
66
+
.get("type")
67
+
.and_then(|t| t.as_str())
68
+
.unwrap_or("unknown");
69
+
map_lexicon_type_to_graphql(item_type_name, items_obj)
70
+
} else {
71
+
GraphQLType::Json
72
+
};
73
+
GraphQLType::Array(Box::new(item_type))
74
+
}
75
+
"object" => {
76
+
let properties = lexicon_def
77
+
.get("properties")
78
+
.and_then(|p| p.as_object());
79
+
80
+
let required_fields: Vec<String> = lexicon_def
81
+
.get("required")
82
+
.and_then(|r| r.as_array())
83
+
.map(|arr| {
84
+
arr.iter()
85
+
.filter_map(|v| v.as_str().map(|s| s.to_string()))
86
+
.collect()
87
+
})
88
+
.unwrap_or_default();
89
+
90
+
if let Some(props) = properties {
91
+
let fields = props
92
+
.iter()
93
+
.map(|(field_name, field_def)| {
94
+
let field_type_name = field_def
95
+
.get("type")
96
+
.and_then(|t| t.as_str())
97
+
.unwrap_or("unknown");
98
+
99
+
GraphQLField {
100
+
name: field_name.clone(),
101
+
field_type: map_lexicon_type_to_graphql(
102
+
field_type_name,
103
+
field_def,
104
+
),
105
+
is_required: required_fields.contains(&field_name.to_string()),
106
+
}
107
+
})
108
+
.collect();
109
+
110
+
GraphQLType::Object(fields)
111
+
} else {
112
+
GraphQLType::Json
113
+
}
114
+
}
115
+
"union" => {
116
+
GraphQLType::Union
117
+
}
118
+
_ => GraphQLType::Json,
119
+
}
120
+
}
121
+
122
+
/// Extract collection schema from lexicon definitions
123
+
pub fn extract_collection_fields(
124
+
lexicon_defs: &Value,
125
+
) -> Vec<GraphQLField> {
126
+
let main_def = lexicon_defs
127
+
.get("main")
128
+
.or_else(|| lexicon_defs.get("record"));
129
+
130
+
if let Some(main) = main_def {
131
+
let type_name = main
132
+
.get("type")
133
+
.and_then(|t| t.as_str())
134
+
.unwrap_or("object");
135
+
136
+
// For "record" type, the actual object definition is nested under "record" field
137
+
let object_def = if type_name == "record" {
138
+
main.get("record").unwrap_or(main)
139
+
} else {
140
+
main
141
+
};
142
+
143
+
if type_name == "record" || type_name == "object" {
144
+
let object_type_name = object_def
145
+
.get("type")
146
+
.and_then(|t| t.as_str())
147
+
.unwrap_or("object");
148
+
149
+
if let GraphQLType::Object(fields) = map_lexicon_type_to_graphql(object_type_name, object_def) {
150
+
return fields;
151
+
}
152
+
}
153
+
}
154
+
155
+
vec![]
156
+
}
157
+
158
+
/// Extract the record key type from lexicon definitions
159
+
/// Returns Some("tid"), Some("literal:self"), Some("any"), or None
160
+
pub fn extract_record_key(lexicon_defs: &Value) -> Option<String> {
161
+
lexicon_defs
162
+
.get("main")?
163
+
.get("key")
164
+
.and_then(|k| k.as_str())
165
+
.map(|s| s.to_string())
166
+
}
+8
-2
api/src/main.rs
+8
-2
api/src/main.rs
···
5
5
mod cache;
6
6
mod database;
7
7
mod errors;
8
+
mod graphql;
8
9
mod jetstream;
9
10
mod jetstream_cursor;
10
11
mod jobs;
···
389
390
"/xrpc/network.slices.slice.getSyncSummary",
390
391
get(xrpc::network::slices::slice::get_sync_summary::handler),
391
392
)
393
+
// GraphQL endpoint
394
+
.route(
395
+
"/graphql",
396
+
get(graphql::graphql_playground).post(graphql::graphql_handler),
397
+
)
392
398
// Dynamic collection-specific XRPC endpoints (wildcard routes must come last)
393
399
.route(
394
-
"/xrpc/*method",
400
+
"/xrpc/{*method}",
395
401
get(api::xrpc_dynamic::dynamic_xrpc_handler),
396
402
)
397
403
.route(
398
-
"/xrpc/*method",
404
+
"/xrpc/{*method}",
399
405
post(api::xrpc_dynamic::dynamic_xrpc_post_handler),
400
406
)
401
407
.layer(TraceLayer::new_for_http())