Gleam Lustre Fullstack Atproto Demo App w/Slices.Network GraphQL API

use squall for graphql queries

+1851 -874
+8 -1
README.md
··· 29 29 - **SQLight** - SQLite database driver 30 30 - **Storail** - Session management 31 31 - **Glow Auth** - OAuth utilities 32 + - **Squall** - Type-safe GraphQL code generator 32 33 33 34 ### Shared 34 35 - Monorepo structure with shared types and utilities between client and server ··· 188 189 Profile pages include prerendered data in the initial HTML response, embedded as JSON in a script tag for instant hydration. 189 190 190 191 ### GraphQL Integration 191 - Direct queries and mutations to the Slices network API for ATProto profile data with access token support. 192 + Type-safe GraphQL queries and mutations generated from `.gql` files using Squall. All GraphQL operations are defined in `server/src/api/graphql/` and automatically generate type-safe Gleam code with decoders and input types. 193 + 194 + To regenerate GraphQL code after modifying `.gql` files: 195 + ```bash 196 + cd server 197 + make generate-graphql 198 + ``` 192 199 193 200 ## Building for Production 194 201
+4
graphql.config.yml
··· 1 + # graphql.config.yml 2 + 3 + schema: "./server/schema.graphql" 4 + documents: "server/**/*.{gql}"
+26
server/Makefile
··· 1 + .PHONY: help download-schema generate-graphql dev test build 2 + 3 + GRAPHQL_ENDPOINT := https://api.slices.network/graphql?slice=at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3m3gc7lhwzx2z 4 + 5 + help: ## Show this help message 6 + @echo 'Usage: make [target]' 7 + @echo '' 8 + @echo 'Available targets:' 9 + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}' 10 + 11 + download-schema: ## Download GraphQL schema from endpoint 12 + @echo "Downloading GraphQL schema from $(GRAPHQL_ENDPOINT)..." 13 + @npx get-graphql-schema $(GRAPHQL_ENDPOINT) > schema.graphql 14 + @echo "Schema saved to schema.graphql" 15 + 16 + generate-graphql: ## Regenerate GraphQL type-safe code from .gql files 17 + gleam run -m squall generate "$(GRAPHQL_ENDPOINT)" 18 + 19 + dev: ## Run the development server 20 + gleam run 21 + 22 + test: ## Run tests 23 + gleam test 24 + 25 + build: ## Build the project 26 + gleam build
+32
server/README.md
··· 22 22 gleam run # Run the project 23 23 gleam test # Run the tests 24 24 ``` 25 + 26 + ## GraphQL Code Generation 27 + 28 + This project uses [Squall](https://github.com/bigmoves/squall) to generate type-safe GraphQL queries from `.gql` files. 29 + 30 + ### Regenerating GraphQL Code 31 + 32 + After modifying any `.gql` files in `src/api/graphql/`, regenerate the type-safe Gleam code: 33 + 34 + ```sh 35 + # Using make (recommended) 36 + make generate-graphql 37 + 38 + # Or directly with gleam 39 + gleam run -m squall generate "https://api.slices.network/graphql?slice=at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3m3gc7lhwzx2z" 40 + ``` 41 + 42 + This will: 43 + - Introspect the GraphQL schema 44 + - Find all `.gql` files in `src/api/graphql/` 45 + - Generate type-safe `.gleam` files with decoders and input types 46 + 47 + ### GraphQL Queries 48 + 49 + All GraphQL operations are defined in `src/api/graphql/`: 50 + - `get_profile.gql` - Fetch profile by handle 51 + - `upload_blob.gql` - Upload blob mutation 52 + - `update_profile.gql` - Update profile mutation 53 + - `check_profile_exists.gql` - Check if profile exists 54 + - `sync_user_collections.gql` - Sync user collections 55 + - `get_bluesky_profile.gql` - Get Bluesky profile 56 + - `create_profile.gql` - Create new profile
+2
server/gleam.toml
··· 29 29 envoy = ">= 1.0.2 and < 2.0.0" 30 30 dotenv_gleam = ">= 2.0.1 and < 3.0.0" 31 31 birl = ">= 1.0.0 and < 2.0.0" 32 + squall = { git = "https://github.com/bigmoves/squall.git", ref = "458e03a" } 33 + # squall = { path = "../../squall" } local testing 32 34 33 35 [erlang] 34 36 extra_applications = ["inets", "ssl"]
+5 -1
server/manifest.toml
··· 2 2 # You typically do not need to edit this file 3 3 4 4 packages = [ 5 + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, 5 6 { name = "birl", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "2AC7BA26F998E3DFADDB657148BD5DDFE966958AD4D6D6957DD0D22E5B56C400" }, 6 7 { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, 7 8 { name = "dotenv_gleam", version = "2.0.1", build_tools = ["gleam"], requirements = ["envoy", "gleam_erlang", "gleam_stdlib", "simplifile"], otp_app = "dotenv_gleam", source = "hex", outer_checksum = "47391525F97AF2086B34A4F2E81C1A1102863ACA983540CD87A8D295B1636445" }, ··· 9 10 { name = "esqlite", version = "0.9.0", build_tools = ["rebar3"], requirements = [], otp_app = "esqlite", source = "hex", outer_checksum = "CCF72258A4EE152EC7AD92AA9A03552EB6CA1B06B65C93AD5B6E55C302E05855" }, 10 11 { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, 11 12 { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 13 + { name = "glam", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "237C2CE218A2A0A5D46D625F8EF5B78F964BC91018B78D692B17E1AB84295229" }, 12 14 { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, 13 15 { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 14 16 { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, ··· 33 35 { name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" }, 34 36 { name = "shared", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], source = "local", path = "../shared" }, 35 37 { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, 36 - { name = "sqlight", version = "1.0.2", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "74327861946FD2DA2313F80FD0130DBB38A70F262869C783C58F8600C8675E7D" }, 38 + { name = "sqlight", version = "1.0.3", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "CADD79663C9B61D4BAC960A47CC2D42CA8F48EAF5804DBEB79977287750F4B16" }, 39 + { name = "squall", version = "0.1.0", build_tools = ["gleam"], requirements = ["argv", "filepath", "glam", "gleam_http", "gleam_httpc", "gleam_json", "gleam_stdlib", "simplifile"], source = "git", repo = "https://github.com/bigmoves/squall.git", commit = "458e03af94d88b3d141648cd46ee3557748400f0" }, 37 40 { name = "storail", version = "3.0.1", build_tools = ["gleam"], requirements = ["directories", "filepath", "gleam_crypto", "gleam_json", "gleam_stdlib", "simplifile"], otp_app = "storail", source = "hex", outer_checksum = "22499D1930B57F389DE12939B3B74DC1EC2B4125BB173080D41BC60927FE45D9" }, 38 41 { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, 39 42 { name = "wisp", version = "2.1.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "362BDDD11BF48EB38CDE51A73BC7D1B89581B395CA998E3F23F11EC026151C54" }, ··· 55 58 mist = { version = ">= 5.0.3 and < 6.0.0" } 56 59 shared = { path = "../shared" } 57 60 sqlight = { version = ">= 1.0.2 and < 2.0.0" } 61 + squall = { git = "https://github.com/bigmoves/squall.git", ref = "458e03a" } 58 62 storail = { version = ">= 3.0.1 and < 4.0.0" } 59 63 wisp = { version = ">= 2.1.0 and < 3.0.0" }
+682
server/schema.graphql
··· 1 + """ 2 + Indicates that an Input Object is a OneOf Input Object (and thus requires exactly one of its field be provided) 3 + """ 4 + directive @oneOf on INPUT_OBJECT 5 + 6 + """ 7 + Provides a scalar specification URL for specifying the behavior of custom scalar types. 8 + """ 9 + directive @specifiedBy( 10 + """URL that specifies the behavior of this scalar.""" 11 + url: String! 12 + ) on SCALAR 13 + 14 + input AggregationOrderBy { 15 + count: SortDirection 16 + } 17 + 18 + type AppBskyActorProfile { 19 + id: ID! 20 + uri: String! 21 + cid: String! 22 + did: String! 23 + indexedAt: String! 24 + actorHandle: String 25 + avatar: Blob 26 + banner: Blob 27 + createdAt: String 28 + description: String 29 + displayName: String 30 + joinedViaStarterPack: JSON 31 + labels: JSON 32 + pinnedPost: JSON 33 + pronouns: String 34 + website: String 35 + orgAtmosphereconfProfile: OrgAtmosphereconfProfile 36 + orgAtmosphereconfProfiles(limit: Int): [OrgAtmosphereconfProfile!]! 37 + orgAtmosphereconfProfilesCount: Int! 38 + appBskyActorProfiles(limit: Int): [AppBskyActorProfile!]! 39 + appBskyActorProfilesCount: Int! 40 + } 41 + 42 + type AppBskyActorProfileAggregated { 43 + avatar: JSON 44 + banner: JSON 45 + createdAt: JSON 46 + description: JSON 47 + displayName: JSON 48 + joinedViaStarterPack: JSON 49 + labels: JSON 50 + pinnedPost: JSON 51 + pronouns: JSON 52 + website: JSON 53 + count: Int! 54 + } 55 + 56 + type AppBskyActorProfileConnection { 57 + totalCount: Int! 58 + pageInfo: PageInfo! 59 + edges: [AppBskyActorProfileEdge!]! 60 + nodes: [AppBskyActorProfile!]! 61 + } 62 + 63 + type AppBskyActorProfileEdge { 64 + node: AppBskyActorProfile! 65 + cursor: String! 66 + } 67 + 68 + enum AppBskyActorProfileGroupByField { 69 + indexedAt 70 + avatar 71 + banner 72 + createdAt 73 + description 74 + displayName 75 + joinedViaStarterPack 76 + labels 77 + pinnedPost 78 + pronouns 79 + website 80 + } 81 + 82 + input AppBskyActorProfileGroupByFieldInput { 83 + field: AppBskyActorProfileGroupByField! 84 + interval: DateInterval 85 + } 86 + 87 + input AppBskyActorProfileInput { 88 + avatar: JSON 89 + banner: JSON 90 + createdAt: String 91 + description: String 92 + displayName: String 93 + joinedViaStarterPack: JSON 94 + labels: JSON 95 + pinnedPost: JSON 96 + pronouns: String 97 + website: String 98 + } 99 + 100 + input AppBskyActorProfileSortFieldInput { 101 + field: AppBskyActorProfileGroupByField! 102 + direction: SortDirection 103 + } 104 + 105 + input AppBskyActorProfileWhereInput { 106 + indexedAt: DateTimeFilter 107 + uri: StringFilter 108 + cid: StringFilter 109 + did: StringFilter 110 + collection: StringFilter 111 + actorHandle: StringFilter 112 + avatar: StringFilter 113 + banner: StringFilter 114 + createdAt: StringFilter 115 + description: StringFilter 116 + displayName: StringFilter 117 + joinedViaStarterPack: StringFilter 118 + labels: StringFilter 119 + pinnedPost: StringFilter 120 + pronouns: StringFilter 121 + website: StringFilter 122 + json: StringFilter 123 + and: [AppBskyActorProfileWhereInput] 124 + or: [AppBskyActorProfileWhereInput] 125 + } 126 + 127 + type Blob { 128 + ref: String! 129 + mimeType: String! 130 + size: Int! 131 + 132 + """ 133 + Generate CDN URL for the blob with the specified preset (avatar, banner, feed_thumbnail, feed_fullsize) 134 + """ 135 + url(preset: String): String! 136 + } 137 + 138 + type BlobUploadResponse { 139 + blob: Blob! 140 + } 141 + 142 + type CollectionSummary { 143 + collection: String! 144 + estimatedRepos: Int! 145 + isExternal: Boolean! 146 + } 147 + 148 + type ComAtprotoRepoStrongRef { 149 + id: ID! 150 + did: String! 151 + indexedAt: String! 152 + actorHandle: String 153 + cid: String! 154 + uri: String! 155 + orgAtmosphereconfProfile: OrgAtmosphereconfProfile 156 + appBskyActorProfile: AppBskyActorProfile 157 + orgAtmosphereconfProfiles(limit: Int): [OrgAtmosphereconfProfile!]! 158 + orgAtmosphereconfProfilesCount: Int! 159 + appBskyActorProfiles(limit: Int): [AppBskyActorProfile!]! 160 + appBskyActorProfilesCount: Int! 161 + } 162 + 163 + type ComAtprotoRepoStrongRefAggregated { 164 + cid: JSON 165 + uri: JSON 166 + count: Int! 167 + } 168 + 169 + type ComAtprotoRepoStrongRefConnection { 170 + totalCount: Int! 171 + pageInfo: PageInfo! 172 + edges: [ComAtprotoRepoStrongRefEdge!]! 173 + nodes: [ComAtprotoRepoStrongRef!]! 174 + } 175 + 176 + type ComAtprotoRepoStrongRefEdge { 177 + node: ComAtprotoRepoStrongRef! 178 + cursor: String! 179 + } 180 + 181 + enum ComAtprotoRepoStrongRefGroupByField { 182 + indexedAt 183 + cid 184 + uri 185 + } 186 + 187 + input ComAtprotoRepoStrongRefGroupByFieldInput { 188 + field: ComAtprotoRepoStrongRefGroupByField! 189 + interval: DateInterval 190 + } 191 + 192 + input ComAtprotoRepoStrongRefInput { 193 + cid: String! 194 + uri: String! 195 + } 196 + 197 + input ComAtprotoRepoStrongRefSortFieldInput { 198 + field: ComAtprotoRepoStrongRefGroupByField! 199 + direction: SortDirection 200 + } 201 + 202 + input ComAtprotoRepoStrongRefWhereInput { 203 + indexedAt: DateTimeFilter 204 + did: StringFilter 205 + collection: StringFilter 206 + actorHandle: StringFilter 207 + cid: StringFilter 208 + uri: StringFilter 209 + json: StringFilter 210 + and: [ComAtprotoRepoStrongRefWhereInput] 211 + or: [ComAtprotoRepoStrongRefWhereInput] 212 + } 213 + 214 + type CommunityLexiconLocationHthree { 215 + name: String 216 + value: String 217 + } 218 + 219 + type CommunityLexiconLocationHthreeAggregated { 220 + name: JSON 221 + value: JSON 222 + count: Int! 223 + } 224 + 225 + type CommunityLexiconLocationHthreeConnection { 226 + totalCount: Int! 227 + pageInfo: PageInfo! 228 + edges: [CommunityLexiconLocationHthreeEdge!]! 229 + nodes: [CommunityLexiconLocationHthree!]! 230 + } 231 + 232 + type CommunityLexiconLocationHthreeEdge { 233 + node: CommunityLexiconLocationHthree! 234 + cursor: String! 235 + } 236 + 237 + enum CommunityLexiconLocationHthreeGroupByField { 238 + indexedAt 239 + name 240 + value 241 + } 242 + 243 + input CommunityLexiconLocationHthreeGroupByFieldInput { 244 + field: CommunityLexiconLocationHthreeGroupByField! 245 + interval: DateInterval 246 + } 247 + 248 + input CommunityLexiconLocationHthreeInput { 249 + name: String 250 + value: String! 251 + } 252 + 253 + input CommunityLexiconLocationHthreeSortFieldInput { 254 + field: CommunityLexiconLocationHthreeGroupByField! 255 + direction: SortDirection 256 + } 257 + 258 + input CommunityLexiconLocationHthreeWhereInput { 259 + indexedAt: DateTimeFilter 260 + uri: StringFilter 261 + cid: StringFilter 262 + did: StringFilter 263 + collection: StringFilter 264 + actorHandle: StringFilter 265 + name: StringFilter 266 + value: StringFilter 267 + json: StringFilter 268 + and: [CommunityLexiconLocationHthreeWhereInput] 269 + or: [CommunityLexiconLocationHthreeWhereInput] 270 + } 271 + 272 + enum DateInterval { 273 + second 274 + minute 275 + hour 276 + day 277 + week 278 + month 279 + quarter 280 + year 281 + } 282 + 283 + input DateTimeFilter { 284 + eq: String 285 + gt: String 286 + gte: String 287 + lt: String 288 + lte: String 289 + } 290 + 291 + type DeleteSliceRecordsOutput { 292 + message: String! 293 + recordsDeleted: Int! 294 + actorsDeleted: Int! 295 + } 296 + 297 + type JetstreamLogEntry { 298 + id: String! 299 + createdAt: String! 300 + logType: String! 301 + jobId: String 302 + userDid: String 303 + sliceUri: String 304 + level: String! 305 + message: String! 306 + metadata: JSON 307 + } 308 + 309 + scalar JSON 310 + 311 + type Mutation { 312 + """Sync user collections for a given DID""" 313 + syncUserCollections(did: String!): SyncResult! 314 + 315 + """Create a new org.atmosphereconf.profile record""" 316 + createOrgAtmosphereconfProfile(input: OrgAtmosphereconfProfileInput!, rkey: String): OrgAtmosphereconfProfile! 317 + 318 + """Update a org.atmosphereconf.profile record""" 319 + updateOrgAtmosphereconfProfile(rkey: String!, input: OrgAtmosphereconfProfileInput!): OrgAtmosphereconfProfile! 320 + 321 + """Delete a org.atmosphereconf.profile record""" 322 + deleteOrgAtmosphereconfProfile(rkey: String!): OrgAtmosphereconfProfile! 323 + 324 + """Create a new community.lexicon.location.hthree record""" 325 + createCommunityLexiconLocationHthree(input: CommunityLexiconLocationHthreeInput!, rkey: String): CommunityLexiconLocationHthree! 326 + 327 + """Update a community.lexicon.location.hthree record""" 328 + updateCommunityLexiconLocationHthree(rkey: String!, input: CommunityLexiconLocationHthreeInput!): CommunityLexiconLocationHthree! 329 + 330 + """Delete a community.lexicon.location.hthree record""" 331 + deleteCommunityLexiconLocationHthree(rkey: String!): CommunityLexiconLocationHthree! 332 + 333 + """Create a new app.bsky.actor.profile record""" 334 + createAppBskyActorProfile(input: AppBskyActorProfileInput!, rkey: String): AppBskyActorProfile! 335 + 336 + """Update a app.bsky.actor.profile record""" 337 + updateAppBskyActorProfile(rkey: String!, input: AppBskyActorProfileInput!): AppBskyActorProfile! 338 + 339 + """Delete a app.bsky.actor.profile record""" 340 + deleteAppBskyActorProfile(rkey: String!): AppBskyActorProfile! 341 + 342 + """Create a new com.atproto.repo.strongRef record""" 343 + createComAtprotoRepoStrongRef(input: ComAtprotoRepoStrongRefInput!, rkey: String): ComAtprotoRepoStrongRef! 344 + 345 + """Update a com.atproto.repo.strongRef record""" 346 + updateComAtprotoRepoStrongRef(rkey: String!, input: ComAtprotoRepoStrongRefInput!): ComAtprotoRepoStrongRef! 347 + 348 + """Delete a com.atproto.repo.strongRef record""" 349 + deleteComAtprotoRepoStrongRef(rkey: String!): ComAtprotoRepoStrongRef! 350 + 351 + """Start a sync job to backfill collections from the ATProto relay""" 352 + startSync(slice: String, collections: [String], externalCollections: [String], repos: [String], limitPerRepo: Int, skipValidation: Boolean, maxRepos: Int): StartSyncOutput! 353 + 354 + """Cancel a pending or running sync job""" 355 + cancelJob(jobId: String!): Boolean! 356 + 357 + """Delete a sync job from the database""" 358 + deleteJob(id: ID!): ID 359 + 360 + """Upload a blob to the user's AT Protocol repository""" 361 + uploadBlob(data: String!, mimeType: String!): BlobUploadResponse! 362 + 363 + """Register a new OAuth client for a slice""" 364 + createOAuthClient(sliceUri: String!, clientName: String!, redirectUris: [String!]!, scope: String!, clientUri: String, logoUri: String, tosUri: String, policyUri: String): OAuthClient! 365 + 366 + """Update an OAuth client""" 367 + updateOAuthClient(clientId: String!, clientName: String, redirectUris: [String], scope: String, clientUri: String, logoUri: String, tosUri: String, policyUri: String): OAuthClient! 368 + 369 + """Delete an OAuth client""" 370 + deleteOAuthClient(clientId: String!): Boolean! 371 + 372 + """ 373 + Delete all records and actors from a slice index. Requires authentication and slice ownership. 374 + """ 375 + deleteSliceRecords(slice: String): DeleteSliceRecordsOutput! 376 + } 377 + 378 + type OAuthClient { 379 + clientId: String! 380 + clientSecret: String 381 + clientName: String! 382 + redirectUris: [String!]! 383 + grantTypes: [String!]! 384 + responseTypes: [String!]! 385 + scope: String 386 + clientUri: String 387 + logoUri: String 388 + tosUri: String 389 + policyUri: String 390 + createdAt: String! 391 + createdByDid: String! 392 + } 393 + 394 + type OrgAtmosphereconfProfile { 395 + id: ID! 396 + uri: String! 397 + cid: String! 398 + did: String! 399 + indexedAt: String! 400 + actorHandle: String 401 + avatar: Blob 402 + createdAt: String 403 + description: String 404 + displayName: String 405 + homeTown: CommunityLexiconLocationHthree 406 + interests: [String] 407 + appBskyActorProfile: AppBskyActorProfile 408 + orgAtmosphereconfProfiles(limit: Int): [OrgAtmosphereconfProfile!]! 409 + orgAtmosphereconfProfilesCount: Int! 410 + appBskyActorProfiles(limit: Int): [AppBskyActorProfile!]! 411 + appBskyActorProfilesCount: Int! 412 + } 413 + 414 + type OrgAtmosphereconfProfileAggregated { 415 + avatar: JSON 416 + createdAt: JSON 417 + description: JSON 418 + displayName: JSON 419 + homeTown: JSON 420 + interests: JSON 421 + count: Int! 422 + } 423 + 424 + type OrgAtmosphereconfProfileConnection { 425 + totalCount: Int! 426 + pageInfo: PageInfo! 427 + edges: [OrgAtmosphereconfProfileEdge!]! 428 + nodes: [OrgAtmosphereconfProfile!]! 429 + } 430 + 431 + type OrgAtmosphereconfProfileEdge { 432 + node: OrgAtmosphereconfProfile! 433 + cursor: String! 434 + } 435 + 436 + enum OrgAtmosphereconfProfileGroupByField { 437 + indexedAt 438 + avatar 439 + createdAt 440 + description 441 + displayName 442 + homeTown 443 + interests 444 + } 445 + 446 + input OrgAtmosphereconfProfileGroupByFieldInput { 447 + field: OrgAtmosphereconfProfileGroupByField! 448 + interval: DateInterval 449 + } 450 + 451 + input OrgAtmosphereconfProfileInput { 452 + avatar: JSON 453 + createdAt: String 454 + description: String 455 + displayName: String 456 + homeTown: JSON 457 + interests: [String] 458 + } 459 + 460 + input OrgAtmosphereconfProfileSortFieldInput { 461 + field: OrgAtmosphereconfProfileGroupByField! 462 + direction: SortDirection 463 + } 464 + 465 + input OrgAtmosphereconfProfileWhereInput { 466 + indexedAt: DateTimeFilter 467 + uri: StringFilter 468 + cid: StringFilter 469 + did: StringFilter 470 + collection: StringFilter 471 + actorHandle: StringFilter 472 + avatar: StringFilter 473 + createdAt: StringFilter 474 + description: StringFilter 475 + displayName: StringFilter 476 + homeTown: StringFilter 477 + interests: StringFilter 478 + json: StringFilter 479 + and: [OrgAtmosphereconfProfileWhereInput] 480 + or: [OrgAtmosphereconfProfileWhereInput] 481 + } 482 + 483 + type PageInfo { 484 + hasNextPage: Boolean! 485 + hasPreviousPage: Boolean! 486 + startCursor: String 487 + endCursor: String 488 + } 489 + 490 + type Query { 491 + """Query org.atmosphereconf.profile records""" 492 + orgAtmosphereconfProfiles(first: Int, after: String, last: Int, before: String, sortBy: [OrgAtmosphereconfProfileSortFieldInput], where: OrgAtmosphereconfProfileWhereInput): OrgAtmosphereconfProfileConnection! 493 + 494 + """ 495 + Aggregated query for org.atmosphereconf.profile records with GROUP BY support 496 + """ 497 + orgAtmosphereconfProfilesAggregated(groupBy: [OrgAtmosphereconfProfileGroupByFieldInput!], where: OrgAtmosphereconfProfileWhereInput, orderBy: AggregationOrderBy, limit: Int): [OrgAtmosphereconfProfileAggregated!]! 498 + 499 + """Query community.lexicon.location.hthree records""" 500 + communityLexiconLocationHthrees(first: Int, after: String, last: Int, before: String, sortBy: [CommunityLexiconLocationHthreeSortFieldInput], where: CommunityLexiconLocationHthreeWhereInput): CommunityLexiconLocationHthreeConnection! 501 + 502 + """ 503 + Aggregated query for community.lexicon.location.hthree records with GROUP BY support 504 + """ 505 + communityLexiconLocationHthreesAggregated(groupBy: [CommunityLexiconLocationHthreeGroupByFieldInput!], where: CommunityLexiconLocationHthreeWhereInput, orderBy: AggregationOrderBy, limit: Int): [CommunityLexiconLocationHthreeAggregated!]! 506 + 507 + """Query app.bsky.actor.profile records""" 508 + appBskyActorProfiles(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyActorProfileSortFieldInput], where: AppBskyActorProfileWhereInput): AppBskyActorProfileConnection! 509 + 510 + """ 511 + Aggregated query for app.bsky.actor.profile records with GROUP BY support 512 + """ 513 + appBskyActorProfilesAggregated(groupBy: [AppBskyActorProfileGroupByFieldInput!], where: AppBskyActorProfileWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyActorProfileAggregated!]! 514 + 515 + """Query com.atproto.repo.strongRef records""" 516 + comAtprotoRepoStrongRefs(first: Int, after: String, last: Int, before: String, sortBy: [ComAtprotoRepoStrongRefSortFieldInput], where: ComAtprotoRepoStrongRefWhereInput): ComAtprotoRepoStrongRefConnection! 517 + 518 + """ 519 + Aggregated query for com.atproto.repo.strongRef records with GROUP BY support 520 + """ 521 + comAtprotoRepoStrongRefsAggregated(groupBy: [ComAtprotoRepoStrongRefGroupByFieldInput!], where: ComAtprotoRepoStrongRefWhereInput, orderBy: AggregationOrderBy, limit: Int): [ComAtprotoRepoStrongRefAggregated!]! 522 + 523 + """ 524 + Get logs from the Jetstream real-time indexing service, optionally filtered by slice 525 + """ 526 + jetstreamLogs(slice: String, limit: Int): [JetstreamLogEntry!]! 527 + 528 + """Get status of a specific sync job""" 529 + syncJob(jobId: String!): SyncJob 530 + 531 + """Get sync job history for a slice""" 532 + syncJobs(slice: String, limit: Int): [SyncJob!]! 533 + 534 + """Get logs for a specific sync job""" 535 + syncJobLogs(jobId: String!, limit: Int): [JetstreamLogEntry!]! 536 + 537 + """Get summary of repos that would be synced based on collection filters""" 538 + getSyncSummary(slice: String!, collections: [String], externalCollections: [String], repos: [String]): SyncSummary! 539 + 540 + """ 541 + Get sparkline data for multiple slices showing record indexing activity over time 542 + """ 543 + sparklines(slices: [String!]!, interval: String, duration: String): [SliceSparkline!]! 544 + 545 + """ 546 + Query records across all collections in a slice with filtering and pagination. 547 + Provide either sliceUri or both actorHandle and rkey. 548 + """ 549 + sliceRecords(sliceUri: String, actorHandle: String, rkey: String, first: Int, after: String, where: SliceRecordsWhereInput): SliceRecordsConnection! 550 + 551 + """Get all OAuth clients for a slice""" 552 + oauthClients(slice: String): [OAuthClient!]! 553 + } 554 + 555 + type SliceRecord { 556 + uri: String! 557 + cid: String! 558 + did: String! 559 + collection: String! 560 + value: String! 561 + indexedAt: String! 562 + } 563 + 564 + type SliceRecordEdge { 565 + node: SliceRecord! 566 + cursor: String! 567 + } 568 + 569 + type SliceRecordsConnection { 570 + totalCount: Int! 571 + edges: [SliceRecordEdge!]! 572 + pageInfo: PageInfo! 573 + } 574 + 575 + input SliceRecordsWhereInput { 576 + collection: StringFilter 577 + did: StringFilter 578 + uri: StringFilter 579 + cid: StringFilter 580 + indexedAt: DateTimeFilter 581 + json: StringFilter 582 + or: [SliceRecordsWhereInput] 583 + } 584 + 585 + type SliceSparkline { 586 + sliceUri: String! 587 + points: [SparklinePoint!]! 588 + } 589 + 590 + enum SortDirection { 591 + asc 592 + desc 593 + } 594 + 595 + type SparklinePoint { 596 + timestamp: String! 597 + count: Int! 598 + } 599 + 600 + type StartSyncOutput { 601 + jobId: String! 602 + message: String! 603 + } 604 + 605 + input StringFilter { 606 + eq: String 607 + in: [String] 608 + contains: String 609 + fuzzy: String 610 + gt: String 611 + gte: String 612 + lt: String 613 + lte: String 614 + } 615 + 616 + type Subscription { 617 + """Subscribe to org.atmosphereconf.profile record creation events""" 618 + orgAtmosphereconfProfileCreated: OrgAtmosphereconfProfile! 619 + 620 + """Subscribe to org.atmosphereconf.profile record update events""" 621 + orgAtmosphereconfProfileUpdated: OrgAtmosphereconfProfile! 622 + 623 + """ 624 + Subscribe to org.atmosphereconf.profile record deletion events. Returns the URI of deleted records. 625 + """ 626 + orgAtmosphereconfProfileDeleted: String! 627 + 628 + """Subscribe to app.bsky.actor.profile record creation events""" 629 + appBskyActorProfileCreated: AppBskyActorProfile! 630 + 631 + """Subscribe to app.bsky.actor.profile record update events""" 632 + appBskyActorProfileUpdated: AppBskyActorProfile! 633 + 634 + """ 635 + Subscribe to app.bsky.actor.profile record deletion events. Returns the URI of deleted records. 636 + """ 637 + appBskyActorProfileDeleted: String! 638 + 639 + """Subscribe to new Jetstream log entries, optionally filtered by slice""" 640 + jetstreamLogsCreated(slice: String): JetstreamLogEntry! 641 + 642 + """Subscribe to sync job status updates""" 643 + syncJobUpdated(jobId: String, slice: String): SyncJob! 644 + } 645 + 646 + type SyncJob { 647 + id: ID! 648 + jobId: String! 649 + sliceUri: String! 650 + status: String! 651 + createdAt: String! 652 + startedAt: String 653 + completedAt: String 654 + result: SyncJobResult 655 + error: String 656 + retryCount: Int! 657 + } 658 + 659 + type SyncJobResult { 660 + success: Boolean! 661 + totalRecords: Int! 662 + collectionsSynced: [String!]! 663 + reposProcessed: Int! 664 + message: String! 665 + } 666 + 667 + type SyncResult { 668 + success: Boolean! 669 + reposProcessed: Int! 670 + recordsSynced: Int! 671 + timedOut: Boolean! 672 + message: String! 673 + } 674 + 675 + type SyncSummary { 676 + totalRepos: Int! 677 + cappedRepos: Int! 678 + wouldBeCapped: Boolean! 679 + appliedLimit: Int! 680 + collectionsSummary: [CollectionSummary!]! 681 + } 682 +
+171 -857
server/src/api/graphql.gleam
··· 1 - import gleam/dynamic/decode 2 - import gleam/http 3 - import gleam/http/request 4 - import gleam/httpc 1 + import api/graphql/check_profile_exists 2 + import api/graphql/create_profile as create_profile_gql 3 + import api/graphql/get_bluesky_profile 4 + import api/graphql/get_profile as get_profile_gql 5 + import api/graphql/sync_user_collections 6 + import api/graphql/update_profile as update_profile_gql 7 + import api/graphql/upload_blob as upload_blob_gql 5 8 import gleam/json 6 9 import gleam/option.{type Option, None, Some} 7 10 import gleam/result 8 - import gleam/string 9 11 import shared/profile.{type Profile} 12 + import squall 10 13 11 14 pub type Config { 12 15 Config(api_url: String, slice_uri: String, access_token: String) 13 16 } 14 17 18 + /// Create a squall client from our config 19 + fn create_client(config: Config) -> squall.Client { 20 + squall.new_client(config.api_url, [ 21 + #("X-Slice-Uri", config.slice_uri), 22 + #("Authorization", "Bearer " <> config.access_token), 23 + ]) 24 + } 25 + 15 26 /// Fetch profile by handle from the GraphQL API 16 27 pub fn get_profile_by_handle( 17 28 config: Config, 18 29 handle: String, 19 30 ) -> Result(Option(Profile), String) { 20 - let query = 21 - " 22 - query GetProfile($handle: String!) { 23 - orgAtmosphereconfProfiles(where: { actorHandle: { eq: $handle } }, first: 1) { 24 - edges { 25 - node { 26 - id 27 - uri 28 - cid 29 - did 30 - actorHandle 31 - displayName 32 - description 33 - avatar { 34 - ref 35 - mimeType 36 - size 37 - url(preset: \"avatar\") 38 - } 39 - homeTown 40 - interests 41 - indexedAt 42 - } 43 - } 44 - } 45 - } 46 - " 31 + let client = create_client(config) 47 32 48 - let variables = json.object([#("handle", json.string(handle))]) 33 + use response <- result.try(get_profile_gql.get_profile(client, handle)) 49 34 50 - let body_json = 51 - json.object([ 52 - #("query", json.string(query)), 53 - #("variables", variables), 54 - ]) 35 + // Convert the generated response to our Profile type 36 + case response.org_atmosphereconf_profiles.edges { 37 + [] -> Ok(None) 38 + [first_edge, ..] -> { 39 + let node = first_edge.node 55 40 56 - // Build the HTTP request 57 - use req <- result.try( 58 - request.to(config.api_url) 59 - |> result.map_error(fn(_) { "Failed to create request" }), 60 - ) 61 - 62 - let req = 63 - request.set_method(req, http.Post) 64 - |> request.set_header("content-type", "application/json") 65 - |> request.set_header("X-Slice-Uri", config.slice_uri) 66 - |> request.set_body(json.to_string(body_json)) 41 + // Convert avatar from generated Blob type to our AvatarBlob type 42 + let avatar_blob = case node.avatar { 43 + Some(blob) -> 44 + Some(profile.AvatarBlob( 45 + ref: blob.ref, 46 + mime_type: blob.mime_type, 47 + size: blob.size, 48 + )) 49 + None -> None 50 + } 67 51 68 - // Send the request 69 - use resp <- result.try( 70 - httpc.send(req) 71 - |> result.map_error(fn(_) { "HTTP request failed" }), 72 - ) 52 + // Convert home_town from generated type to HomeTown type 53 + let home_town = case node.home_town { 54 + Some(ht) -> convert_home_town(ht) 55 + None -> None 56 + } 73 57 74 - // Check status code 75 - case resp.status { 76 - 200 -> parse_profile_response(resp.body) 77 - _ -> 78 - Error( 79 - "API returned status " 80 - <> string.inspect(resp.status) 81 - <> " with body: " 82 - <> resp.body, 58 + Ok( 59 + Some(profile.Profile( 60 + id: node.id, 61 + uri: node.uri, 62 + cid: node.cid, 63 + did: node.did, 64 + handle: node.actor_handle, 65 + display_name: node.display_name, 66 + description: node.description, 67 + avatar_url: case node.avatar { 68 + Some(blob) -> Some(blob.url) 69 + None -> None 70 + }, 71 + avatar_blob: avatar_blob, 72 + home_town: home_town, 73 + interests: node.interests, 74 + created_at: node.created_at, 75 + indexed_at: node.indexed_at, 76 + )), 83 77 ) 78 + } 84 79 } 85 80 } 86 81 87 - /// Decode a profile from GraphQL format (with camelCase fields) 88 - /// The data should be positioned at the profile node level 89 - fn decode_graphql_profile(data: decode.Dynamic) -> decode.Decoder(Profile) { 90 - use id <- decode.field("id", decode.string) 91 - use uri <- decode.field("uri", decode.string) 92 - use cid <- decode.field("cid", decode.string) 93 - use did <- decode.field("did", decode.string) 94 - 95 - // For optional fields, extract them manually 96 - let handle = case 97 - decode.run(data, decode.at(["actorHandle"], decode.optional(decode.string))) 98 - { 99 - Ok(val) -> val 100 - Error(_) -> None 82 + /// Convert generated CommunityLexiconLocationHthree to HomeTown type 83 + fn convert_home_town( 84 + ht: get_profile_gql.CommunityLexiconLocationHthree, 85 + ) -> Option(profile.HomeTown) { 86 + case ht.name, ht.value { 87 + Some(name), Some(value) -> Some(profile.HomeTown(name: name, h3_index: value)) 88 + _, _ -> None 101 89 } 102 - 103 - let display_name = case 104 - decode.run(data, decode.at(["displayName"], decode.optional(decode.string))) 105 - { 106 - Ok(val) -> val 107 - Error(_) -> None 108 - } 109 - 110 - let description = case 111 - decode.run(data, decode.at(["description"], decode.optional(decode.string))) 112 - { 113 - Ok(val) -> val 114 - Error(_) -> None 115 - } 116 - 117 - let avatar_url = case 118 - decode.run( 119 - data, 120 - decode.at(["avatar", "url"], decode.optional(decode.string)), 121 - ) 122 - { 123 - Ok(val) -> val 124 - Error(_) -> None 125 - } 126 - 127 - let avatar_blob = case 128 - decode.run( 129 - data, 130 - decode.at( 131 - ["avatar"], 132 - decode.optional({ 133 - use ref <- decode.field("ref", decode.string) 134 - use mime_type <- decode.field("mimeType", decode.string) 135 - use size <- decode.field("size", decode.int) 136 - decode.success(profile.AvatarBlob( 137 - ref: ref, 138 - mime_type: mime_type, 139 - size: size, 140 - )) 141 - }), 142 - ), 143 - ) 144 - { 145 - Ok(val) -> val 146 - Error(_) -> None 147 - } 148 - 149 - let home_town = case 150 - decode.run( 151 - data, 152 - decode.at( 153 - ["homeTown"], 154 - decode.optional({ 155 - use name <- decode.field("name", decode.string) 156 - use value <- decode.field("value", decode.string) 157 - decode.success(profile.HomeTown(name: name, h3_index: value)) 158 - }), 159 - ), 160 - ) 161 - { 162 - Ok(val) -> val 163 - Error(_) -> None 164 - } 165 - 166 - let interests = case 167 - decode.run( 168 - data, 169 - decode.at(["interests"], decode.optional(decode.list(decode.string))), 170 - ) 171 - { 172 - Ok(val) -> val 173 - Error(_) -> None 174 - } 175 - 176 - use indexed_at <- decode.field("indexedAt", decode.string) 177 - decode.success(profile.Profile( 178 - id:, 179 - uri:, 180 - cid:, 181 - did:, 182 - handle:, 183 - display_name:, 184 - description:, 185 - avatar_url:, 186 - avatar_blob:, 187 - home_town:, 188 - interests:, 189 - indexed_at:, 190 - )) 191 90 } 192 91 193 - /// Parse the GraphQL response and extract profile data 194 - fn parse_profile_response( 195 - response_body: String, 196 - ) -> Result(Option(Profile), String) { 197 - // Parse JSON 198 - use data <- result.try( 199 - json.parse(response_body, decode.dynamic) 200 - |> result.map_error(fn(_) { "Failed to parse JSON response" }), 201 - ) 202 - 203 - // Extract the edges array 204 - let edges_decoder = 205 - decode.at( 206 - ["data", "orgAtmosphereconfProfiles", "edges"], 207 - decode.list(decode.dynamic), 208 - ) 209 - 210 - use edges <- result.try( 211 - decode.run(data, edges_decoder) 212 - |> result.map_error(fn(errors) { 213 - "Failed to extract edges: " <> string.inspect(errors) 214 - }), 215 - ) 216 - 217 - // Extract first profile if exists 218 - case edges { 219 - [] -> Ok(None) 220 - [first_edge, ..] -> { 221 - // Decode the profile from edge.node.* fields 222 - let profile_decoder = { 223 - use id <- decode.subfield(["node", "id"], decode.string) 224 - use uri <- decode.subfield(["node", "uri"], decode.string) 225 - use cid <- decode.subfield(["node", "cid"], decode.string) 226 - use did <- decode.subfield(["node", "did"], decode.string) 227 - 228 - // For optional fields 229 - let handle = case 230 - decode.run( 231 - first_edge, 232 - decode.at(["node", "actorHandle"], decode.optional(decode.string)), 233 - ) 234 - { 235 - Ok(val) -> val 236 - Error(_) -> None 237 - } 238 - 239 - let display_name = case 240 - decode.run( 241 - first_edge, 242 - decode.at(["node", "displayName"], decode.optional(decode.string)), 243 - ) 244 - { 245 - Ok(val) -> val 246 - Error(_) -> None 247 - } 248 - 249 - let description = case 250 - decode.run( 251 - first_edge, 252 - decode.at(["node", "description"], decode.optional(decode.string)), 253 - ) 254 - { 255 - Ok(val) -> val 256 - Error(_) -> None 257 - } 258 - 259 - let avatar_url = case 260 - decode.run( 261 - first_edge, 262 - decode.at(["node", "avatar", "url"], decode.optional(decode.string)), 263 - ) 264 - { 265 - Ok(val) -> val 266 - Error(_) -> None 267 - } 268 - 269 - // Decode avatar blob (ref, mimeType, size) 270 - let avatar_blob = case 271 - decode.run( 272 - first_edge, 273 - decode.at( 274 - ["node", "avatar"], 275 - decode.optional({ 276 - use ref <- decode.field("ref", decode.string) 277 - use mime_type <- decode.field("mimeType", decode.string) 278 - use size <- decode.field("size", decode.int) 279 - decode.success(profile.AvatarBlob( 280 - ref: ref, 281 - mime_type: mime_type, 282 - size: size, 283 - )) 284 - }), 285 - ), 286 - ) 287 - { 288 - Ok(val) -> val 289 - Error(_) -> None 290 - } 291 - 292 - let home_town = case 293 - decode.run( 294 - first_edge, 295 - decode.at( 296 - ["node", "homeTown"], 297 - decode.optional({ 298 - use name <- decode.field("name", decode.string) 299 - use value <- decode.field("value", decode.string) 300 - decode.success(profile.HomeTown(name: name, h3_index: value)) 301 - }), 302 - ), 303 - ) 304 - { 305 - Ok(val) -> val 306 - Error(_) -> None 307 - } 308 - 309 - let interests = case 310 - decode.run( 311 - first_edge, 312 - decode.at( 313 - ["node", "interests"], 314 - decode.optional(decode.list(decode.string)), 315 - ), 316 - ) 317 - { 318 - Ok(val) -> val 319 - Error(_) -> None 320 - } 321 - 322 - use indexed_at <- decode.subfield(["node", "indexedAt"], decode.string) 323 - decode.success(profile.Profile( 324 - id:, 325 - uri:, 326 - cid:, 327 - did:, 328 - handle:, 329 - display_name:, 330 - description:, 331 - avatar_url:, 332 - avatar_blob:, 333 - home_town:, 334 - interests:, 335 - indexed_at:, 336 - )) 337 - } 338 - 339 - use profile <- result.try( 340 - decode.run(first_edge, profile_decoder) 341 - |> result.map_error(fn(errors) { 342 - "Failed to decode profile: " <> string.inspect(errors) 343 - }), 344 - ) 345 - Ok(Some(profile)) 346 - } 92 + /// Convert update_profile's CommunityLexiconLocationHthree to HomeTown type 93 + fn convert_home_town_from_update( 94 + ht: update_profile_gql.CommunityLexiconLocationHthree, 95 + ) -> Option(profile.HomeTown) { 96 + case ht.name, ht.value { 97 + Some(name), Some(value) -> Some(profile.HomeTown(name: name, h3_index: value)) 98 + _, _ -> None 347 99 } 348 100 } 349 101 350 - pub type ProfileUpdate { 351 - ProfileUpdate( 352 - display_name: Option(String), 353 - description: Option(String), 354 - home_town: Option(json.Json), 355 - interests: Option(List(String)), 356 - avatar: Option(json.Json), 357 - ) 358 - } 102 + // Re-export the generated input type for convenience 103 + pub type ProfileUpdate = 104 + update_profile_gql.OrgAtmosphereconfProfileInput 359 105 360 106 /// Upload a blob (e.g., avatar image) and return the blob reference 361 107 pub fn upload_blob( ··· 363 109 base64_data: String, 364 110 mime_type: String, 365 111 ) -> Result(json.Json, String) { 366 - let mutation = 367 - " 368 - mutation UploadBlob($data: String!, $mimeType: String!) { 369 - uploadBlob(data: $data, mimeType: $mimeType) { 370 - blob 371 - } 372 - } 373 - " 374 - 375 - let variables = 376 - json.object([ 377 - #("data", json.string(base64_data)), 378 - #("mimeType", json.string(mime_type)), 379 - ]) 380 - 381 - let body_json = 382 - json.object([ 383 - #("query", json.string(mutation)), 384 - #("variables", variables), 385 - ]) 112 + let client = create_client(config) 386 113 387 - // Build the HTTP request 388 - use req <- result.try( 389 - request.to(config.api_url) 390 - |> result.map_error(fn(_) { "Failed to create request" }), 391 - ) 114 + use response <- result.try(upload_blob_gql.upload_blob( 115 + client, 116 + base64_data, 117 + mime_type, 118 + )) 392 119 393 - let req = 394 - request.set_method(req, http.Post) 395 - |> request.set_header("content-type", "application/json") 396 - |> request.set_header("X-Slice-Uri", config.slice_uri) 397 - |> request.set_header("Authorization", "Bearer " <> config.access_token) 398 - |> request.set_body(json.to_string(body_json)) 120 + // Extract blob fields directly from the typed response 121 + let blob = response.upload_blob.blob 399 122 400 - // Send the request 401 - use resp <- result.try( 402 - httpc.send(req) 403 - |> result.map_error(fn(_) { "HTTP request failed" }), 123 + // Reconstruct as json.Json with flat ref string 124 + Ok( 125 + json.object([ 126 + #("$type", json.string("blob")), 127 + #("ref", json.string(blob.ref)), 128 + #("mimeType", json.string(blob.mime_type)), 129 + #("size", json.int(blob.size)), 130 + ]), 404 131 ) 405 - 406 - // Check status code 407 - case resp.status { 408 - 200 -> { 409 - // Parse the response to extract the blob object 410 - // The blob is returned as a JSON object with fields like ref, mimeType, size 411 - use parsed <- result.try( 412 - json.parse(resp.body, decode.dynamic) 413 - |> result.map_error(fn(_) { "Failed to parse blob response" }), 414 - ) 415 - 416 - // Extract the blob field as a dynamic value 417 - use blob_data <- result.try( 418 - decode.run(parsed, decode.at(["data", "uploadBlob", "blob"], decode.dynamic)) 419 - |> result.map_error(fn(_) { "Failed to extract blob from response" }), 420 - ) 421 - 422 - // The blob structure is: 423 - // { "$type": "blob", "mimeType": "...", "ref": { "$link": "..." }, "size": 123 } 424 - 425 - // Decode the nested $link from ref.$link 426 - let link_decoder = { 427 - use link <- decode.subfield(["ref", "$link"], decode.string) 428 - decode.success(link) 429 - } 430 - 431 - use cid_link <- result.try( 432 - decode.run(blob_data, link_decoder) 433 - |> result.map_error(fn(errors) { 434 - "Failed to extract $link from ref: " <> string.inspect(errors) 435 - }), 436 - ) 437 - 438 - let mime_decoder = { 439 - use mime <- decode.field("mimeType", decode.string) 440 - decode.success(mime) 441 - } 442 - 443 - let mime_type = case decode.run(blob_data, mime_decoder) { 444 - Ok(val) -> val 445 - Error(_) -> "application/octet-stream" 446 - } 447 - 448 - let size_decoder = { 449 - use s <- decode.field("size", decode.int) 450 - decode.success(s) 451 - } 452 - 453 - let size = case decode.run(blob_data, size_decoder) { 454 - Ok(val) -> val 455 - Error(_) -> 0 456 - } 457 - 458 - // Reconstruct as json.Json with the ref nested structure 459 - Ok(json.object([ 460 - #("$type", json.string("blob")), 461 - #("mimeType", json.string(mime_type)), 462 - #("ref", json.object([#("$link", json.string(cid_link))])), 463 - #("size", json.int(size)), 464 - ])) 465 - } 466 - _ -> 467 - Error( 468 - "Blob upload returned status " 469 - <> string.inspect(resp.status) 470 - <> " with body: " 471 - <> resp.body, 472 - ) 473 - } 474 132 } 475 133 476 134 /// Update profile via GraphQL mutation ··· 479 137 _handle: String, 480 138 update: ProfileUpdate, 481 139 ) -> Result(profile.Profile, String) { 482 - let mutation = 483 - " 484 - mutation UpdateProfile($rkey: String!, $input: OrgAtmosphereconfProfileInput!) { 485 - updateOrgAtmosphereconfProfile(rkey: $rkey, input: $input) { 486 - id 487 - uri 488 - cid 489 - did 490 - actorHandle 491 - displayName 492 - description 493 - avatar { 494 - ref 495 - mimeType 496 - size 497 - url 498 - } 499 - homeTown 500 - interests 501 - indexedAt 502 - } 503 - } 504 - " 140 + let client = create_client(config) 505 141 506 - // Build input object 507 - let input_fields = [] 142 + // Use the generated function directly 143 + use response <- result.try(update_profile_gql.update_profile( 144 + client, 145 + "self", 146 + update, 147 + )) 508 148 509 - let input_fields = case update.display_name { 510 - Some(val) -> [#("displayName", json.string(val)), ..input_fields] 511 - None -> input_fields 512 - } 149 + // Convert generated type to our Profile type 150 + let node = response.update_org_atmosphereconf_profile 151 + Ok(convert_generated_profile_to_profile(node)) 152 + } 513 153 514 - let input_fields = case update.description { 515 - Some(val) -> [#("description", json.string(val)), ..input_fields] 516 - None -> input_fields 154 + /// Helper to convert generated profile type to our Profile type 155 + fn convert_generated_profile_to_profile( 156 + node: update_profile_gql.OrgAtmosphereconfProfile, 157 + ) -> profile.Profile { 158 + let avatar_blob = case node.avatar { 159 + Some(blob) -> 160 + Some(profile.AvatarBlob( 161 + ref: blob.ref, 162 + mime_type: blob.mime_type, 163 + size: blob.size, 164 + )) 165 + None -> None 517 166 } 518 167 519 - let input_fields = case update.home_town { 520 - Some(val) -> [#("homeTown", val), ..input_fields] 521 - None -> input_fields 522 - } 523 - 524 - let input_fields = case update.interests { 525 - Some(val) -> [#("interests", json.array(val, json.string)), ..input_fields] 526 - None -> input_fields 527 - } 528 - 529 - let input_fields = case update.avatar { 530 - Some(val) -> [#("avatar", val), ..input_fields] 531 - None -> input_fields 168 + let home_town = case node.home_town { 169 + Some(ht) -> convert_home_town_from_update(ht) 170 + None -> None 532 171 } 533 172 534 - let variables = 535 - json.object([ 536 - #("rkey", json.string("self")), 537 - #("input", json.object(input_fields)), 538 - ]) 539 - 540 - let body_json = 541 - json.object([ 542 - #("query", json.string(mutation)), 543 - #("variables", variables), 544 - ]) 545 - 546 - // Build the HTTP request 547 - use req <- result.try( 548 - request.to(config.api_url) 549 - |> result.map_error(fn(_) { "Failed to create request" }), 550 - ) 551 - 552 - let req = 553 - request.set_method(req, http.Post) 554 - |> request.set_header("content-type", "application/json") 555 - |> request.set_header("X-Slice-Uri", config.slice_uri) 556 - |> request.set_header("Authorization", "Bearer " <> config.access_token) 557 - |> request.set_body(json.to_string(body_json)) 558 - 559 - // Send the request 560 - use resp <- result.try( 561 - httpc.send(req) 562 - |> result.map_error(fn(_) { "HTTP request failed" }), 173 + profile.Profile( 174 + id: node.id, 175 + uri: node.uri, 176 + cid: node.cid, 177 + did: node.did, 178 + handle: node.actor_handle, 179 + display_name: node.display_name, 180 + description: node.description, 181 + avatar_url: case node.avatar { 182 + Some(blob) -> Some(blob.url) 183 + None -> None 184 + }, 185 + avatar_blob: avatar_blob, 186 + home_town: home_town, 187 + interests: node.interests, 188 + created_at: node.created_at, 189 + indexed_at: node.indexed_at, 563 190 ) 564 - 565 - // Check status code and parse response 566 - case resp.status { 567 - 200 -> { 568 - // Parse JSON and extract the profile node 569 - use data <- result.try( 570 - json.parse(resp.body, decode.dynamic) 571 - |> result.map_error(fn(_) { "Failed to parse JSON response" }), 572 - ) 573 - 574 - // Extract the profile node from data.updateOrgAtmosphereconfProfile 575 - use profile_node <- result.try( 576 - decode.run( 577 - data, 578 - decode.at(["data", "updateOrgAtmosphereconfProfile"], decode.dynamic), 579 - ) 580 - |> result.map_error(fn(errors) { 581 - "Failed to extract updateOrgAtmosphereconfProfile: " 582 - <> string.inspect(errors) 583 - }), 584 - ) 585 - 586 - // Decode the profile using the reusable decoder 587 - decode.run(profile_node, decode_graphql_profile(profile_node)) 588 - |> result.map_error(fn(errors) { 589 - "Failed to decode profile: " <> string.inspect(errors) 590 - }) 591 - } 592 - _ -> 593 - Error( 594 - "API returned status " 595 - <> string.inspect(resp.status) 596 - <> " with body: " 597 - <> resp.body, 598 - ) 599 - } 600 191 } 601 192 602 193 // PROFILE INITIALIZATION HELPERS ---------------------------------------------- 603 194 604 - pub type BlueskyProfile { 605 - BlueskyProfile( 606 - display_name: Option(String), 607 - description: Option(String), 608 - avatar: Option(BlueskyAvatar), 609 - ) 610 - } 195 + // Re-export generated types with semantic names 196 + pub type BlueskyProfile = 197 + get_bluesky_profile.AppBskyActorProfile 611 198 612 - pub type BlueskyAvatar { 613 - BlueskyAvatar(ref: String, mime_type: String, size: Int) 614 - } 199 + pub type BlueskyAvatar = 200 + get_bluesky_profile.Blob 615 201 616 202 /// Check if a profile already exists for the given DID 617 203 pub fn check_profile_exists(config: Config, did: String) -> Result(Bool, String) { 618 - let query = 619 - " 620 - query CheckProfile($did: String!) { 621 - orgAtmosphereconfProfiles(where: { did: { eq: $did } }, first: 1) { 622 - edges { 623 - node { 624 - id 625 - } 626 - } 627 - } 628 - } 629 - " 630 - 631 - let variables = json.object([#("did", json.string(did))]) 632 - 633 - let body_json = 634 - json.object([ 635 - #("query", json.string(query)), 636 - #("variables", variables), 637 - ]) 638 - 639 - use req <- result.try( 640 - request.to(config.api_url) 641 - |> result.map_error(fn(_) { "Failed to create request" }), 642 - ) 643 - 644 - let req = 645 - request.set_method(req, http.Post) 646 - |> request.set_header("content-type", "application/json") 647 - |> request.set_header("X-Slice-Uri", config.slice_uri) 648 - |> request.set_header("Authorization", "Bearer " <> config.access_token) 649 - |> request.set_body(json.to_string(body_json)) 650 - 651 - use resp <- result.try( 652 - httpc.send(req) 653 - |> result.map_error(fn(_) { "HTTP request failed" }), 654 - ) 655 - 656 - case resp.status { 657 - 200 -> { 658 - use data <- result.try( 659 - json.parse(resp.body, decode.dynamic) 660 - |> result.map_error(fn(_) { "Failed to parse JSON response" }), 661 - ) 662 - 663 - let edges_decoder = 664 - decode.at( 665 - ["data", "orgAtmosphereconfProfiles", "edges"], 666 - decode.list(decode.dynamic), 667 - ) 204 + let client = create_client(config) 668 205 669 - use edges <- result.try( 670 - decode.run(data, edges_decoder) 671 - |> result.map_error(fn(_) { "Failed to extract edges" }), 672 - ) 206 + use response <- result.try(check_profile_exists.check_profile_exists( 207 + client, 208 + did, 209 + )) 673 210 674 - case edges { 675 - [] -> Ok(False) 676 - _ -> Ok(True) 677 - } 678 - } 679 - _ -> 680 - Error( 681 - "API returned status " 682 - <> string.inspect(resp.status) 683 - <> " with body: " 684 - <> resp.body, 685 - ) 211 + case response.org_atmosphereconf_profiles.edges { 212 + [] -> Ok(False) 213 + _ -> Ok(True) 686 214 } 687 215 } 688 216 689 217 /// Sync user collections (Bluesky data) 690 218 pub fn sync_user_collections(config: Config, did: String) -> Result(Nil, String) { 691 - let mutation = 692 - " 693 - mutation SyncUserCollections($did: String!) { 694 - syncUserCollections(did: $did) { 695 - success 696 - message 697 - } 698 - } 699 - " 219 + let client = create_client(config) 700 220 701 - let variables = json.object([#("did", json.string(did))]) 221 + use _response <- result.try(sync_user_collections.sync_user_collections( 222 + client, 223 + did, 224 + )) 702 225 703 - let body_json = 704 - json.object([ 705 - #("query", json.string(mutation)), 706 - #("variables", variables), 707 - ]) 708 - 709 - use req <- result.try( 710 - request.to(config.api_url) 711 - |> result.map_error(fn(_) { "Failed to create request" }), 712 - ) 713 - 714 - let req = 715 - request.set_method(req, http.Post) 716 - |> request.set_header("content-type", "application/json") 717 - |> request.set_header("X-Slice-Uri", config.slice_uri) 718 - |> request.set_header("Authorization", "Bearer " <> config.access_token) 719 - |> request.set_body(json.to_string(body_json)) 720 - 721 - use resp <- result.try( 722 - httpc.send(req) 723 - |> result.map_error(fn(_) { "HTTP request failed" }), 724 - ) 725 - 726 - case resp.status { 727 - 200 -> Ok(Nil) 728 - _ -> 729 - Error( 730 - "Sync failed with status " 731 - <> string.inspect(resp.status) 732 - <> " with body: " 733 - <> resp.body, 734 - ) 735 - } 226 + Ok(Nil) 736 227 } 737 228 738 229 /// Fetch Bluesky profile data ··· 740 231 config: Config, 741 232 did: String, 742 233 ) -> Result(Option(BlueskyProfile), String) { 743 - let query = 744 - " 745 - query GetBskyProfile($did: String!) { 746 - appBskyActorProfiles(where: { did: { eq: $did } }, first: 1) { 747 - edges { 748 - node { 749 - displayName 750 - description 751 - avatar { 752 - ref 753 - mimeType 754 - size 755 - } 756 - } 757 - } 758 - } 759 - } 760 - " 234 + let client = create_client(config) 761 235 762 - let variables = json.object([#("did", json.string(did))]) 763 - 764 - let body_json = 765 - json.object([ 766 - #("query", json.string(query)), 767 - #("variables", variables), 768 - ]) 769 - 770 - use req <- result.try( 771 - request.to(config.api_url) 772 - |> result.map_error(fn(_) { "Failed to create request" }), 773 - ) 774 - 775 - let req = 776 - request.set_method(req, http.Post) 777 - |> request.set_header("content-type", "application/json") 778 - |> request.set_header("X-Slice-Uri", config.slice_uri) 779 - |> request.set_header("Authorization", "Bearer " <> config.access_token) 780 - |> request.set_body(json.to_string(body_json)) 781 - 782 - use resp <- result.try( 783 - httpc.send(req) 784 - |> result.map_error(fn(_) { "HTTP request failed" }), 785 - ) 786 - 787 - case resp.status { 788 - 200 -> { 789 - use data <- result.try( 790 - json.parse(resp.body, decode.dynamic) 791 - |> result.map_error(fn(_) { "Failed to parse JSON response" }), 792 - ) 793 - 794 - let edges_decoder = 795 - decode.at( 796 - ["data", "appBskyActorProfiles", "edges"], 797 - decode.list(decode.dynamic), 798 - ) 799 - 800 - use edges <- result.try( 801 - decode.run(data, edges_decoder) 802 - |> result.map_error(fn(_) { "Failed to extract edges" }), 803 - ) 804 - 805 - case edges { 806 - [] -> Ok(None) 807 - [first_edge, ..] -> { 808 - let display_name = case 809 - decode.run( 810 - first_edge, 811 - decode.at(["node", "displayName"], decode.optional(decode.string)), 812 - ) 813 - { 814 - Ok(val) -> val 815 - Error(_) -> None 816 - } 236 + use response <- result.try(get_bluesky_profile.get_bluesky_profile( 237 + client, 238 + did, 239 + )) 817 240 818 - let description = case 819 - decode.run( 820 - first_edge, 821 - decode.at(["node", "description"], decode.optional(decode.string)), 822 - ) 823 - { 824 - Ok(val) -> val 825 - Error(_) -> None 826 - } 827 - 828 - let avatar = case 829 - decode.run( 830 - first_edge, 831 - decode.at( 832 - ["node", "avatar"], 833 - decode.optional({ 834 - use ref <- decode.field("ref", decode.string) 835 - use mime_type <- decode.field("mimeType", decode.string) 836 - use size <- decode.field("size", decode.int) 837 - decode.success(BlueskyAvatar( 838 - ref: ref, 839 - mime_type: mime_type, 840 - size: size, 841 - )) 842 - }), 843 - ), 844 - ) 845 - { 846 - Ok(val) -> val 847 - Error(_) -> None 848 - } 849 - 850 - Ok(Some(BlueskyProfile( 851 - display_name: display_name, 852 - description: description, 853 - avatar: avatar, 854 - ))) 855 - } 856 - } 857 - } 858 - _ -> 859 - Error( 860 - "API returned status " 861 - <> string.inspect(resp.status) 862 - <> " with body: " 863 - <> resp.body, 864 - ) 241 + case response.app_bsky_actor_profiles.edges { 242 + [] -> Ok(None) 243 + [first_edge, ..] -> Ok(Some(first_edge.node)) 865 244 } 866 245 } 867 246 868 - pub type ProfileInput { 869 - ProfileInput( 870 - display_name: String, 871 - description: Option(String), 872 - avatar: Option(json.Json), 873 - created_at: String, 874 - ) 875 - } 247 + // Re-export the same input type for create operations (it's the same as ProfileUpdate) 248 + pub type ProfileInput = 249 + create_profile_gql.OrgAtmosphereconfProfileInput 876 250 877 251 /// Create a new profile 878 252 pub fn create_profile( 879 253 config: Config, 880 254 input: ProfileInput, 881 255 ) -> Result(Nil, String) { 882 - let mutation = 883 - " 884 - mutation CreateProfile( 885 - $input: OrgAtmosphereconfProfileInput! 886 - $rkey: String 887 - ) { 888 - createOrgAtmosphereconfProfile(input: $input, rkey: $rkey) { 889 - id 890 - } 891 - } 892 - " 893 - 894 - // Build input object 895 - let input_fields = [#("displayName", json.string(input.display_name))] 896 - 897 - let input_fields = case input.description { 898 - Some(val) -> [#("description", json.string(val)), ..input_fields] 899 - None -> input_fields 900 - } 901 - 902 - let input_fields = case input.avatar { 903 - Some(val) -> [#("avatar", val), ..input_fields] 904 - None -> input_fields 905 - } 906 - 907 - let input_fields = [ 908 - #("createdAt", json.string(input.created_at)), 909 - ..input_fields 910 - ] 911 - 912 - let variables = 913 - json.object([ 914 - #("rkey", json.string("self")), 915 - #("input", json.object(input_fields)), 916 - ]) 917 - 918 - let body_json = 919 - json.object([ 920 - #("query", json.string(mutation)), 921 - #("variables", variables), 922 - ]) 923 - 924 - use req <- result.try( 925 - request.to(config.api_url) 926 - |> result.map_error(fn(_) { "Failed to create request" }), 927 - ) 928 - 929 - let req = 930 - request.set_method(req, http.Post) 931 - |> request.set_header("content-type", "application/json") 932 - |> request.set_header("X-Slice-Uri", config.slice_uri) 933 - |> request.set_header("Authorization", "Bearer " <> config.access_token) 934 - |> request.set_body(json.to_string(body_json)) 256 + let client = create_client(config) 935 257 936 - use resp <- result.try( 937 - httpc.send(req) 938 - |> result.map_error(fn(_) { "HTTP request failed" }), 939 - ) 258 + use _response <- result.try(create_profile_gql.create_profile( 259 + client, 260 + input, 261 + "self", 262 + )) 940 263 941 - case resp.status { 942 - 200 -> Ok(Nil) 943 - _ -> 944 - Error( 945 - "Create profile failed with status " 946 - <> string.inspect(resp.status) 947 - <> " with body: " 948 - <> resp.body, 949 - ) 950 - } 264 + Ok(Nil) 951 265 }
+84
server/src/api/graphql/check_profile_exists.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http 3 + import gleam/http/request 4 + import gleam/httpc 5 + import gleam/json 6 + import gleam/list 7 + import gleam/result 8 + import squall 9 + 10 + pub type OrgAtmosphereconfProfileConnection { 11 + OrgAtmosphereconfProfileConnection(edges: List(OrgAtmosphereconfProfileEdge)) 12 + } 13 + 14 + pub fn org_atmosphereconf_profile_connection_decoder() -> decode.Decoder(OrgAtmosphereconfProfileConnection) { 15 + use edges <- decode.field("edges", decode.list(org_atmosphereconf_profile_edge_decoder())) 16 + decode.success(OrgAtmosphereconfProfileConnection(edges: edges)) 17 + } 18 + 19 + pub type OrgAtmosphereconfProfileEdge { 20 + OrgAtmosphereconfProfileEdge(node: OrgAtmosphereconfProfile) 21 + } 22 + 23 + pub fn org_atmosphereconf_profile_edge_decoder() -> decode.Decoder(OrgAtmosphereconfProfileEdge) { 24 + use node <- decode.field("node", org_atmosphereconf_profile_decoder()) 25 + decode.success(OrgAtmosphereconfProfileEdge(node: node)) 26 + } 27 + 28 + pub type OrgAtmosphereconfProfile { 29 + OrgAtmosphereconfProfile(id: String) 30 + } 31 + 32 + pub fn org_atmosphereconf_profile_decoder() -> decode.Decoder(OrgAtmosphereconfProfile) { 33 + use id <- decode.field("id", decode.string) 34 + decode.success(OrgAtmosphereconfProfile(id: id)) 35 + } 36 + 37 + pub type CheckProfileExistsResponse { 38 + CheckProfileExistsResponse( 39 + org_atmosphereconf_profiles: OrgAtmosphereconfProfileConnection, 40 + ) 41 + } 42 + 43 + pub fn check_profile_exists_response_decoder() -> decode.Decoder(CheckProfileExistsResponse) { 44 + use org_atmosphereconf_profiles <- decode.field("orgAtmosphereconfProfiles", org_atmosphereconf_profile_connection_decoder()) 45 + decode.success(CheckProfileExistsResponse( 46 + org_atmosphereconf_profiles: org_atmosphereconf_profiles, 47 + )) 48 + } 49 + 50 + pub fn check_profile_exists(client: squall.Client, did: String) -> Result(CheckProfileExistsResponse, String) { 51 + let query = 52 + "query CheckProfile($did: String!) { orgAtmosphereconfProfiles(where: { did: { eq: $did } }, first: 1) { edges { node { id } } } }" 53 + let variables = 54 + json.object([#("did", json.string(did))]) 55 + let body = 56 + json.object([#("query", json.string(query)), #("variables", variables)]) 57 + use req <- result.try( 58 + request.to(client.endpoint) 59 + |> result.map_error(fn(_) { "Invalid endpoint URL" }), 60 + ) 61 + let req = 62 + req 63 + |> request.set_method(http.Post) 64 + |> request.set_body(json.to_string(body)) 65 + |> request.set_header("content-type", "application/json") 66 + let req = 67 + list.fold(client.headers, req, fn(r, header) { 68 + request.set_header(r, header.0, header.1) 69 + }) 70 + use resp <- result.try( 71 + httpc.send(req) 72 + |> result.map_error(fn(_) { "HTTP request failed" }), 73 + ) 74 + use json_value <- result.try( 75 + json.parse(from: resp.body, using: decode.dynamic) 76 + |> result.map_error(fn(_) { "Failed to decode JSON response" }), 77 + ) 78 + let data_and_response_decoder = { 79 + use data <- decode.field("data", check_profile_exists_response_decoder()) 80 + decode.success(data) 81 + } 82 + decode.run(json_value, data_and_response_decoder) 83 + |> result.map_error(fn(_) { "Failed to decode response data" }) 84 + }
+9
server/src/api/graphql/check_profile_exists.gql
··· 1 + query CheckProfile($did: String!) { 2 + orgAtmosphereconfProfiles(where: { did: { eq: $did } }, first: 1) { 3 + edges { 4 + node { 5 + id 6 + } 7 + } 8 + } 9 + }
+124
server/src/api/graphql/create_profile.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http 3 + import gleam/http/request 4 + import gleam/httpc 5 + import gleam/json 6 + import gleam/list 7 + import gleam/result 8 + import squall 9 + import gleam/option.{type Option, Some, None} 10 + 11 + pub type OrgAtmosphereconfProfileInput { 12 + OrgAtmosphereconfProfileInput( 13 + avatar: Option(json.Json), 14 + created_at: Option(String), 15 + description: Option(String), 16 + display_name: Option(String), 17 + home_town: Option(json.Json), 18 + interests: Option(List(String)), 19 + ) 20 + } 21 + 22 + fn org_atmosphereconf_profile_input_to_json(input: OrgAtmosphereconfProfileInput) -> json.Json { 23 + [{ 24 + case input.avatar { 25 + Some(val) -> Some(#("avatar", val)) 26 + None -> None 27 + } 28 + }, { 29 + case input.created_at { 30 + Some(val) -> Some(#("createdAt", json.string(val))) 31 + None -> None 32 + } 33 + }, { 34 + case input.description { 35 + Some(val) -> Some(#("description", json.string(val))) 36 + None -> None 37 + } 38 + }, { 39 + case input.display_name { 40 + Some(val) -> Some(#("displayName", json.string(val))) 41 + None -> None 42 + } 43 + }, { 44 + case input.home_town { 45 + Some(val) -> Some(#("homeTown", val)) 46 + None -> None 47 + } 48 + }, { 49 + case input.interests { 50 + Some(val) -> Some(#("interests", json.array(from: val, of: json.string))) 51 + None -> None 52 + } 53 + }] 54 + |> list.filter_map(fn(x) { 55 + case x { 56 + Some(val) -> Ok(val) 57 + None -> Error(Nil) 58 + } 59 + }) 60 + |> json.object 61 + } 62 + 63 + pub type OrgAtmosphereconfProfile { 64 + OrgAtmosphereconfProfile(id: String) 65 + } 66 + 67 + pub fn org_atmosphereconf_profile_decoder() -> decode.Decoder(OrgAtmosphereconfProfile) { 68 + use id <- decode.field("id", decode.string) 69 + decode.success(OrgAtmosphereconfProfile(id: id)) 70 + } 71 + 72 + pub type CreateProfileResponse { 73 + CreateProfileResponse( 74 + create_org_atmosphereconf_profile: OrgAtmosphereconfProfile, 75 + ) 76 + } 77 + 78 + pub fn create_profile_response_decoder() -> decode.Decoder(CreateProfileResponse) { 79 + use create_org_atmosphereconf_profile <- decode.field("createOrgAtmosphereconfProfile", org_atmosphereconf_profile_decoder()) 80 + decode.success(CreateProfileResponse( 81 + create_org_atmosphereconf_profile: create_org_atmosphereconf_profile, 82 + )) 83 + } 84 + 85 + pub fn create_profile(client: squall.Client, input: OrgAtmosphereconfProfileInput, rkey: String) -> Result(CreateProfileResponse, String) { 86 + let query = 87 + "mutation CreateProfile($input: OrgAtmosphereconfProfileInput!, $rkey: String) { createOrgAtmosphereconfProfile(input: $input, rkey: $rkey) { id } }" 88 + let variables = 89 + json.object( 90 + [ 91 + #("input", org_atmosphereconf_profile_input_to_json(input)), 92 + #("rkey", json.string(rkey)), 93 + ], 94 + ) 95 + let body = 96 + json.object([#("query", json.string(query)), #("variables", variables)]) 97 + use req <- result.try( 98 + request.to(client.endpoint) 99 + |> result.map_error(fn(_) { "Invalid endpoint URL" }), 100 + ) 101 + let req = 102 + req 103 + |> request.set_method(http.Post) 104 + |> request.set_body(json.to_string(body)) 105 + |> request.set_header("content-type", "application/json") 106 + let req = 107 + list.fold(client.headers, req, fn(r, header) { 108 + request.set_header(r, header.0, header.1) 109 + }) 110 + use resp <- result.try( 111 + httpc.send(req) 112 + |> result.map_error(fn(_) { "HTTP request failed" }), 113 + ) 114 + use json_value <- result.try( 115 + json.parse(from: resp.body, using: decode.dynamic) 116 + |> result.map_error(fn(_) { "Failed to decode JSON response" }), 117 + ) 118 + let data_and_response_decoder = { 119 + use data <- decode.field("data", create_profile_response_decoder()) 120 + decode.success(data) 121 + } 122 + decode.run(json_value, data_and_response_decoder) 123 + |> result.map_error(fn(_) { "Failed to decode response data" }) 124 + }
+5
server/src/api/graphql/create_profile.gql
··· 1 + mutation CreateProfile($input: OrgAtmosphereconfProfileInput!, $rkey: String) { 2 + createOrgAtmosphereconfProfile(input: $input, rkey: $rkey) { 3 + id 4 + } 5 + }
+106
server/src/api/graphql/get_bluesky_profile.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http 3 + import gleam/http/request 4 + import gleam/httpc 5 + import gleam/json 6 + import gleam/list 7 + import gleam/result 8 + import squall 9 + import gleam/option.{type Option} 10 + 11 + pub type AppBskyActorProfileConnection { 12 + AppBskyActorProfileConnection(edges: List(AppBskyActorProfileEdge)) 13 + } 14 + 15 + pub fn app_bsky_actor_profile_connection_decoder() -> decode.Decoder(AppBskyActorProfileConnection) { 16 + use edges <- decode.field("edges", decode.list(app_bsky_actor_profile_edge_decoder())) 17 + decode.success(AppBskyActorProfileConnection(edges: edges)) 18 + } 19 + 20 + pub type AppBskyActorProfileEdge { 21 + AppBskyActorProfileEdge(node: AppBskyActorProfile) 22 + } 23 + 24 + pub fn app_bsky_actor_profile_edge_decoder() -> decode.Decoder(AppBskyActorProfileEdge) { 25 + use node <- decode.field("node", app_bsky_actor_profile_decoder()) 26 + decode.success(AppBskyActorProfileEdge(node: node)) 27 + } 28 + 29 + pub type AppBskyActorProfile { 30 + AppBskyActorProfile( 31 + display_name: Option(String), 32 + description: Option(String), 33 + avatar: Option(Blob), 34 + ) 35 + } 36 + 37 + pub fn app_bsky_actor_profile_decoder() -> decode.Decoder(AppBskyActorProfile) { 38 + use display_name <- decode.field("displayName", decode.optional(decode.string)) 39 + use description <- decode.field("description", decode.optional(decode.string)) 40 + use avatar <- decode.field("avatar", decode.optional(blob_decoder())) 41 + decode.success(AppBskyActorProfile( 42 + display_name: display_name, 43 + description: description, 44 + avatar: avatar, 45 + )) 46 + } 47 + 48 + pub type Blob { 49 + Blob(ref: String, mime_type: String, size: Int) 50 + } 51 + 52 + pub fn blob_decoder() -> decode.Decoder(Blob) { 53 + use ref <- decode.field("ref", decode.string) 54 + use mime_type <- decode.field("mimeType", decode.string) 55 + use size <- decode.field("size", decode.int) 56 + decode.success(Blob(ref: ref, mime_type: mime_type, size: size)) 57 + } 58 + 59 + pub type GetBlueskyProfileResponse { 60 + GetBlueskyProfileResponse( 61 + app_bsky_actor_profiles: AppBskyActorProfileConnection, 62 + ) 63 + } 64 + 65 + pub fn get_bluesky_profile_response_decoder() -> decode.Decoder(GetBlueskyProfileResponse) { 66 + use app_bsky_actor_profiles <- decode.field("appBskyActorProfiles", app_bsky_actor_profile_connection_decoder()) 67 + decode.success(GetBlueskyProfileResponse( 68 + app_bsky_actor_profiles: app_bsky_actor_profiles, 69 + )) 70 + } 71 + 72 + pub fn get_bluesky_profile(client: squall.Client, did: String) -> Result(GetBlueskyProfileResponse, String) { 73 + let query = 74 + "query GetBskyProfile($did: String!) { appBskyActorProfiles(where: { did: { eq: $did } }, first: 1) { edges { node { displayName description avatar { ref mimeType size } } } } }" 75 + let variables = 76 + json.object([#("did", json.string(did))]) 77 + let body = 78 + json.object([#("query", json.string(query)), #("variables", variables)]) 79 + use req <- result.try( 80 + request.to(client.endpoint) 81 + |> result.map_error(fn(_) { "Invalid endpoint URL" }), 82 + ) 83 + let req = 84 + req 85 + |> request.set_method(http.Post) 86 + |> request.set_body(json.to_string(body)) 87 + |> request.set_header("content-type", "application/json") 88 + let req = 89 + list.fold(client.headers, req, fn(r, header) { 90 + request.set_header(r, header.0, header.1) 91 + }) 92 + use resp <- result.try( 93 + httpc.send(req) 94 + |> result.map_error(fn(_) { "HTTP request failed" }), 95 + ) 96 + use json_value <- result.try( 97 + json.parse(from: resp.body, using: decode.dynamic) 98 + |> result.map_error(fn(_) { "Failed to decode JSON response" }), 99 + ) 100 + let data_and_response_decoder = { 101 + use data <- decode.field("data", get_bluesky_profile_response_decoder()) 102 + decode.success(data) 103 + } 104 + decode.run(json_value, data_and_response_decoder) 105 + |> result.map_error(fn(_) { "Failed to decode response data" }) 106 + }
+15
server/src/api/graphql/get_bluesky_profile.gql
··· 1 + query GetBskyProfile($did: String!) { 2 + appBskyActorProfiles(where: { did: { eq: $did } }, first: 1) { 3 + edges { 4 + node { 5 + displayName 6 + description 7 + avatar { 8 + ref 9 + mimeType 10 + size 11 + } 12 + } 13 + } 14 + } 15 + }
+144
server/src/api/graphql/get_profile.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http 3 + import gleam/http/request 4 + import gleam/httpc 5 + import gleam/json 6 + import gleam/list 7 + import gleam/result 8 + import squall 9 + import gleam/option.{type Option} 10 + 11 + pub type OrgAtmosphereconfProfileConnection { 12 + OrgAtmosphereconfProfileConnection(edges: List(OrgAtmosphereconfProfileEdge)) 13 + } 14 + 15 + pub fn org_atmosphereconf_profile_connection_decoder() -> decode.Decoder(OrgAtmosphereconfProfileConnection) { 16 + use edges <- decode.field("edges", decode.list(org_atmosphereconf_profile_edge_decoder())) 17 + decode.success(OrgAtmosphereconfProfileConnection(edges: edges)) 18 + } 19 + 20 + pub type OrgAtmosphereconfProfileEdge { 21 + OrgAtmosphereconfProfileEdge(node: OrgAtmosphereconfProfile) 22 + } 23 + 24 + pub fn org_atmosphereconf_profile_edge_decoder() -> decode.Decoder(OrgAtmosphereconfProfileEdge) { 25 + use node <- decode.field("node", org_atmosphereconf_profile_decoder()) 26 + decode.success(OrgAtmosphereconfProfileEdge(node: node)) 27 + } 28 + 29 + pub type OrgAtmosphereconfProfile { 30 + OrgAtmosphereconfProfile( 31 + id: String, 32 + uri: String, 33 + cid: String, 34 + did: String, 35 + actor_handle: Option(String), 36 + display_name: Option(String), 37 + description: Option(String), 38 + avatar: Option(Blob), 39 + home_town: Option(CommunityLexiconLocationHthree), 40 + interests: Option(List(String)), 41 + created_at: Option(String), 42 + indexed_at: String, 43 + ) 44 + } 45 + 46 + pub fn org_atmosphereconf_profile_decoder() -> decode.Decoder(OrgAtmosphereconfProfile) { 47 + use id <- decode.field("id", decode.string) 48 + use uri <- decode.field("uri", decode.string) 49 + use cid <- decode.field("cid", decode.string) 50 + use did <- decode.field("did", decode.string) 51 + use actor_handle <- decode.field("actorHandle", decode.optional(decode.string)) 52 + use display_name <- decode.field("displayName", decode.optional(decode.string)) 53 + use description <- decode.field("description", decode.optional(decode.string)) 54 + use avatar <- decode.field("avatar", decode.optional(blob_decoder())) 55 + use home_town <- decode.field("homeTown", decode.optional(community_lexicon_location_hthree_decoder())) 56 + use interests <- decode.field("interests", decode.optional(decode.list(decode.string))) 57 + use created_at <- decode.field("createdAt", decode.optional(decode.string)) 58 + use indexed_at <- decode.field("indexedAt", decode.string) 59 + decode.success(OrgAtmosphereconfProfile( 60 + id: id, 61 + uri: uri, 62 + cid: cid, 63 + did: did, 64 + actor_handle: actor_handle, 65 + display_name: display_name, 66 + description: description, 67 + avatar: avatar, 68 + home_town: home_town, 69 + interests: interests, 70 + created_at: created_at, 71 + indexed_at: indexed_at, 72 + )) 73 + } 74 + 75 + pub type Blob { 76 + Blob(ref: String, mime_type: String, size: Int, url: String) 77 + } 78 + 79 + pub fn blob_decoder() -> decode.Decoder(Blob) { 80 + use ref <- decode.field("ref", decode.string) 81 + use mime_type <- decode.field("mimeType", decode.string) 82 + use size <- decode.field("size", decode.int) 83 + use url <- decode.field("url", decode.string) 84 + decode.success(Blob(ref: ref, mime_type: mime_type, size: size, url: url)) 85 + } 86 + 87 + pub type CommunityLexiconLocationHthree { 88 + CommunityLexiconLocationHthree(name: Option(String), value: Option(String)) 89 + } 90 + 91 + pub fn community_lexicon_location_hthree_decoder() -> decode.Decoder(CommunityLexiconLocationHthree) { 92 + use name <- decode.field("name", decode.optional(decode.string)) 93 + use value <- decode.field("value", decode.optional(decode.string)) 94 + decode.success(CommunityLexiconLocationHthree(name: name, value: value)) 95 + } 96 + 97 + pub type GetProfileResponse { 98 + GetProfileResponse( 99 + org_atmosphereconf_profiles: OrgAtmosphereconfProfileConnection, 100 + ) 101 + } 102 + 103 + pub fn get_profile_response_decoder() -> decode.Decoder(GetProfileResponse) { 104 + use org_atmosphereconf_profiles <- decode.field("orgAtmosphereconfProfiles", org_atmosphereconf_profile_connection_decoder()) 105 + decode.success(GetProfileResponse( 106 + org_atmosphereconf_profiles: org_atmosphereconf_profiles, 107 + )) 108 + } 109 + 110 + pub fn get_profile(client: squall.Client, handle: String) -> Result(GetProfileResponse, String) { 111 + let query = 112 + "query GetProfile($handle: String!) { orgAtmosphereconfProfiles(where: { actorHandle: { eq: $handle } }, first: 1) { edges { node { id uri cid did actorHandle displayName description avatar { ref mimeType size url(preset: \"avatar\") } homeTown { name value } interests createdAt indexedAt } } } }" 113 + let variables = 114 + json.object([#("handle", json.string(handle))]) 115 + let body = 116 + json.object([#("query", json.string(query)), #("variables", variables)]) 117 + use req <- result.try( 118 + request.to(client.endpoint) 119 + |> result.map_error(fn(_) { "Invalid endpoint URL" }), 120 + ) 121 + let req = 122 + req 123 + |> request.set_method(http.Post) 124 + |> request.set_body(json.to_string(body)) 125 + |> request.set_header("content-type", "application/json") 126 + let req = 127 + list.fold(client.headers, req, fn(r, header) { 128 + request.set_header(r, header.0, header.1) 129 + }) 130 + use resp <- result.try( 131 + httpc.send(req) 132 + |> result.map_error(fn(_) { "HTTP request failed" }), 133 + ) 134 + use json_value <- result.try( 135 + json.parse(from: resp.body, using: decode.dynamic) 136 + |> result.map_error(fn(_) { "Failed to decode JSON response" }), 137 + ) 138 + let data_and_response_decoder = { 139 + use data <- decode.field("data", get_profile_response_decoder()) 140 + decode.success(data) 141 + } 142 + decode.run(json_value, data_and_response_decoder) 143 + |> result.map_error(fn(_) { "Failed to decode response data" }) 144 + }
+31
server/src/api/graphql/get_profile.gql
··· 1 + query GetProfile($handle: String!) { 2 + orgAtmosphereconfProfiles( 3 + where: { actorHandle: { eq: $handle } } 4 + first: 1 5 + ) { 6 + edges { 7 + node { 8 + id 9 + uri 10 + cid 11 + did 12 + actorHandle 13 + displayName 14 + description 15 + avatar { 16 + ref 17 + mimeType 18 + size 19 + url(preset: "avatar") 20 + } 21 + homeTown { 22 + name 23 + value 24 + } 25 + interests 26 + createdAt 27 + indexedAt 28 + } 29 + } 30 + } 31 + }
+65
server/src/api/graphql/sync_user_collections.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http 3 + import gleam/http/request 4 + import gleam/httpc 5 + import gleam/json 6 + import gleam/list 7 + import gleam/result 8 + import squall 9 + 10 + pub type SyncResult { 11 + SyncResult(success: Bool, message: String) 12 + } 13 + 14 + pub fn sync_result_decoder() -> decode.Decoder(SyncResult) { 15 + use success <- decode.field("success", decode.bool) 16 + use message <- decode.field("message", decode.string) 17 + decode.success(SyncResult(success: success, message: message)) 18 + } 19 + 20 + pub type SyncUserCollectionsResponse { 21 + SyncUserCollectionsResponse(sync_user_collections: SyncResult) 22 + } 23 + 24 + pub fn sync_user_collections_response_decoder() -> decode.Decoder(SyncUserCollectionsResponse) { 25 + use sync_user_collections <- decode.field("syncUserCollections", sync_result_decoder()) 26 + decode.success(SyncUserCollectionsResponse( 27 + sync_user_collections: sync_user_collections, 28 + )) 29 + } 30 + 31 + pub fn sync_user_collections(client: squall.Client, did: String) -> Result(SyncUserCollectionsResponse, String) { 32 + let query = 33 + "mutation SyncUserCollections($did: String!) { syncUserCollections(did: $did) { success message } }" 34 + let variables = 35 + json.object([#("did", json.string(did))]) 36 + let body = 37 + json.object([#("query", json.string(query)), #("variables", variables)]) 38 + use req <- result.try( 39 + request.to(client.endpoint) 40 + |> result.map_error(fn(_) { "Invalid endpoint URL" }), 41 + ) 42 + let req = 43 + req 44 + |> request.set_method(http.Post) 45 + |> request.set_body(json.to_string(body)) 46 + |> request.set_header("content-type", "application/json") 47 + let req = 48 + list.fold(client.headers, req, fn(r, header) { 49 + request.set_header(r, header.0, header.1) 50 + }) 51 + use resp <- result.try( 52 + httpc.send(req) 53 + |> result.map_error(fn(_) { "HTTP request failed" }), 54 + ) 55 + use json_value <- result.try( 56 + json.parse(from: resp.body, using: decode.dynamic) 57 + |> result.map_error(fn(_) { "Failed to decode JSON response" }), 58 + ) 59 + let data_and_response_decoder = { 60 + use data <- decode.field("data", sync_user_collections_response_decoder()) 61 + decode.success(data) 62 + } 63 + decode.run(json_value, data_and_response_decoder) 64 + |> result.map_error(fn(_) { "Failed to decode response data" }) 65 + }
+6
server/src/api/graphql/sync_user_collections.gql
··· 1 + mutation SyncUserCollections($did: String!) { 2 + syncUserCollections(did: $did) { 3 + success 4 + message 5 + } 6 + }
+183
server/src/api/graphql/update_profile.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http 3 + import gleam/http/request 4 + import gleam/httpc 5 + import gleam/json 6 + import gleam/list 7 + import gleam/result 8 + import squall 9 + import gleam/option.{type Option, Some, None} 10 + 11 + pub type OrgAtmosphereconfProfileInput { 12 + OrgAtmosphereconfProfileInput( 13 + avatar: Option(json.Json), 14 + created_at: Option(String), 15 + description: Option(String), 16 + display_name: Option(String), 17 + home_town: Option(json.Json), 18 + interests: Option(List(String)), 19 + ) 20 + } 21 + 22 + fn org_atmosphereconf_profile_input_to_json(input: OrgAtmosphereconfProfileInput) -> json.Json { 23 + [{ 24 + case input.avatar { 25 + Some(val) -> Some(#("avatar", val)) 26 + None -> None 27 + } 28 + }, { 29 + case input.created_at { 30 + Some(val) -> Some(#("createdAt", json.string(val))) 31 + None -> None 32 + } 33 + }, { 34 + case input.description { 35 + Some(val) -> Some(#("description", json.string(val))) 36 + None -> None 37 + } 38 + }, { 39 + case input.display_name { 40 + Some(val) -> Some(#("displayName", json.string(val))) 41 + None -> None 42 + } 43 + }, { 44 + case input.home_town { 45 + Some(val) -> Some(#("homeTown", val)) 46 + None -> None 47 + } 48 + }, { 49 + case input.interests { 50 + Some(val) -> Some(#("interests", json.array(from: val, of: json.string))) 51 + None -> None 52 + } 53 + }] 54 + |> list.filter_map(fn(x) { 55 + case x { 56 + Some(val) -> Ok(val) 57 + None -> Error(Nil) 58 + } 59 + }) 60 + |> json.object 61 + } 62 + 63 + pub type OrgAtmosphereconfProfile { 64 + OrgAtmosphereconfProfile( 65 + id: String, 66 + uri: String, 67 + cid: String, 68 + did: String, 69 + actor_handle: Option(String), 70 + display_name: Option(String), 71 + description: Option(String), 72 + avatar: Option(Blob), 73 + home_town: Option(CommunityLexiconLocationHthree), 74 + interests: Option(List(String)), 75 + created_at: Option(String), 76 + indexed_at: String, 77 + ) 78 + } 79 + 80 + pub fn org_atmosphereconf_profile_decoder() -> decode.Decoder(OrgAtmosphereconfProfile) { 81 + use id <- decode.field("id", decode.string) 82 + use uri <- decode.field("uri", decode.string) 83 + use cid <- decode.field("cid", decode.string) 84 + use did <- decode.field("did", decode.string) 85 + use actor_handle <- decode.field("actorHandle", decode.optional(decode.string)) 86 + use display_name <- decode.field("displayName", decode.optional(decode.string)) 87 + use description <- decode.field("description", decode.optional(decode.string)) 88 + use avatar <- decode.field("avatar", decode.optional(blob_decoder())) 89 + use home_town <- decode.field("homeTown", decode.optional(community_lexicon_location_hthree_decoder())) 90 + use interests <- decode.field("interests", decode.optional(decode.list(decode.string))) 91 + use created_at <- decode.field("createdAt", decode.optional(decode.string)) 92 + use indexed_at <- decode.field("indexedAt", decode.string) 93 + decode.success(OrgAtmosphereconfProfile( 94 + id: id, 95 + uri: uri, 96 + cid: cid, 97 + did: did, 98 + actor_handle: actor_handle, 99 + display_name: display_name, 100 + description: description, 101 + avatar: avatar, 102 + home_town: home_town, 103 + interests: interests, 104 + created_at: created_at, 105 + indexed_at: indexed_at, 106 + )) 107 + } 108 + 109 + pub type Blob { 110 + Blob(ref: String, mime_type: String, size: Int, url: String) 111 + } 112 + 113 + pub fn blob_decoder() -> decode.Decoder(Blob) { 114 + use ref <- decode.field("ref", decode.string) 115 + use mime_type <- decode.field("mimeType", decode.string) 116 + use size <- decode.field("size", decode.int) 117 + use url <- decode.field("url", decode.string) 118 + decode.success(Blob(ref: ref, mime_type: mime_type, size: size, url: url)) 119 + } 120 + 121 + pub type CommunityLexiconLocationHthree { 122 + CommunityLexiconLocationHthree(name: Option(String), value: Option(String)) 123 + } 124 + 125 + pub fn community_lexicon_location_hthree_decoder() -> decode.Decoder(CommunityLexiconLocationHthree) { 126 + use name <- decode.field("name", decode.optional(decode.string)) 127 + use value <- decode.field("value", decode.optional(decode.string)) 128 + decode.success(CommunityLexiconLocationHthree(name: name, value: value)) 129 + } 130 + 131 + pub type UpdateProfileResponse { 132 + UpdateProfileResponse( 133 + update_org_atmosphereconf_profile: OrgAtmosphereconfProfile, 134 + ) 135 + } 136 + 137 + pub fn update_profile_response_decoder() -> decode.Decoder(UpdateProfileResponse) { 138 + use update_org_atmosphereconf_profile <- decode.field("updateOrgAtmosphereconfProfile", org_atmosphereconf_profile_decoder()) 139 + decode.success(UpdateProfileResponse( 140 + update_org_atmosphereconf_profile: update_org_atmosphereconf_profile, 141 + )) 142 + } 143 + 144 + pub fn update_profile(client: squall.Client, rkey: String, input: OrgAtmosphereconfProfileInput) -> Result(UpdateProfileResponse, String) { 145 + let query = 146 + "mutation UpdateProfile($rkey: String!, $input: OrgAtmosphereconfProfileInput!) { updateOrgAtmosphereconfProfile(rkey: $rkey, input: $input) { id uri cid did actorHandle displayName description avatar { ref mimeType size url } homeTown { name value } interests createdAt indexedAt } }" 147 + let variables = 148 + json.object( 149 + [ 150 + #("rkey", json.string(rkey)), 151 + #("input", org_atmosphereconf_profile_input_to_json(input)), 152 + ], 153 + ) 154 + let body = 155 + json.object([#("query", json.string(query)), #("variables", variables)]) 156 + use req <- result.try( 157 + request.to(client.endpoint) 158 + |> result.map_error(fn(_) { "Invalid endpoint URL" }), 159 + ) 160 + let req = 161 + req 162 + |> request.set_method(http.Post) 163 + |> request.set_body(json.to_string(body)) 164 + |> request.set_header("content-type", "application/json") 165 + let req = 166 + list.fold(client.headers, req, fn(r, header) { 167 + request.set_header(r, header.0, header.1) 168 + }) 169 + use resp <- result.try( 170 + httpc.send(req) 171 + |> result.map_error(fn(_) { "HTTP request failed" }), 172 + ) 173 + use json_value <- result.try( 174 + json.parse(from: resp.body, using: decode.dynamic) 175 + |> result.map_error(fn(_) { "Failed to decode JSON response" }), 176 + ) 177 + let data_and_response_decoder = { 178 + use data <- decode.field("data", update_profile_response_decoder()) 179 + decode.success(data) 180 + } 181 + decode.run(json_value, data_and_response_decoder) 182 + |> result.map_error(fn(_) { "Failed to decode response data" }) 183 + }
+24
server/src/api/graphql/update_profile.gql
··· 1 + mutation UpdateProfile($rkey: String!, $input: OrgAtmosphereconfProfileInput!) { 2 + updateOrgAtmosphereconfProfile(rkey: $rkey, input: $input) { 3 + id 4 + uri 5 + cid 6 + did 7 + actorHandle 8 + displayName 9 + description 10 + avatar { 11 + ref 12 + mimeType 13 + size 14 + url 15 + } 16 + homeTown { 17 + name 18 + value 19 + } 20 + interests 21 + createdAt 22 + indexedAt 23 + } 24 + }
+78
server/src/api/graphql/upload_blob.gleam
··· 1 + import gleam/dynamic/decode 2 + import gleam/http 3 + import gleam/http/request 4 + import gleam/httpc 5 + import gleam/json 6 + import gleam/list 7 + import gleam/result 8 + import squall 9 + 10 + pub type BlobUploadResponse { 11 + BlobUploadResponse(blob: Blob) 12 + } 13 + 14 + pub fn blob_upload_response_decoder() -> decode.Decoder(BlobUploadResponse) { 15 + use blob <- decode.field("blob", blob_decoder()) 16 + decode.success(BlobUploadResponse(blob: blob)) 17 + } 18 + 19 + pub type Blob { 20 + Blob(ref: String, mime_type: String, size: Int) 21 + } 22 + 23 + pub fn blob_decoder() -> decode.Decoder(Blob) { 24 + use ref <- decode.field("ref", decode.string) 25 + use mime_type <- decode.field("mimeType", decode.string) 26 + use size <- decode.field("size", decode.int) 27 + decode.success(Blob(ref: ref, mime_type: mime_type, size: size)) 28 + } 29 + 30 + pub type UploadBlobResponse { 31 + UploadBlobResponse(upload_blob: BlobUploadResponse) 32 + } 33 + 34 + pub fn upload_blob_response_decoder() -> decode.Decoder(UploadBlobResponse) { 35 + use upload_blob <- decode.field("uploadBlob", blob_upload_response_decoder()) 36 + decode.success(UploadBlobResponse(upload_blob: upload_blob)) 37 + } 38 + 39 + pub fn upload_blob(client: squall.Client, data: String, mime_type: String) -> Result(UploadBlobResponse, String) { 40 + let query = 41 + "mutation UploadBlob($data: String!, $mimeType: String!) { uploadBlob(data: $data, mimeType: $mimeType) { blob { ref mimeType size } } }" 42 + let variables = 43 + json.object( 44 + [ 45 + #("data", json.string(data)), 46 + #("mimeType", json.string(mime_type)), 47 + ], 48 + ) 49 + let body = 50 + json.object([#("query", json.string(query)), #("variables", variables)]) 51 + use req <- result.try( 52 + request.to(client.endpoint) 53 + |> result.map_error(fn(_) { "Invalid endpoint URL" }), 54 + ) 55 + let req = 56 + req 57 + |> request.set_method(http.Post) 58 + |> request.set_body(json.to_string(body)) 59 + |> request.set_header("content-type", "application/json") 60 + let req = 61 + list.fold(client.headers, req, fn(r, header) { 62 + request.set_header(r, header.0, header.1) 63 + }) 64 + use resp <- result.try( 65 + httpc.send(req) 66 + |> result.map_error(fn(_) { "HTTP request failed" }), 67 + ) 68 + use json_value <- result.try( 69 + json.parse(from: resp.body, using: decode.dynamic) 70 + |> result.map_error(fn(_) { "Failed to decode JSON response" }), 71 + ) 72 + let data_and_response_decoder = { 73 + use data <- decode.field("data", upload_blob_response_decoder()) 74 + decode.success(data) 75 + } 76 + decode.run(json_value, data_and_response_decoder) 77 + |> result.map_error(fn(_) { "Failed to decode response data" }) 78 + }
+9
server/src/api/graphql/upload_blob.gql
··· 1 + mutation UploadBlob($data: String!, $mimeType: String!) { 2 + uploadBlob(data: $data, mimeType: $mimeType) { 3 + blob { 4 + ref 5 + mimeType 6 + size 7 + } 8 + } 9 + }
+14 -12
server/src/api/profile_init.gleam
··· 1 1 import api/graphql 2 + import api/graphql/create_profile as create_profile_gql 2 3 import birl 3 4 import gleam/io 4 5 import gleam/json ··· 60 61 Ok(Some(profile)) -> 61 62 case profile.avatar { 62 63 Some(avatar_blob) -> 63 - Some(json.object([ 64 - #("$type", json.string("blob")), 65 - #( 66 - "ref", 67 - json.object([#("$link", json.string(avatar_blob.ref))]), 68 - ), 69 - #("mimeType", json.string(avatar_blob.mime_type)), 70 - #("size", json.int(avatar_blob.size)), 71 - ])) 64 + Some( 65 + json.object([ 66 + #("$type", json.string("blob")), 67 + #("ref", json.string(avatar_blob.ref)), 68 + #("mimeType", json.string(avatar_blob.mime_type)), 69 + #("size", json.int(avatar_blob.size)), 70 + ]), 71 + ) 72 72 None -> None 73 73 } 74 74 _ -> None ··· 78 78 let created_at = birl.to_iso8601(now) 79 79 80 80 let profile_input = 81 - graphql.ProfileInput( 82 - display_name: display_name, 81 + create_profile_gql.OrgAtmosphereconfProfileInput( 82 + display_name: Some(display_name), 83 83 description: description, 84 84 avatar: avatar, 85 - created_at: created_at, 85 + created_at: Some(created_at), 86 + home_town: None, 87 + interests: None, 86 88 ) 87 89 88 90 case graphql.create_profile(config, profile_input) {
+19 -3
server/src/server.gleam
··· 1 1 import api/graphql 2 + import api/graphql/update_profile as update_profile_gql 2 3 import api/profile_init 3 4 import dotenv_gleam 4 5 import envoy ··· 449 450 Error(_) -> None 450 451 } 451 452 453 + let created_at = case 454 + decode.run( 455 + parsed, 456 + decode.at(["created_at"], decode.optional(decode.string)), 457 + ) 458 + { 459 + Ok(val) -> val 460 + Error(_) -> None 461 + } 462 + 452 463 Ok(#( 453 - graphql.ProfileUpdate( 464 + update_profile_gql.OrgAtmosphereconfProfileInput( 454 465 display_name: display_name, 455 466 description: description, 456 467 home_town: home_town, 457 468 interests: interests, 458 469 avatar: None, 470 + created_at: created_at, 459 471 ), 460 472 avatar_base64, 461 473 avatar_mime_type, ··· 487 499 Some( 488 500 json.object([ 489 501 #("$type", json.string("blob")), 490 - #("ref", json.object([#("$link", json.string(blob.ref))])), 502 + #("ref", json.string(blob.ref)), 491 503 #("mimeType", json.string(blob.mime_type)), 492 504 #("size", json.int(blob.size)), 493 505 ]), ··· 502 514 } 503 515 504 516 // Create final update with avatar blob if available 505 - let final_update = graphql.ProfileUpdate(..update, avatar: avatar_blob) 517 + let final_update = 518 + update_profile_gql.OrgAtmosphereconfProfileInput( 519 + ..update, 520 + avatar: avatar_blob, 521 + ) 506 522 507 523 case graphql.update_profile(config, handle, final_update) { 508 524 Ok(updated_profile) -> {
+5
shared/src/shared/profile.gleam
··· 26 26 avatar_blob: Option(AvatarBlob), 27 27 home_town: Option(HomeTown), 28 28 interests: Option(List(String)), 29 + created_at: Option(String), 29 30 indexed_at: String, 30 31 ) 31 32 } ··· 61 62 "interests", 62 63 decode.optional(decode.list(decode.string)), 63 64 ) 65 + use created_at <- decode.field("created_at", decode.optional(decode.string)) 64 66 use indexed_at <- decode.field("indexed_at", decode.string) 65 67 decode.success(Profile( 66 68 id:, ··· 74 76 avatar_blob:, 75 77 home_town:, 76 78 interests:, 79 + created_at:, 77 80 indexed_at:, 78 81 )) 79 82 } ··· 91 94 avatar_blob:, 92 95 home_town:, 93 96 interests:, 97 + created_at:, 94 98 indexed_at:, 95 99 ) = profile 96 100 ··· 126 130 "interests", 127 131 json.nullable(interests, fn(list) { json.array(list, json.string) }), 128 132 ), 133 + #("created_at", json.nullable(created_at, json.string)), 129 134 #("indexed_at", json.string(indexed_at)), 130 135 ]) 131 136 }