Highly ambitious ATProtocol AppView service and sdks

relay compatible graphql endpoint

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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())
+1 -1
api/src/models.rs
··· 16 16 pub slice_uri: Option<String>, 17 17 } 18 18 19 - #[derive(Debug, Serialize, Deserialize)] 19 + #[derive(Debug, Clone, Serialize, Deserialize)] 20 20 #[serde(rename_all = "camelCase")] 21 21 pub struct IndexedRecord { 22 22 pub uri: String,